SwiftUI 自定义图形:我如何画出渐变进度环和聊天气泡
SwiftUI 自定义图形:我如何画出渐变进度环和聊天气泡
App 需要一个渐变色的环形进度条,第一反应是用 UIKit 的 CAShapeLayer,但 SwiftUI 其实可以用 Shape 协议实现更简洁的方案。
Shape 协议基础
Shape 是 View 的特例,返回 Path:
protocol Shape {
func path(in rect: CGRect) -> Path
}
实现一个最简单的圆形:
struct CircleShape: Shape {
func path(in rect: CGRect) -> Path {
Path(ellipseIn: rect)
}
}
Shape 相比直接用 Path 的优势:
- 可以用
.fill()和.stroke()直接渲染 - 支持
Animatable协议 - 可以作为
View的修饰目标
渐变进度环
核心思路:用 trim 属性控制显示比例,用 stroke 绘制弧线:
struct GradientProgressRing: View {
let progress: Double
let lineWidth: CGFloat
@State private var animatedProgress: Double = 0
var body: some View {
ZStack {
// 背景环
Circle()
.stroke(Color.gray.opacity(0.2), lineWidth: lineWidth)
// 前景渐变环
Circle()
.trim(from: 0, to: animatedProgress)
.stroke(
AngularGradient(
gradient: Gradient(colors: [.blue, .purple, .pink, .blue]),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.8), value: animatedProgress)
}
.onAppear {
animatedProgress = progress
}
.onChange(of: progress) { _, newValue in
animatedProgress = newValue
}
}
}
关键点:trim(from:to:) 的 to 值范围是 0-1,1 代表完整一圈。旋转 -90 度让起点从顶部开始。
用 AnimatableModifier 做动画数值插值
要让进度变化时自动动画,需要让 progress 符合 Animatable:
struct AnimatableGradientRing: View, Animatable {
@State private var animatedProgress: Double = 0
var progress: Double
var animatableData: Double {
get { animatedProgress }
set { animatedProgress = newValue }
}
var body: some View {
ZStack {
Circle()
.stroke(Color.gray.opacity(0.2), lineWidth: 12)
Circle()
.trim(from: 0, to: animatedProgress)
.stroke(
LinearGradient(
gradient: Gradient(colors: [.blue, .purple]),
startPoint: .leading,
endPoint: .trailing
),
style: StrokeStyle(lineWidth: 12, lineCap: .round)
)
.rotationEffect(.degrees(-90))
}
.onAppear {
animatedProgress = progress
}
}
}
这样修改 progress 时,SwiftUI 会自动做数值插值动画:
struct UsageExample: View {
@State private var progress: Double = 0.2
var body: some View {
VStack {
AnimatableGradientRing(progress: progress)
.frame(width: 200, height: 200)
Slider(value: $progress, in: 0...1)
}
}
}
聊天气泡:Path 实战
聊天气泡的关键是"尾巴"部分,用 Path 手动绘制:
struct ChatBubble: View {
let message: String
let isOutgoing: Bool
var body: some View {
HStack {
if isOutgoing { Spacer(minLength: 60) }
Text(message)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
ChatBubbleShape(isOutgoing: isOutgoing)
.fill(isOutgoing ? Color.blue : Color.gray.opacity(0.3))
)
.foregroundStyle(isOutgoing ? .white : .primary)
if !isOutgoing { Spacer(minLength: 60) }
}
}
}
struct ChatBubbleShape: Shape {
let isOutgoing: Bool
func path(in rect: CGRect) -> Path {
var path = Path()
let radius: CGFloat = 16
let tailSize: CGFloat = 8
// 主气泡:圆角矩形
let bubbleRect = isOutgoing
? rect.insetBy(dx: 0, dy: tailSize)
: rect.insetBy(dx: 0, dy: tailSize)
// 左上角开始,顺时针绘制
path.addRoundedRect(
in: CGRect(x: 0, y: 0, width: rect.width, height: rect.height - tailSize),
cornerSize: CGSize(width: radius, height: radius)
)
// 气泡尾巴
let tailX: CGFloat
let tailY = rect.height - tailSize
if isOutgoing {
tailX = rect.width - 30
path.move(to: CGPoint(x: tailX, y: tailY))
path.addQuadCurve(
to: CGPoint(x: tailX - 10, y: tailY + tailSize),
control: CGPoint(x: tailX, y: tailY + tailSize)
)
path.addQuadCurve(
to: CGPoint(x: tailX - 20, y: tailY),
control: CGPoint(x: tailX - 10, y: tailY)
)
} else {
tailX = 30
path.move(to: CGPoint(x: tailX, y: tailY))
path.addQuadCurve(
to: CGPoint(x: tailX + 10, y: tailY + tailSize),
control: CGPoint(x: tailX, y: tailY + tailSize)
)
path.addQuadCurve(
to: CGPoint(x: tailX + 20, y: tailY),
control: CGPoint(x: tailX + 10, y: tailY)
)
}
return path
}
}
更简洁的气泡方案:Mask + Overlay
不想手写 Path,可以用 mask 叠加的思路:
struct MaskedChatBubble: View {
let message: String
let isOutgoing: Bool
var body: some View {
HStack {
if isOutgoing { Spacer(minLength: 40) }
Text(message)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(isOutgoing ? Color.blue : Color.gray.opacity(0.3))
)
.overlay(alignment: isOutgoing ? .bottomTrailing : .bottomLeading) {
Triangle()
.fill(isOutgoing ? Color.blue : Color.gray.opacity(0.3))
.offset(x: isOutgoing ? 4 : -4, y: 4)
}
.foregroundStyle(isOutgoing ? .white : .primary)
.clipShape(ChatBubbleMask(isOutgoing: isOutgoing))
if !isOutgoing { Spacer(minLength: 40) }
}
}
}
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.closeSubpath()
return path
}
}
struct ChatBubbleMask: Shape {
let isOutgoing: Bool
func path(in rect: CGRect) -> Path {
var path = RoundedRectangle(cornerRadius: 18).path(in: rect)
let tailPath = Triangle().path(in: CGRect(
x: isOutgoing ? rect.width - 20 : 0,
y: rect.height - 12,
width: 16,
height: 12
))
return path
}
}
复杂图形:用 CGPath 转换
如果已经有设计稿导出的 SVG/Path,可以用 UIBezierPath 转 Path:
extension Path {
init(cgPath: CGPath) {
self.init()
self.addPath(cgPath)
}
}
// 从 UIBezierPath 创建
extension Path {
init(bezierPath: UIBezierPath) {
self.init(cgPath: bezierPath.cgPath)
}
}
然后在设计工具里导出 path data,填进去:
struct CustomLogoShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
// 设计工具导出的 path data
path.move(to: CGPoint(x: 10, y: 10))
path.addQuadCurve(to: CGPoint(x: 50, y: 50), control: CGPoint(x: 30, y: 10))
// ...
return path
}
}
Shape 的复用:Inset 变体
同一个图形可能需要内嵌版本(比如边框样式),可以用 inset:
struct InsetCircle: Shape {
let inset: CGFloat
func path(in rect: CGRect) -> Path {
let insetRect = rect.insetBy(dx: inset, dy: inset)
return Circle().path(in: insetRect)
}
}
// 使用
Circle().stroke(lineWidth: 2) // 描边
InsetCircle(inset: 2).fill() // 填充(视觉效果和上面相同)
总结
SwiftUI 的 Shape 协议非常强大:
- 进度环:用
trim控制比例,stroke绘制弧线,AngularGradient做渐变 - 动画数值:
Animatable协议让 progress 变化自动产生插值动画 - 聊天气泡:用
Path绘制气泡主体和尾巴,或用 mask + overlay 的组合方案 - 路径复用:
inset修饰符可以基于同一图形创建变体 - 与设计工具集成:可以用
UIBezierPath作为中间格式导入设计稿
图形绘制本质上是数学问题,Path 则是 SwiftUI 提供的瑞士军刀。掌握了基本图元的绘制逻辑,就能组合出任意复杂的自定义 UI。
