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

SwiftUI实现图片沿路径移动并持续翻转+路径跟随动画

问题描述

我想要实现图片沿半圆路径移动,移动过程中自身持续翻转,同时路径随图片前进逐步显示的动画效果。目前已借助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)
    }
}
解决方案

一、实现图片移动时的持续翻转

利用animate状态的动画进度,给图片添加同步的旋转动画,让其在移动过程中持续翻转:

修改ContentView中的图片部分,新增基于animate的旋转效果:

Image(systemName: "paperplane.fill").resizable().foregroundColor(Color.red)
    .rotationEffect(.degrees(45))
    .rotationEffect(.degrees(180))
    // 新增持续翻转动画,3秒内旋转3圈,可根据需求调整圈数
    .rotationEffect(.degrees(animate ? 360 * 3 : 0))
    .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()
        }
    }

二、实现路径跟随图片逐步显示的动画

通过给路径添加可动画的修剪进度属性,同步图片的移动进度来控制路径显示范围:

  1. 修改SemiCircle结构体,添加修剪进度属性并支持动画:
struct SemiCircle: Shape {
    var trimPct: CGFloat = 0 // 控制路径显示的进度
    
    func path(in rect: CGRect) -> Path {
        SemiCircle.semiCirclePath(in: rect)
            .trimmedPath(from: 0, to: trimPct) // 根据进度修剪路径
    }
    
    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
    }
    
    // 让trimPct成为可动画属性
    var animatableData: CGFloat {
        get { trimPct }
        set { trimPct = newValue }
    }
}
  1. 在ContentView中绑定路径的修剪进度和animate状态:
SemiCircle(trimPct: animate ? 1 : 0) // 图片进度从1到0,路径进度对应从0到1
    .stroke(style: StrokeStyle(lineWidth: 2, dash: [10, 15]))
    .frame(width: geometry.size.width, height: geometry.size.height)

完整修改后的代码

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

    var body: some View {
        GeometryReader(content: { geometry in
            ZStack(alignment: .topLeading) {
                SemiCircle(trimPct: animate ? 1 : 0)
                    .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))
                    .rotationEffect(.degrees(animate ? 360 * 3 : 0))
                    .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 {
    var trimPct: CGFloat = 0
    
    func path(in rect: CGRect) -> Path {
        SemiCircle.semiCirclePath(in: rect)
            .trimmedPath(from: 0, to: trimPct)
    }
    
    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
    }
    
    var animatableData: CGFloat {
        get { trimPct }
        set { trimPct = newValue }
    }
}

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 {
        let diff: CGFloat = 0.001
        let comp: CGFloat = 1 - diff
        
        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)
    }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.18 01:24:52