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

7

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

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


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 */ )
)

6

Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa Dịch vụ tổ chức sự kiện 5 sao Thông tin về chúng tôi Dịch vụ sinh nhật bé trai Dịch vụ sinh nhật bé gái Sự kiện trọn gói Các tiết mục giải trí Dịch vụ bổ trợ Tiệc cưới sang trọng Dịch vụ khai trương Tư vấn tổ chức sự kiện Hình ảnh sự kiện Cập nhật tin tức Liên hệ ngay Thuê chú hề chuyên nghiệp Tiệc tất niên cho công ty Trang trí tiệc cuối năm Tiệc tất niên độc đáo Sinh nhật bé Hải Đăng Sinh nhật đáng yêu bé Khánh Vân Sinh nhật sang trọng Bích Ngân Tiệc sinh nhật bé Thanh Trang Dịch vụ ông già Noel Xiếc thú vui nhộn Biểu diễn xiếc quay đĩa Dịch vụ tổ chức tiệc uy tín Khám phá dịch vụ của chúng tôi Tiệc sinh nhật cho bé trai Trang trí tiệc cho bé gái Gói sự kiện chuyên nghiệp Chương trình giải trí hấp dẫn Dịch vụ hỗ trợ sự kiện Trang trí tiệc cưới đẹp Khởi đầu thành công với khai trương Chuyên gia tư vấn sự kiện Xem ảnh các sự kiện đẹp Tin mới về sự kiện Kết nối với đội ngũ chuyên gia Chú hề vui nhộn cho tiệc sinh nhật Ý tưởng tiệc cuối năm Tất niên độc đáo Trang trí tiệc hiện đại Tổ chức sinh nhật cho Hải Đăng Sinh nhật độc quyền Khánh Vân Phong cách tiệc Bích Ngân Trang trí tiệc bé Thanh Trang Thuê dịch vụ ông già Noel chuyên nghiệp Xem xiếc khỉ đặc sắc Xiếc quay đĩa thú vị
Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa
Thiết kế website Thiết kế website Thiết kế website Cách kháng tài khoản quảng cáo Mua bán Fanpage Facebook Dịch vụ SEO Tổ chức sinh nhật