Day 32
Day 32 êŽë š
Project 6, part 1
When Steve Jobs introduced Aqua, the visual theme that has powered macOS ever since Mac OS X launched in 2001, he said âwe made the buttons on the screen look so good you'll want to lick them.â I donât know if you were using Macs way back then, but over the years Aqua has given us glass-like buttons, pin stripes, brushed metal and so much more, and even today the âgenieâ window minimize looks amazing.
When we make apps with great visual appeal, users notice. Sure, it wonât affect the core functionality of the app, and itâs easy to go overboard with design and cause that core to get a little lost, but when you do it right a beautiful user interface brings a little extra delight and can help set your app apart from others.
Animations are one of the fundamental ways we can bring our apps to life, and youâll be pleased to know SwiftUI gives us a range of tools for using them. Today weâll be looking at easier animations, but tomorrow weâll progress onto more difficult stuff â itâs a good idea to be aware of both, so you can tackle whatever problems come up in the future.
Today you have five topics to work through, in which youâll learn about implicit animations, explicit animations, binding animations, and more.
Animation: Introduction
Animation: Introduction
Weâre back to another technique project, and this time weâre going to be looking at something fast, beautiful, and really under-valued: animations.
Animations are there for a few reasons, of which one definitely is to make our user interfaces look better. However, they are also there to help users understand whatâs going on with our program: when one window disappears and another slides in, itâs clear to the user where the other window has gone to, which means itâs also clear where they can look to get it back.
In this technique project weâre going to look at a range of animations and transitions with SwiftUI. Some are easy â in fact, youâll be able to get great results almost immediately! â but some require more thinking. All will be useful, though, particularly as you work to make sure your apps are attractive and help guide the userâs eyes as best as you can.
As with the other days itâs a good idea to work in an Xcode project so you can see your code in action, so please create a new App project called Animations.
Creating implicit animations
Creating implicit animations
In SwiftUI, the simplest type of animation is an implicit one: we tell our views ahead of time âif someone wants to animate you, hereâs how you should respondâ, and nothing more. SwiftUI will then take care of making sure any changes that do occur follow the animation you requested. In practice this makes animation trivial â it literally could not be any easier.
Letâs start with an example. This code shows a simple red button with no action, using 50 points of padding and a circular clip shape:
Button("Tap Me") {
// do nothing
}
.padding(50)
.background(.red)
.foregroundColor(.white)
.clipShape(Circle())
What we want is for that button to get bigger every time itâs tapped, and we can do that with a new modifier called scaleEffect()
. You provide this with a value from 0 up, and it will be drawn at that size â a value of 1.0 is equivalent to 100%, i.e. the buttonâs normal size.
Because we want to change the scale effect value every time the button is tapped, we need to use an @State
property that will store a Double
. So, please add this property to your view now:
@State private var animationAmount = 1.0
Now we can make the button use that for its scale effect, by adding this modifier:
.scaleEffect(animationAmount)
Finally, when the button is tapped we want to increase the animation amount by 1, so use this for the buttonâs action:
animationAmount += 1
If you run that code youâll see that you can tap the button repeatedly to have it scale up and up. It wonât get redrawn at increasingly high resolutions, so as the button gets bigger youâll see it gets a bit blurry, but thatâs OK.
Now, the human eye is highly sensitive to movement â weâre extremely good at detecting when things move or change their appearance, which is what makes animation both so important and so pleasing. So, we can ask SwiftUI to create an implicit animation for our changes so that all the scaling happens smoothly by adding an animation()
modifier to the button:
.animation(.default, value: animationAmount)
That asks SwiftUI to apply a default animation whenever the value of animationAmount
changes, and immediately youâll see that tapping the button now causes it to scale up with an animation.
That implicit animation takes effect on all properties of the view that change, meaning that if we attach more animating modifiers to the view then they will all change together. For example, we could add a second new modifier to the button, .blur()
, which lets us add a Gaussian blur with a special radius â add this before the animation()
modifier:
.blur(radius: (animationAmount - 1) * 3)
A radius of (animationAmount - 1) * 3
means the blur radius will start at 0 (no blur), but then move to 3 points, 6 points, 9 points, and beyond as you tap the button.
If you run the app again youâll see that it now scales and blurs smoothly.
The point is that nowhere have we said what each frame of the animation should look like, and we havenât even said when SwiftUI should start and finish the animation. Instead, our animation becomes a function of our state just like the views themselves.
Customizing animations in SwiftUI
Customizing animations in SwiftUI
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 an âease in, ease outâ animation, which means iOS will start the animation slow, make it pick up speed, then slow down as it approaches its end.
We can control the type of animation used by passing in different values to the modifier. For example, we could use .easeOut
to make the animation start fast then slow down to a smooth stop:
.animation(.easeOut, 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.
There are even spring animations, that cause the movement to overshoot then return to settle at its target. You can control the initial stiffness of the spring (which sets its initial velocity when the animation starts), and also how fast the animation should be âdampedâ â lower values cause the spring to bounce back and forth for longer.
For example, this makes our button scale up quickly then bounce:
.animation(.interpolatingSpring(stiffness: 50, damping: 1), 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)
.foregroundColor(.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)
.foregroundColor(.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!
Animating bindings
Animating bindings
The animation()
modifier can be applied to any SwiftUI binding, which causes the value to animate between its current and new value. This even works if the data in question isnât really something that sounds like it can be animated, such as a Boolean â you can mentally imagine animating from 1.0 to 2.0 because we could do 1.05, 1.1, 1.15, and so on, but going from âfalseâ to âtrueâ sounds like thereâs no room for in between values.
This is best explained with some working code to look at, so hereâs a view with a VStack
, a Stepper
, and a Button
:
struct ContentView: View {
@State private var animationAmount = 1.0
var body: some View {
VStack {
Stepper("Scale amount", value: $animationAmount.animation(), in: 1...10)
Spacer()
Button("Tap Me") {
animationAmount += 1
}
.padding(40)
.background(.red)
.foregroundColor(.white)
.clipShape(Circle())
.scaleEffect(animationAmount)
}
}
}
As you can see, the stepper can move animationAmount
up and down, and tapping the button will add 1 to it â they are both tied to the same data, which in turn causes the size of the button to change. However, tapping the button changes animationCount
immediately, so the button will just jump up to its larger size. In contrast, the stepper is bound to $animationAmount.animation()
, which means SwiftUI will automatically animate its changes.
Now, as an experiment Iâd like you to change the start of the body
to this:
var body: some View {
print(animationAmount)
return VStack {
Because we have some non-view code in there, we need to add return
before the VStack
so Swift understands which part is the view that is being sent back. But adding print(animationAmount)
is important, and to see why Iâd like you to run the program again and try manipulating the stepper.
What you should see is that it prints out 2.0, 3.0, 4.0, and so on. At the same time, the button is scaling up or down smoothly â it doesnât just jump straight to scale 2, 3, and 4. Whatâs actually happening here is that SwiftUI is examining the state of our view before the binding changes, examining the target state of our views after the binding changes, then applying an animation to get from point A to point B.
This is why we can animate a Boolean changing: Swift isnât somehow inventing new values between false and true, but just animating the view changes that occur as a result of the change.
These binding animations use a similar animation()
modifier that we use on views, so you can go to town with animation modifiers if you want to:
Stepper("Scale amount", value: $animationAmount.animation(
.easeInOut(duration: 1)
.repeatCount(3, autoreverses: true)
), in: 1...10)
Tip: With this variant of the animation()
modifier, we donât need to specify which value weâre watching for changes â itâs literally attached to the value it should watch!
These binding animations effectively turn the tables on implicit animations: rather than setting the animation on a view and implicitly animating it with a state change, we now set nothing on the view and explicitly animate it with a state change. In the former, the state change has no idea it will trigger an animation, and in the latter the view has no idea it will be animated â both work and both are important.
Creating explicit animations
Creating explicit animations
Youâve seen how SwiftUI lets us create implicit animations by attaching the animation()
modifier to a view, and how it also lets us create animated binding changes by adding the animation()
modifier to a binding, but thereâs a third useful way we can create animations: explicitly asking SwiftUI to animate changes occurring as the result of a state change.
This still doesnât mean we create each frame of the animation by hand â that remains SwiftUIâs job, and it continues to figure out the animation by looking at the state of our views before and after the state change was applied.
Now, though, weâre being explicit that we want an animation to occur when some arbitrary state change occurs: itâs not attached to a binding, and itâs not attached to a view, itâs just us explicitly asking for a particular animation to occur because of a state change.
To demonstrate this, letâs return to a simple button example again:
struct ContentView: View {
var body: some View {
Button("Tap Me") {
// do nothing
}
.padding(50)
.background(.red)
.foregroundColor(.white)
.clipShape(Circle())
}
}
When that button is tapped, weâre going to make it spin around with a 3D effect. This requires another new modifier, rotation3DEffect()
, which can be given a rotation amount in degrees as well as an axis that determines how the view rotates. Think of this axis like a skewer through your view:
- If we skewer the view through the X axis (horizontally) then it will be able to spin forwards and backwards.
- If we skewer the view through the Y axis (vertically) then it will be able to spin left and right.
- If we skewer the view through the Z axis (depth) then it will be able to rotate left and right.
Making this work requires some state we can modify, and rotation degrees are specified as a Double
. So, please add this property now:
@State private var animationAmount = 0.0
Next, weâre going to ask the button to rotate by animationAmount
degrees along its Y axis, which means it will spin left and right. Add this modifier to the button now:
.rotation3DEffect(.degrees(animationAmount), axis: (x: 0, y: 1, z: 0))
Now for the important part: weâre going to add some code to the buttonâs action so that it adds 360 to animationAmount
every time itâs tapped.
If we just write animationAmount += 360
then the change will happen immediately, because there is no animation modifier attached to the button. This is where explicit animations come in: if we use a withAnimation()
closure then SwiftUI will ensure any changes resulting from the new state will automatically be animated.
So, put this in the buttonâs action now:
withAnimation {
animationAmount += 360
}
Run that code now and I think youâll be impressed by how good it looks â every time you tap the button it spins around in 3D space, and it was so easy to write. If you have time, experiment a little with the axes so you can really understand how they work. In case you were curious, you can use more than one axis at once.
withAnimation()
can be given an animation parameter, using all the same animations you can use elsewhere in SwiftUI. For example, we could make our rotation effect use a spring animation using a withAnimation()
call like this:
withAnimation(.interpolatingSpring(stiffness: 5, damping: 1)) {
animationAmount += 360
}