在 SwiftUI 中沿着路径旋转和动画图像?

问题描述 投票:0回答:1

我正在尝试创建以下动画。

自从上次发布以来,我已经成功地沿着路径调整图像的方向(感谢 swiftui-lab)。现在,如何在图像跟随路径时连续翻转图像,以及如何让路径跟随带有动画的图像,如上面的链接动画所示?

struct ContentView: View {
    
    @State private var animate = false

    var body: some View {
        GeometryReader(content: { geometry in
            ZStack(alignment: .topLeading) {
                SemiCircle()
                    .stroke(style: StrokeStyle(lineWidth: 2, dash: [10, 15]))
                    .frame(width: geometry.size.width, height: geometry.size.height)
                
                Image(systemName: "paperplane.fill").resizable().foregroundColor(Color.red)
                    .rotationEffect(.degrees(45))
                    .rotationEffect(.degrees(180))
                    .frame(width: 50, height: 50).offset(x: -25, y: -25)
                    .modifier(FollowEffect(pct: animate ? 0 : 1, path: SemiCircle.semiCirclePath(in: CGRect(x: 0, y: 0, width: geometry.size.width, height: geometry.size.height)), rotate: true))
                    .onAppear {
                        withAnimation(Animation.linear(duration: 3.0).repeatForever(autoreverses: false)) {
                            animate.toggle()
                        }
                    }
            }
            .frame(alignment: .topLeading)
        })
        .padding(50)
    }
}

struct SemiCircle: Shape {
    func path(in rect: CGRect) -> Path {
        SemiCircle.semiCirclePath(in: rect)
    }
    
    static func semiCirclePath(in rect: CGRect) -> Path {
        var path = Path()
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2
        path.addArc(center: center, radius: radius, startAngle: .degrees(0), endAngle: .degrees(180), clockwise: true)
        return path
    }
}

struct FollowEffect: GeometryEffect {
    var pct: CGFloat = 0
    let path: Path
    var rotate = true
    
    var animatableData: CGFloat {
        get { return pct }
        set { pct = newValue }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        if !rotate {
            let pt = percentPoint(pct)
            return ProjectionTransform(CGAffineTransform(translationX: pt.x, y: pt.y))
        } else {
            let pt1 = percentPoint(pct)
            let pt2 = percentPoint(pct - 0.01)
            
            let angle = calculateDirection(pt1, pt2)
            let transform = CGAffineTransform(translationX: pt1.x, y: pt1.y).rotated(by: angle)
            
            return ProjectionTransform(transform)
        }
    }
    
    func percentPoint(_ percent: CGFloat) -> CGPoint {
        // percent difference between points
        let diff: CGFloat = 0.001
        let comp: CGFloat = 1 - diff
        
        // handle limits
        let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
        
        let f = pct > comp ? comp : pct
        let t = pct > comp ? 1 : pct + diff
        let tp = path.trimmedPath(from: f, to: t)
        
        return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
    }
    
    func calculateDirection(_ pt1: CGPoint,_ pt2: CGPoint) -> CGFloat {
        let a = pt2.x - pt1.x
        let b = pt2.y - pt1.y
        
        let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi
        
        return CGFloat(angle)
    }
}
ios animation swiftui
1个回答
0
投票

本例中的路径是一个简单的弧线,所以我认为您使用

Animatable
ViewModifier
来执行动画的想法是正确的。

  • 我建议,实现旋转效果(沿圆弧)的最简单方法是执行等于圆弧半径的偏移,旋转视图,然后反转偏移。

  • 对于 3D 滚动,请尝试围绕 x 轴使用

    rotation3DEffect
    。这需要在添加圆弧旋转之前执行。

  • 如果您不希望弧为 180 度,那么您可以使用一点三角学来计算角度和半径。我为之前的答案计算过一次,可以从那里借用公式。

  • 可以使用

    .trim
    修改器对路径(弧)进行动画处理。但是,此修改器仅适用于
    Shape
    ,因此
    ViewModifier
    无法采用提供的视图并以这种方式进行修改。如果能够创建一个
    ShapeModifier
    ,那就太好了。由于目前这是不可能的,因此需要通过视图修改器将形状添加到视图中,例如,通过在后台绘制它。

  • 动画实际上有不同的阶段(起飞、缩放+滚动、着陆、尾随路径)。单个视图修改器可以处理所有这些阶段,但您需要自己实现阶段逻辑。

下面的版本使用单个

ViewModifier
来应用所有动画效果,包括尾随路径的动画(在后台添加,如上所述)。看起来更像参考动画中的符号是“location.fill”,但我将其保留为“paperplane.fill”,就像您使用的那样。

struct ContentView: View {
    @State private var animate = false

    var body: some View {
        GeometryReader { proxy in
            let w = proxy.size.width
            let halfWidth = w / 2
            let curveHeight = w * 0.2
            let slopeLen = sqrt((halfWidth * halfWidth) + (curveHeight * curveHeight))
            let arcRadius = (slopeLen * slopeLen) / (2 * curveHeight)
            let arcAngle = 4 * asin((slopeLen / 2) / arcRadius)

            Image(systemName: "paperplane.fill")
                .resizable()
                .rotationEffect(.degrees(45))
                .foregroundStyle(.red)
                .modifier(
                    FlightAnimation(
                        curveHeight: curveHeight,
                        arcRadius: arcRadius,
                        arcAngle: arcAngle,
                        progress: animate ? 1 : 0
                    )
                )
        }
        .padding(30)
        .onAppear {
            withAnimation(.linear(duration: 3.0).repeatForever(autoreverses: false)) {
                animate.toggle()
            }
        }
    }
}

struct FlightPath: Shape {
    let curveHeight: CGFloat
    let arcRadius: CGFloat
    let arcAngle: Double

    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.minX, y: rect.minY + curveHeight))
        path.addArc(
            center: CGPoint(x: rect.midX, y: rect.minY + arcRadius),
            radius: arcRadius,
            startAngle: .degrees(-90) - .radians(arcAngle / 2),
            endAngle: .degrees(-90) + .radians(arcAngle / 2),
            clockwise: false
        )
        return path
    }
}

struct FlightAnimation: ViewModifier, Animatable {
    let curveHeight: CGFloat
    let arcRadius: CGFloat
    let arcAngle: Double
    let planeSize: CGFloat = 30
    let flightFractionDoingRoll = 0.4
    let totalRollDegrees: CGFloat = 360
    let scalingRampUpDuration = 0.05
    let maxScaling: CGFloat = 1.5
    let trailingFlightPathDurationFraction: CGFloat = 0.3
    var progress: CGFloat

    var animatableData: CGFloat {
        get { progress }
        set { progress = newValue }
    }

    private var totalFlightDuration: CGFloat {
        1 - trailingFlightPathDurationFraction
    }

    private var flightProgress: CGFloat {
        progress / totalFlightDuration
    }

    private var rollBegin: CGFloat {
        ((1 - flightFractionDoingRoll) / 2) * totalFlightDuration
    }

    private var rollEnd: CGFloat {
        totalFlightDuration - rollBegin
    }

    private var rotationAngle: Angle {
        .radians(min(1, flightProgress) * arcAngle) - .radians(arcAngle / 2)
    }

    private var rollDegrees: CGFloat {
        let rollFraction = progress > rollBegin && progress < rollEnd
            ? (progress - rollBegin) / (flightFractionDoingRoll * totalFlightDuration)
            : 0
        return totalRollDegrees * rollFraction
    }

    private var trimFrom: CGFloat {
        progress <= totalFlightDuration
            ? 0
            : (progress - totalFlightDuration) / trailingFlightPathDurationFraction
    }

    private var trimTo: CGFloat {
        progress < totalFlightDuration
            ? progress / totalFlightDuration
            : 1
    }

    var scaleFactor: CGFloat {
        let scaleBegin = rollBegin - scalingRampUpDuration
        let scaleEnd = rollEnd + scalingRampUpDuration
        let scaleFraction = progress > scaleBegin && progress < scaleEnd
            ? min(progress - scaleBegin, scaleEnd - progress) / scalingRampUpDuration
            : 0
        return 1 + (min(1, scaleFraction) * (maxScaling - 1))
    }

    func body(content: Content) -> some View {
        content
            .frame(width: planeSize, height: planeSize)
            .scaleEffect(scaleFactor)
            .rotation3DEffect(
                .degrees(rollDegrees),
                axis: (x: 1, y: 0, z: 0),
                perspective: 0.1
            )
            .offset(y: -arcRadius)
            .rotationEffect(rotationAngle)
            .offset(y: arcRadius)
            .frame(height: curveHeight + (planeSize / 2), alignment: .top)
            .frame(maxWidth: .infinity)
            .background {
                FlightPath(curveHeight: curveHeight, arcRadius: arcRadius, arcAngle: arcAngle)
                    .trim(from: trimFrom, to: trimTo)
                    .stroke(.gray, style: .init(lineWidth: 3, dash: [10, 10]))
                    .padding(.top, planeSize / 2)
            }
    }
}

Animation

© www.soinside.com 2019 - 2024. All rights reserved.