SwiftUI 自定义图形:我如何画出渐变进度环和聊天气泡

SwiftUI 自定义图形:我如何画出渐变进度环和聊天气泡

App 需要一个渐变色的环形进度条,第一反应是用 UIKitCAShapeLayer,但 SwiftUI 其实可以用 Shape 协议实现更简洁的方案。

Shape 协议基础

ShapeView 的特例,返回 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,可以用 UIBezierPathPath

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 协议非常强大:

  1. 进度环:用 trim 控制比例,stroke 绘制弧线,AngularGradient 做渐变
  2. 动画数值Animatable 协议让 progress 变化自动产生插值动画
  3. 聊天气泡:用 Path 绘制气泡主体和尾巴,或用 mask + overlay 的组合方案
  4. 路径复用inset 修饰符可以基于同一图形创建变体
  5. 与设计工具集成:可以用 UIBezierPath 作为中间格式导入设计稿

图形绘制本质上是数学问题,Path 则是 SwiftUI 提供的瑞士军刀。掌握了基本图元的绘制逻辑,就能组合出任意复杂的自定义 UI。

最后更新 4/20/2026, 6:02:32 AM