Search code examples
iosanimationswiftui

Rotate & animate image along a path in SwiftUI?


I'm trying to create the following animation.

Since last posting, I've managed to orient the image along the path (thanks to swiftui-lab). How do I now flip the image continuously as it follows the path as well as have the path follow the image with animation as can be seen in the linked animation above?

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)
    }
}

Solution

  • The path in this case is a simple arc, so an Animatable ViewModifier is sufficient for the animation. Some notes:

    • For the 3D roll, try using rotation3DEffect around the x-axis. This needs to be performed before adding the arc rotation.

    • I would suggest, the easiest way to implement the rotation effect along the arc is to perform an offset equal to the arc radius, rotate the view, then negate the offset.

    • If you don't want the arc to be 180 degrees then you can compute the angle and radius using a little trigonometry. I worked this out once for a previous answer, the formula can be borrowed from there.

    • The path (arc) can be animated using the .trim modifier. However, this modifier only works on a Shape, so a ViewModifier is not able to take the view supplied to it and apply .trim as modifier. What would be great, is if it would be possible to create an animatable ShapeModifier, for animating shapes. Since this is not currently possible, the shape needs to be added to the view by the view modifier, for example, by drawing it in the background.

    • The animation actually has different phases (take-off, roll, landing, trailing path). A single view modifier can handle all of these phases, but you need to implement the phase logic yourself.

    The version below uses a single ViewModifier to apply all the animation effects, including the animation of the trailing path (which is added in the background, as explained above). A symbol that looks more like the one in the reference animation would be "location.fill", but I left it as "paperplane.fill", like you were using.

    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()
                    .scaledToFit()
                    .rotationEffect(.degrees(45))
                    .foregroundStyle(.red)
                    .modifier(
                        FlightAnimation(
                            curveHeight: curveHeight,
                            arcRadius: arcRadius,
                            arcAngle: arcAngle,
                            progress: animate ? 1 : 0
                        )
                    )
            }
            .padding(30)
            .onAppear {
                withAnimation(.linear(duration: 4).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 = 24
        let flightFractionDoingRoll = 0.4
        let totalRollDegrees: CGFloat = 360
        let maxScaling: CGFloat = 1.8
        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 rollAngle: Angle {
            let rollFraction = progress > rollBegin && progress < rollEnd
                ? (progress - rollBegin) / (flightFractionDoingRoll * totalFlightDuration)
                : 0
            return .degrees(totalRollDegrees * rollFraction)
        }
    
        private var trimFrom: CGFloat {
            progress <= totalFlightDuration
                ? 0
                : (progress - totalFlightDuration) / trailingFlightPathDurationFraction
        }
    
        private var trimTo: CGFloat {
            progress < totalFlightDuration
                ? progress / totalFlightDuration
                : 1
        }
    
        private var scaleFactor: CGFloat {
            let scaleFraction = progress >= totalFlightDuration || rollBegin <= 0
                ? 0
                : min(progress, totalFlightDuration - progress) / rollBegin
            return 1 + (min(1, scaleFraction) * (maxScaling - 1))
        }
    
        func body(content: Content) -> some View {
            content
                .frame(width: planeSize, height: planeSize)
                .scaleEffect(scaleFactor)
                .rotation3DEffect(
                    rollAngle,
                    axis: (x: 1, y: 0, z: 0),
                    perspective: 0.1
                )
                .offset(y: -arcRadius)
                .rotationEffect(rotationAngle)
                .offset(y: arcRadius)
                .shadow(color: .gray, radius: 4, y: 4)
                .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

    The reference animation also slows in the middle. If you wanted to mimic this too then you could replace the .linear timing curve with a custom timing curve. For example:

    withAnimation(
        .timingCurve(0.15, 0.4, 0.5, 0.2, duration: 4.5)
        .repeatForever(autoreverses: false)
    ) {
        animate.toggle()
    }
    

    Animation


    EDIT The view modifier works on whatever view it is given. So if you wanted the image to have a white border, as in the reference animation, then a ZStack can be used to layer multiple images to give this effect. For example:

    ZStack {
        Image(systemName: "paperplane.fill")
            .resizable()
            .fontWeight(.black)
            .foregroundStyle(.white)
        Image(systemName: "paperplane.fill")
            .resizable()
            .fontWeight(.ultraLight)
            .padding(2)
            .foregroundStyle(.red)
    }
    .scaledToFit()
    .rotationEffect(.degrees(45))
    .modifier(
        FlightAnimation( /* as before */ )
    )
    

    Animation