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)
}
}
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 shadow effect needs to be applied inside the view modifier. If it is applied to the view passed to the view modifier then the shadow rolls too!
The scaling factor and the size of the shadow can depend on the fraction of the "cruise height" that has been reached along the path, to give the impression that the plane is taking off and landing.
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)
}
}
}
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 */ )
)