You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI中Shape内多路径绘制与动画实现问题

问题:实现+与✓的可逆切换动画

想要实现如下动画效果:
动画效果

具体需求

  • 初始根据completed布尔值显示对应形状(+或✓)
  • 从+切换到✓:
    1. 水平描边透明度变为0
    2. 竖线顶端从(midX, minY)逆时针移动到(minX, midY),形成✓的左描边(底端固定)
    3. ✓的右描边从(midX, maxY)动画延伸到(maxX, minY)
  • 从✓切换到+:
    1. ✓的右描边从(maxX, minY)反向回到(midX, maxY)
    2. 左描边动画回到(midX, minY),恢复为+的竖线
    3. 最后水平描边透明度恢复为1
  • 动画需可逆;快速点击时忽略当前动画,直接切换最终状态,松开后根据最终completed完成动画

当前问题

已使用不同线段Shape实现,但存在两个问题:

  1. ✓的右描边未实现正确的路径绘制动画
  2. 形状切换时动画反转逻辑不正确

用户原始代码

自定义Shape部分

struct LineSegment1: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        
        // MARK: + shape's horizontal line
        path.move(to: CGPoint(x: rect.minX, y: rect.midY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
        
        return path
    }
}

struct LineSegment2: Shape {
    var endPoint: CGPoint

    var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get { AnimatablePair(endPoint.x, endPoint.y) }
        set {
            endPoint.x = newValue.first
            endPoint.y = newValue.second
        }
    }

    // MARK: + shape's vertical line & ☑️ shape's left stroke
    func path(in rect: CGRect) -> Path {
        let start = CGPoint(x: rect.midX, y: rect.maxY)
        let end = CGPoint(x: endPoint.x * rect.width, y: endPoint.y * rect.height)
        var path = Path()
        path.move(to: start)
        path.addLine(to: end)
        return path
    }
}

struct LineSegment3: Shape {
    var animatableData: CGFloat {
        get { return progress }
        set { progress = newValue }
    }
    
    var progress: CGFloat = 0
    
    func path(in rect: CGRect) -> Path {
        var path = Path()
        // MARK: ☑️ shape's, right stroke
        path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.minX + (rect.maxX * progress), y: rect.minY))
        return path
    }
}

主视图部分

struct ContentView: View {
    @State private var drawProgress: CGFloat = 0.0
    @State private var completed: Bool = false
    
    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 5)
                .frame(width: 300, height: 300)
                .foregroundColor(.red)
                .overlay {
                    ZStack {
                        LineSegment1()
                            .stroke(.white, lineWidth: 4.0)
                            .opacity(completed ? 0 : 1)
                            .frame(width: 200, height: 200)
                        LineSegment2(endPoint: CGPoint(
                            x: completed ? 0.0 : 0.5,
                            y: completed ? 0.5 : 0.0))
                        .stroke(completed ? .yellow : .white, lineWidth: 4.0)
                        .frame(width: 200, height: 200)
                        LineSegment3(progress: drawProgress)
                            .stroke(.yellow, lineWidth: 4.0)
                            .opacity(completed ? 1 : 0)
                            .frame(width: 200, height: 200)
                    }
                }
            
            Button(completed ? "Incomplete" : "Complete") {
                withAnimation(.easeInOut(duration: 0.5)) {
                    drawProgress = 1.0
                    completed.toggle()
                }
            }
            .buttonStyle(.borderedProminent)
            Spacer()
        }
        .padding()
    }
}

解决方案

修复要点

  1. 修正右描边的路径计算:原LineSegment3的终点计算逻辑错误,改为基于矩形相对位置的线性插值,确保从midX,maxY到maxX,minY的平滑过渡。
  2. 同步动画状态与completed逻辑:切换时根据completed的目标状态设置drawProgress,使用Transaction处理快速点击的动画中断,保证状态正确同步。
  3. 调整初始状态匹配:添加onAppear初始化drawProgress,确保初始显示与completed状态一致。

修复后的代码

自定义Shape部分

struct LineSegment1: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        // +的水平线段
        path.move(to: CGPoint(x: rect.minX, y: rect.midY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
        return path
    }
}

struct LineSegment2: Shape {
    var endPoint: CGPoint

    var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get { AnimatablePair(endPoint.x, endPoint.y) }
        set {
            endPoint.x = newValue.first
            endPoint.y = newValue.second
        }
    }

    // +的竖直线段 & ✓的左描边
    func path(in rect: CGRect) -> Path {
        let start = CGPoint(x: rect.midX, y: rect.maxY)
        // 转换为绝对坐标
        let end = CGPoint(
            x: rect.minX + endPoint.x * rect.width,
            y: rect.minY + endPoint.y * rect.height
        )
        var path = Path()
        path.move(to: start)
        path.addLine(to: end)
        return path
    }
}

struct LineSegment3: Shape {
    var progress: CGFloat = 0
    
    var animatableData: CGFloat {
        get { progress }
        set { progress = newValue }
    }
    
    func path(in rect: CGRect) -> Path {
        var path = Path()
        let start = CGPoint(x: rect.midX, y: rect.maxY)
        // 线性插值计算终点:从midX,maxY到maxX,minY
        let endX = rect.midX + (rect.maxX - rect.midX) * progress
        let endY = rect.maxY - (rect.maxY - rect.minY) * progress
        let end = CGPoint(x: endX, y: endY)
        path.move(to: start)
        path.addLine(to: end)
        return path
    }
}

主视图部分

struct ContentView: View {
    @State private var drawProgress: CGFloat = 0.0
    @State private var completed: Bool = false
    
    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 5)
                .frame(width: 300, height: 300)
                .foregroundColor(.red)
                .overlay {
                    ZStack {
                        // 水平线段:未完成时显示
                        LineSegment1()
                            .stroke(.white, lineWidth: 4.0)
                            .opacity(completed ? 0 : 1)
                            .frame(width: 200, height: 200)
                        
                        // 竖直线段/左描边:根据completed切换终点
                        LineSegment2(endPoint: CGPoint(
                            x: completed ? 0 : 0.5,
                            y: completed ? 0.5 : 0
                        ))
                        .stroke(completed ? .yellow : .white, lineWidth: 4.0)
                        .frame(width: 200, height: 200)
                        
                        // 右描边:完成时显示,按进度绘制
                        LineSegment3(progress: drawProgress)
                            .stroke(.yellow, lineWidth: 4.0)
                            .opacity(completed ? 1 : 0)
                            .frame(width: 200, height: 200)
                    }
                }
            
            Button(completed ? "标记未完成" : "标记完成") {
                // 用Transaction处理动画,支持快速点击中断
                withTransaction(Transaction(animation: .easeInOut(duration: 0.5))) {
                    completed.toggle()
                    // 根据目标状态设置进度
                    drawProgress = completed ? 1 : 0
                }
            }
            .buttonStyle(.borderedProminent)
            Spacer()
        }
        .padding()
        // 初始状态同步
        .onAppear {
            drawProgress = completed ? 1 : 0
        }
    }
}

效果说明

  • 右描边现在会从midX,maxY平滑延伸到maxX,minY,反向切换时也会原路退回
  • 快速点击按钮时,当前动画会立即中断,直接切换到最终状态并完成对应动画
  • 初始显示与completed状态完全匹配,无错位问题

内容的提问来源于stack exchange,提问作者batman

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.09 19:50:55