DEV Community

Sarun W.
Sarun W.

Posted on • Originally published at sarunw.com on

SwiftUI Animation

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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())

        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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())

        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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())             
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)                
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
            }


        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
            }


        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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)
)
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

And use it like other animations.

if self.showDetail {
    Text("Detail").transition(.moveAndFade)
}
Enter fullscreen mode Exit fullscreen mode

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


Originally published at https://sarunw.com/posts/swiftui-animation/.
Find out my latest article and more at http://sarunw.com I post articles weekly there.

Subscribe

Related Resources

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. ↩︎

Latest comments (1)

Collapse
 
yourdevguy profile image
Mike Haslam

Thanks for sharing great resource. I really need to wrap my head around animations in SwiftUI