SwiftUI Animation

SwiftUI

SwiftUI is a declarative UI framework. That's not only limited to how you position them, but also how you animate them. Animation is an essential part of UI these days. In this article, we will see how easy it is to animate SwiftUI view.

Let's start with an example of how we animate view In UIKit. In this article, we will play around with simple animation, an arrow button that rotates whenever users tap it.

class ViewController: UIViewController {
var showDetail = false

override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white

let conf = UIImage.SymbolConfiguration(font: .systemFont(ofSize: 50))
let image = UIImage(systemName: "chevron.right.circle", withConfiguration: conf)
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
button.setImage(image, for: .normal)
button.addTarget(self, action: #selector(didTapButton(sender:)), for: .touchUpInside)
view.addSubview(button)
view.addConstraints([
view.centerXAnchor.constraint(equalTo: button.centerXAnchor),
view.centerYAnchor.constraint(equalTo: button.centerYAnchor)
])
}

@objc func didTapButton(sender: UIButton) {
showDetail.toggle()

UIView.animate(withDuration: 0.3) {
if self.showDetail {
let radian = 90 * CGFloat.pi / 180
sender.transform = CGAffineTransform(rotationAngle: radian)
} else {
sender.transform = CGAffineTransform.identity
}
}
}
}

If you write the above code in SwiftUI, you can reduce the line of code by more than a half.

struct CustomView: View {
@Binding var showDetail: Bool

var body: some View {
Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle").font(.system(size: 50))
.rotationEffect(.degrees(showDetail ? 90 : 0))
}
}
}
No animation
SwiftUI changes with no animation

The above example has no animation yet. The default animation for state changes is fade in and out. There are many ways to make SwiftUI animate. We would go through all of them one by one.

Add Animations to Individual Views #

To make view animate, you apply animation(_:)[1] modifier to a view. The animation applies to all child views within the view that applied for animation.

struct CustomView: View {
@Binding var showDetail: Bool

var body: some View {
Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle").font(.system(size: 50))
.rotationEffect(.degrees(showDetail ? 90 : 0))
.animation(.spring())
}
}
}
Spring animation
Spring animation

Right now, the rotation effect is animate with sprint animation. animation(_:) modifier applies to all animatable changes within the views it wraps. Let's try to add one more animatable change by scale up the image.

struct CustomView: View {
@Binding var showDetail: Bool

var body: some View {
Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle").font(.system(size: 50))
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.animation(.spring())

}
}
}
Rotate and scale animation
Rotate and scale animation

Multiple animations #

You can apply multiple animations if you want different animation for each change. The following example would turn off rotation animation by applying nil animation to the rotation effect.

struct CustomView: View {
@Binding var showDetail: Bool

var body: some View {
Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle").font(.system(size: 50))
.rotationEffect(.degrees(showDetail ? 90 : 0))
.animation(nil)
.scaleEffect(showDetail ? 1.5 : 1)
.animation(.spring())

}
}
}
No animation on rotation effect, while scale effect still change with spring animation
No animation on rotation effect, while scale effect still change with spring animation

An animation will apply to all animatable changes up until that point. Define two consecutive animations would result in the closest one take effect.

struct CustomView: View {
@Binding var showDetail: Bool

var body: some View {
Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle").font(.system(size: 50))
.rotationEffect(.degrees(showDetail ? 90 : 0))
.animation(nil) // This one will apply to .rotationEffect
.animation(.spring()) // This won't animate .rotationEffect
.scaleEffect(showDetail ? 1.5 : 1)
.animation(.spring())
}
}
}

Timing #

In UIKit, you can specify the animation duration and delay. In SwiftUI, you can also do that.

Most SwiftUI animation comes with a duration parameter with one exception, spring animation. Spring animation design in a way that let us specify spring characteristic, e.g., damping and stiffness, then duration is derived from these characters. This makes spring animation don't have duration parameter. Luckily, Apple provides another way to indirect adjust duration. The way we can change the duration is by using .speed[2].

.speed returns an animation that has its speed multiplied by speed. For example, if you had oneSecondAnimation.speed(0.25), it would be at 25% of its normal speed, so you would have an animation that would last 4 seconds. In brief, .speed with speed less than 1 would make animation slower, more than 1 would make animation faster.

newDuration = currentDuration / speed

There are more instance method to modify Animation like .delay and .repeat which quite straightforward. You can check it here.

Animate the Effects of State Changes #

Many views might rely on the same state. Instead of apply animations to individual views, you can also apply animations to all views by add animations in places you change your state's value. By wrapping the change of state in withAnimation function, all views that depend on that state would be animated.

From our example, By wrapping the call to .toggle() with a call to the withAnimation function, every change related to that state would be animated.

Remove all .animation and wrap self.showDetail.toggle() in withAnimation function.

struct CustomView: View {
@Binding var showDetail: Bool

var body: some View {
Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle").font(.system(size: 50))
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)

}
}
}

You can pass the same kinds of animations to the withAnimation function that you passed to the animation(_:) modifier. In the following example, we make use of spring animation.

struct CustomView: View {
@Binding var showDetail: Bool

var body: some View {
Button(action: {
withAnimation(.spring()) {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle").font(.system(size: 50))
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
}
}
}

You can still keep .animation functions in views, and it will take precedence over withAnimation. The following code will only animate scale, but not rotation.

struct CustomView: View {
@Binding var showDetail: Bool

var body: some View {
Button(action: {
withAnimation(.spring()) {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle").font(.system(size: 50))
.rotationEffect(.degrees(showDetail ? 90 : 0))
.animation(nil)
.scaleEffect(showDetail ? 1.5 : 1)
}
}
}

These are everything you need to know about animate changes in SwiftUI. The last thing you need to know about animation is transition.

Customize View Transitions #

Transition is an animation that uses when view transition on- and offscreen (hidden and show). By default, views transition on- and offscreen by fading in and out. You can customize this transition by using the transition(_:) modifier.

As an example, we will show Text view when showDetail becomes true.

struct CustomView: View {
@Binding var showDetail: Bool

var body: some View {
Button(action: {
withAnimation(.spring()) {
self.showDetail.toggle()
}
}) {
VStack {
Image(systemName: "chevron.right.circle").font(.system(size: 50))
.rotationEffect(.degrees(showDetail ? 90 : 0))

if self.showDetail {
Text("Detail")
}
Spacer()
}


}
}
}
Default transition
Default transition is fading in and out

The default transition is fade in and out, which should be good enough for most cases, but you also modify the transition. We will specify a transition Animation that makes Text view move from the top edge.

struct CustomView: View {
@Binding var showDetail: Bool

var body: some View {
Button(action: {
withAnimation(.spring()) {
self.showDetail.toggle()
}
}) {
VStack {
Image(systemName: "chevron.right.circle").font(.system(size: 50))
.rotationEffect(.degrees(showDetail ? 90 : 0))

if self.showDetail {
Text("Detail").transition(.move(edge: .top))
}
Spacer()
}


}
}
}
Move from top edge transition
Move from top edge transition

By specify move animation .transition(.move(edge: .top)), we make the text slide from the top edge. As you can see, the dismiss animation is not smooth as you might be expected (it slide up and hang there for a few seconds before disappear).

My first thought is to add animation to Text, but it doesn't work. Show/hide animation rely only on .transition. Luckily SwiftUI has a lot of built-in transition[3] which you can mix and match to meet your needs.

The following code won't make the transition fade in/out.

if self.showDetail {
Text("Detail")
.transition(.move(edge: .top))
.opacity(showDetail ? 1: 0) // This doesn't affect transition animation.
}

Mix and match #

You can mix two transitions with .combined[4]. It will return a new transition that is the result of both transitions being applied.

We want to add fade effect, so we combine .move with .opacity.

Text("Detail").transition(
AnyTransition.move(edge: .top).combined(with: .opacity)
)
Combination of move and opacity transition
Combination of move and opacity transition

Asymmetric animation #

If you don't want the same animation for show and hide, you can create a new animation with a different show (insertion) and hide (removal) animations using .asymmetric.

The following example, we create a new animation with .asymmetric with a slide from top appear animation and scale dismissal animation.

extension AnyTransition {
static var moveAndFade: AnyTransition {
let insertion = AnyTransition.move(edge: .top).combined(with: .opacity)
let removal = AnyTransition.scale
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}

And use it like other animations.

if self.showDetail {
Text("Detail").transition(.moveAndFade)
}
Asymmetric transition
Asymmetric transition

Conclusion #

SwiftUI's animation is very powerful. It can do what we can do in UIKit with less code, but that is not all. All animations in SwiftUI are also interruptible![5] If you have ever try interruptible animation in UIKit, you know how hard it is, this fact alone makes me want to use this in my project.

All SwiftUI's animations are interruptible
All SwiftUI's animations are interruptible

SwiftUI's ViewModifier – Learn a crucial concept in SwiftUI, view modifier, and a guide of how to create your custom modifier.
Animating Views and Transitions – Official SwiftUI's tutorial


  1. https://developer.apple.com/documentation/swiftui/view/3278508-animation ↩︎

  2. https://developer.apple.com/documentation/swiftui/animation/3263784-speed ↩︎

  3. https://developer.apple.com/documentation/swiftui/anytransition ↩︎

  4. https://developer.apple.com/documentation/swiftui/anytransition/3076194-combined ↩︎

  5. Tap mid-animation would reverse the animation state. ↩︎


Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.

If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.

Become a patron Tweet Share

If you enjoy this article, you can subscribe to the weekly newsletter.

Every Friday, you’ll get a quick recap of all articles and tips posted on this site — entirely for free.

← Home