我正在尝试创建以下动画。
自从上次发布以来,我已经成功地沿着路径调整图像的方向(感谢 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)
}
}
本例中的路径是一个简单的弧线,所以我认为您使用
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)
}
}
}