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

SwiftUI中实现UIKit风格抛纸动画的最优方法问询

在SwiftUI中实现UIKit风格的抛纸动画的最佳方案

我需要实现从A点到B点的抛射动画(无弹跳),作为用户操作的反馈(类似相册添加照片的效果)。之前尝试用.rotationEffect()实现,但动画生硬且无法自定义点位,需要能定义起点、顶点、终点的自然抛纸动画。

我尝试的SwiftUI代码(未达到预期效果)

struct KeyframeAnimation: View {
    @State private var ascend = false
    @State private var ascendDescend = false
    @State private var descend = false
    
    var body: some View {
        ZStack{
            VStack{
                ZStack{
                    Image(systemName: "circle.fill")
                        .font(.title)
                        .offset(x: -157)
                        .foregroundColor(Color.red)
                        .rotationEffect(.degrees(ascend ? 17: 0))
                        .rotationEffect(.degrees(ascendDescend ? 145: 0))
                        .rotationEffect(.degrees(descend ? 18: 0))
                        .onAppear{
                            withAnimation(Animation.easeInOut(duration: 0.5)){
                                self.ascend.toggle()
                            }
                            
                            withAnimation(Animation.easeInOut(duration: 1)){
                                self.ascendDescend.toggle()
                            }
                            withAnimation(Animation.easeInOut(duration: 2).delay(8)){
                                self.descend.toggle()
                            }
                        }
                }
            }
        }
    }
}

目标UIKit参考代码(已实现预期效果)

@IBAction func startAnimation(_ sender: Any) {
        myView = UIView(frame: CGRect(x: pointA.center.x - 25, y: pointA.center.y - 25, width: 50, height: 50))
        myView?.backgroundColor = .blue
        myView?.layer.cornerRadius = myView!.frame.height / 2
        view.addSubview(myView!)
    
    UIView.animateKeyframes(withDuration: 1.5, delay: 0.0, options: [.calculationModeCubic], animations: {
        UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.2, animations: {
            self.myView!.center =  CGPoint(x: self.pointA.center.x, y: self.pointA.center.y)
        })
        UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.9, animations: {
            self.myView!.center =  CGPoint(x: self.pointB.center.x + 75, y: self.pointB.center.y - 50 )
            self.myView!.transform = CGAffineTransform(scaleX: 0.75, y: 0.75)
        })
        
        UIView.addKeyframe(withRelativeStartTime: 0.1, relativeDuration: 0.7, animations: {
            self.myView!.center =  CGPoint(x: self.pointB.center.x, y: self.pointB.center.y)
            self.myView!.transform = CGAffineTransform(scaleX: 0.2, y: 0.2)
        })
    }, completion: { _ in
        self.myView?.removeFromSuperview()
    })
}

抛纸动画路径示意图


SwiftUI实现方案

方案1:iOS 17+ 用KeyframeAnimator(推荐)

利用SwiftUI 17新增的KeyframeAnimator可以精准复刻UIKit的关键帧动画逻辑,支持自定义路径和时序:

struct PaperTossAnimation: View {
    // 封装动画状态:位置、缩放比例
    struct AnimationState {
        var position: CGPoint = .zero
        var scale: CGFloat = 1.0
    }
    
    @State private var isAnimating = false
    
    // 替换为实际布局中的A、B、顶点坐标(可通过GeometryReader获取)
    let pointA = CGPoint(x: 100, y: 300)
    let pointB = CGPoint(x: 300, y: 500)
    let vertexPoint = CGPoint(x: 350, y: 200)
    
    var body: some View {
        ZStack {
            // 起点标记(可选,用于调试)
            Circle()
                .fill(.red)
                .frame(width: 20, height: 20)
                .position(pointA)
            
            // 终点标记(可选,用于调试)
            Circle()
                .fill(.green)
                .frame(width: 20, height: 20)
                .position(pointB)
            
            // 抛射的视图
            Circle()
                .fill(.blue)
                .frame(width: 50, height: 50)
                .keyframeAnimator(initialValue: AnimationState(position: pointA, scale: 1.0), trigger: isAnimating) { content, state in
                    content
                        .position(state.position)
                        .scaleEffect(state.scale)
                } keyframes: { _ in
                    // 位置关键帧:从A到顶点再到B
                    KeyframeTrack(\.position) {
                        CubicKeyframe(vertexPoint, duration: 0.6)
                        CubicKeyframe(pointB, duration: 0.9)
                    }
                    
                    // 缩放关键帧:同步缩小
                    KeyframeTrack(\.scale) {
                        LinearKeyframe(0.75, duration: 0.6)
                        LinearKeyframe(0.2, duration: 0.9)
                    }
                }
                .onAppear {
                    isAnimating = true
                }
                // 动画结束后隐藏视图
                .opacity(isAnimating ? 1 : 0)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

方案2:兼容iOS 16及以下版本

通过状态变量切换动画阶段,结合withAnimation模拟关键帧时序:

struct LegacyPaperTossAnimation: View {
    @State private var animationPhase = 0
    
    let pointA = CGPoint(x: 100, y: 300)
    let pointB = CGPoint(x: 300, y: 500)
    let vertexPoint = CGPoint(x: 350, y: 200)
    
    var body: some View {
        ZStack {
            Circle().fill(.red).frame(width:20, height:20).position(pointA)
            Circle().fill(.green).frame(width:20, height:20).position(pointB)
            
            Circle()
                .fill(.blue)
                .frame(width: 50, height: 50)
                .position(currentPosition)
                .scaleEffect(currentScale)
                .onAppear {
                    startAnimation()
                }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
    
    // 根据当前阶段返回对应位置
    private var currentPosition: CGPoint {
        switch animationPhase {
        case 0: return pointA
        case 1: return vertexPoint
        case 2, 3: return pointB
        default: return pointB
        }
    }
    
    // 根据当前阶段返回对应缩放比例
    private var currentScale: CGFloat {
        switch animationPhase {
        case 0: return 1.0
        case 1: return 0.75
        case 2, 3: return 0.2
        default: return 0.2
        }
    }
    
    private func startAnimation() {
        // 第一阶段:移动到顶点
        withAnimation(.easeInOut(duration: 0.6)) {
            animationPhase = 1
        }
        // 第二阶段:移动到终点,与第一阶段衔接
        withAnimation(.easeInOut(duration: 0.9).delay(0.6)) {
            animationPhase = 2
        }
        // 动画结束后隐藏视图
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
            withAnimation {
                animationPhase = 3
            }
        }
    }
}

关键优化点

  • 若需要获取实际布局中的A、B点坐标,可使用GeometryReader包裹父视图,读取目标视图的frame或position
  • 调整关键帧的duration和路径点坐标,可以优化动画的自然度,匹配UIKit中的relativeDuration参数
  • 如需添加旋转效果,可在动画闭包中加入rotationEffect的关键帧,同步调整旋转角度

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.18 20:35:25