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:

    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 maxShadow: CGFloat = 4
        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 cruiseHeightFraction: CGFloat {
            let fraction = rollBegin <= 0 || progress >= totalFlightDuration
                ? 0
                : min(progress, totalFlightDuration - progress) / rollBegin
            return min(1, fraction)
        }
    
        private var scaleFactor: CGFloat {
            1 + (cruiseHeightFraction * (maxScaling - 1))
        }
    
        private var shadowSize: CGFloat {
            cruiseHeightFraction * maxShadow
        }
    
        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: shadowSize, y: shadowSize)
                .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