Whenever a SwiftUI animation is triggered, its state is updated immediately regardless of the duration. The Animation struct does not provide us with any sort of callback to indicate whether it has completed. So how can we detect when our views have stopped animating?
Suppose we want to animate the image of a little plane flying towards the moon. We start by declaring a view for the plane, the moon background and a state variable indicating the current distance from the ground. We lay all of this out on our main view's body.
struct PlaneMoonScene: View {
@State var distance: CGFloat = 0
var plane: some View {
Image(systemName: "paperplane")
…
}
var moonBackground: some View {
Image(systemName: "moon.stars")
…
}
var body: some View {
ZStack(alignment: .bottomLeading) {
moonBackground
plane.offset(x: distance, y: -distance)
}
}
}
To kick off our flight we need to define a launch button. The button's action will animate the distance from 0 to 200 by applying the easeInOut()
timing function for a total duration of 1 second.
struct PlaneMoonScene: View {
…
var launchButton: some View {
Button("Launch!") {
withAnimation(.easeInOut(duration: 1)) {
self.distance = 200
}
}
}
var body: some View {
ZStack {
…
launchButton
}
}
}
As expected pressing Launch! causes our little plane to shoot up and reach the moon. But what if we want to display a congratulatory message once our destination has been attained? For this we need to replace the offset()
modifier that places the plane over the background with our own FlyModifier.
struct FlyModifier: AnimatableModifier {
var totalDistance: CGFloat
var percentage: CGFloat
var onReachedDestination: () -> () = {}
private var distance: CGFloat { percentage * totalDistance }
…
func body(content: Content) -> some View {
content
.offset(x: distance, y: -distance)
}
}
Our FlyModifier
also positions the plane at a specified distance but because it inherits from AnimatableModifier
it has the ability to look at the animation parameters as they transition from initial value to target. Our modifier is applied similarly to offset()
except this time around we do get a chance to provide a completion handler.
struct PlaneMoonScene: View {
@State var percentage: CGFloat = 0
…
var body: some View {
…
plane.modifier(
FlyModifier(totalDistance: 200, percentage: percentage) {
// We have reached the moon!
}
)
}
}
So how do we actually detect within FlyModifier
that the total distance has been covered - or that the percentage value is 1, as in 100 percent of the distance? Well we need to implement the variable animatableData
to comply with AnimatableModifier
and in the setter of that variable we check for completion. If the animation has finished we asynchronously invoke the handler given to us by the main view.
struct FlyModifier: AnimatableModifier {
…
var animatableData: CGFloat {
get { percentage }
set {
percentage = newValue
checkIfFinished()
}
}
func checkIfFinished() -> () {
if percentage == 1 {
DispatchQueue.main.async {
self.onReachedDestination()
}
}
}
…
}
Finally we can celebrate reaching the moon!
struct PlaneMoonScene: View {
@State var reachedMoon = false
…
var congrats: some View {
Text("Congrats!!")
…
}
var launchButton: some View {
Button("Launch!") {
withAnimation(.easeInOut(duration: 1)) {
self.percentage = 1
}
}
}
var body: some View {
ZStack {
moonBackground
plane.modifier(
FlyModifier(totalDistance: 200, percentage: percentage) {
withAnimation { self.reachedMoon.toggle() }
}
}
launchButton
if reachedMoon {
congrats
}
}
}
}
For sample code featuring this and other techniques please checkout our working examples repo. Featured Example: Moonshot.
Originally published at Swift You and I
Top comments (0)