When we attach the animation()
modifier to a view, SwiftUI will automatically animate any changes that happen to that view using whatever is the default system animation, whenever the value we’re watching changes. In practice, that is a very gentle spring, which means iOS will start the animation slow, then make it pick up speed until it ever-so-slightly overshoots the target value, then it will end by going backwards just a touch until it reaches its end state.
We can control the type of animation used by passing in different values to the modifier. For example, we could use .linear
to make the animation move at a constant speed from start to finish:
.animation(.linear, value: animationAmount)
Tip: If you were curious, implicit animations always need to watch a particular value otherwise animations would be triggered for every small change – even rotating the device from portrait to landscape would trigger the animation, which would look strange.
iOS chooses spring animations by default because they mimic what we're used to in the real world. These are hugely customizable: you can control roughly how long the spring should take to complete, and also how bouncy the spring should – whether it should bounce back and forwards more or less, where 1 is maximum bounciness and 0 is no bounciness.
For example, this makes our button scale up quickly then bounce a lot:
.animation(.spring(duration: 1, bounce: 0.9), value: animationAmount)
For more precise control, we can customize the animation with a duration specified as a number of seconds. So, we could get an ease-in-out animation that lasts for two seconds like this:
struct ContentView: View {
@State private var animationAmount = 1.0
var body: some View {
Button("Tap Me") {
animationAmount += 1
}
.padding(50)
.background(.red)
.foregroundStyle(.white)
.clipShape(.circle)
.scaleEffect(animationAmount)
.animation(.easeInOut(duration: 2), value: animationAmount)
}
}
When we say .easeInOut(duration: 2)
we’re actually creating an instance of an Animation
struct that has its own set of modifiers. So, we can attach modifiers directly to the animation to add a delay like this:
.animation(
.easeInOut(duration: 2)
.delay(1),
value: animationAmount
)
With that in place, tapping the button will now wait for a second before executing a two-second animation.
We can also ask the animation to repeat a certain number of times, and even make it bounce back and forward by setting autoreverses
to true. This creates a one-second animation that will bounce up and down before reaching its final size:
.animation(
.easeInOut(duration: 1)
.repeatCount(3, autoreverses: true),
value: animationAmount
)
If we had set repeat count to 2 then the button would scale up then down again, then jump immediately back up to its larger scale. This is because ultimately the button must match the state of our program, regardless of what animations we apply – when the animation finishes the button must have whatever value is set in animationAmount
.
For continuous animations, there is a repeatForever()
modifier that can be used like this:
.animation(
.easeInOut(duration: 1)
.repeatForever(autoreverses: true),
value: animationAmount
)
We can use these repeatForever()
animations in combination with onAppear()
to make animations that start immediately and continue animating for the life of the view.
To demonstrate this, we’re going to remove the animation from the button itself and instead apply it an overlay to make a sort of pulsating circle around the button. Overlays are created using an overlay()
modifier, which lets us create new views at the same size and position as the view we’re overlaying.
So, first add this overlay()
modifier to the button before the animation()
modifier:
.overlay(
Circle()
.stroke(.red)
.scaleEffect(animationAmount)
.opacity(2 - animationAmount)
)
That makes a stroked red circle over our button, using an opacity value of 2 - animationAmount
so that when animationAmount
is 1 the opacity is 1 (it’s opaque) and when animationAmount
is 2 the opacity is 0 (it’s transparent).
Next, remove the scaleEffect()
and blur()
modifiers from the button and comment out the animationAmount += 1
action part too, because we don’t want that to change any more, and move its animation modifier up to the circle inside the overlay:
.overlay(
Circle()
.stroke(.red)
.scaleEffect(animationAmount)
.opacity(2 - animationAmount)
.animation(
.easeOut(duration: 1)
.repeatForever(autoreverses: false),
value: animationAmount
)
)
I’ve switched autoreverses
to false, but otherwise it’s the same animation.
Finally, add an onAppear()
modifier to the button, which will set animationAmount
to 2:
.onAppear {
animationAmount = 2
}
Because the overlay circle uses that for a “repeat forever” animation without autoreversing, you’ll see the overlay circle scale up and fade out continuously.
Your finished code should look like this:
Button("Tap Me") {
// animationAmount += 1
}
.padding(50)
.background(.red)
.foregroundStyle(.white)
.clipShape(.circle)
.overlay(
Circle()
.stroke(.red)
.scaleEffect(animationAmount)
.opacity(2 - animationAmount)
.animation(
.easeInOut(duration: 1)
.repeatForever(autoreverses: false),
value: animationAmount
)
)
.onAppear {
animationAmount = 2
}
Given how little work that involves, it creates a remarkably attractive effect!
SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until September 29th.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.