In this post we will take a closer look at transitions in SwiftUI. SwiftUI provides powerful APIs which do a lot of heavy lifting and makes it really easy for developers to add powerful transitions to their app.

First thing to notice with SwiftUI is that it provides animations and transitions and the question that follows: what is the difference between them? In simple terms, transitions are animations that are triggered when a view is added or removed. Animations apply to changes to any aspect of the view i.e. state, frame, translation, rotation etc. Transitions also require you to set an animation which makes things a little more murky. This is because transitions define the effect that will be used when adding/removing the view and animation provides the timing curve over which that affect will be played out. This will become clearer as we look at examples below

Sliding Menu Example

Lets take the example of a sliding menu from left that we want to animate in and out of the view when the user presses a button. Lets start with drawing out the menu and hooking it up to a button on screen that will open and close it.

Lets first create the menu and conditionally add it to the UI when user taps a button:

struct SimpleTransitionSample: View {
    @State var showMenu: Bool = true

    var menuButtonIcon: Image {
        if showMenu {
            return Image(systemName: "xmark.square")
        }
        else {
            return Image(systemName: "menucard")
        }
    }

    var body: some View {
        HStack(alignment: .top) {
            if showMenu {
                Menu()
            }

            Button {
                self.showMenu.toggle()
            } label: {
                menuButtonIcon
            }
            .padding()
            .foregroundColor(.black)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
    }
}

struct Menu: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 40) {
            Button(action: { }) {
                Image(systemName: "person.crop.circle.fill")
            }
            .padding()

            Button(action: { }) {
                Image(systemName: "gearshape.fill")
            }
            .padding()

            Button(action: { }) {
                Image(systemName: "square.and.arrow.up.fill")
            }
            .padding()

            Spacer()

            Button(action: { }) {
                Image(systemName: "info.circle.fill")
            }
            .padding()
        }
        .padding(.horizontal)
        .foregroundColor(.black)
        .background(Color.brown)
    }
}

struct SimpleTransitionSample_Previews: PreviewProvider {
    static var previews: some View {
        SimpleTransitionSample()
    }
}

If you try this in the simulator or preview, you can now see the menu being added/removed when you press the button. For testing, I have set the initial state to be visible. The layout of the menu is very simplistic as that is not the focus of this post.

Adding Animation

Now we can start discussing ways to animate the menu to slide in and out on button click. Lets first talk about a way of doing it with animation but without using transition by updating the code to:

var body: some View {
        HStack(alignment: .top) {
            Menu()
                .opacity(showMenu ? 1.0 : 0.0)
                .offset(x: showMenu ? 0.0 : -100.0)
                .animation(.default, value: showMenu)

            Button {
                self.showMenu.toggle()
            } label: {
                menuButtonIcon
            }
            .padding()
            .foregroundColor(.black)
            .offset(x: showMenu ? 0 : -100)
            .animation(.default, value: showMenu)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}

You can see now that the menu slides in and out as well as fades in and out. The button just slides along the menu. While this works and achieves what we wanted to do, there is 1 big issue with this i.e. the magic number -100. This is really easy to break e.g. if the user has accessibility font sizes enabled. Ideally we want to offset it by the menu’s width and calculate that width dynamically so it is always correct. This is entirely possible using GeometryReader but there is a much easier way to manage this using transitions so that is what we will look at instead.

Using Transitions on Menu

We can replace the conditional offset and opacity above with transition as below:

var body: some View {
        HStack(alignment: .top) {
            if showMenu {
                Menu()
                    .transition(
                        .move(edge: .leading).combined(with: .opacity)
                    )
            }

            Button {
                self.showMenu.toggle()
            } label: {
                menuButtonIcon
            }
            .padding()
            .foregroundColor(.black)
        }
        .animation(.default, value: showMenu)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}

And we get the exact same effect as before but now there are no magic numbers required! One important thing to note here is how the .animation() is applied to the HStack instead of Menu(). This is to let SwiftUI animate all changes together and to evaluate when a view is being added or removed. As an exercise, if you add the .animation() after the .transition instead, you will notice that removal animates but adding the menu does not.

I have used the .default animation (which is .easeInOut) here but you can try varying intervals and curves to change it as needed. For example, try the follow spring animation to get a bounce effect:

.animation(.spring(response: 0.35, dampingFraction: 0.5), value: showMenu)

Transition API

The transition API is provides a lot of convenience when adding transitions. The APIs are designed to be very flexible. Some transitions that are available in the API include:

  • slide
  • move(edge: )
  • opacity
  • offset
  • scale

You will noticed that these same affects are easily achieved with custom animation as well just like what we started off with. However, the transition API’s true flexibility comes from these next 2 APIs

  • combined(with other: AnyTransition)
  • asymmetric(insertion: AnyTransition, removal: AnyTransition) -> AnyTransition

This allows us to combine multiple transitions together into one as we did for the sliding menu example with .move and .opacity. In case of sliding menu example above, we only specified the entry transition and got the exit transition for free. However, if the entry and exit transitions are supposed to be different, then we can use the .asymmetric API which allows us to specify different transitions for insertion and removal. Add to this the ability to implement custom transitions and this becomes a really powerful set of APIs to implement any transition effect you may want to implement. You can try the following to slide the menu in from leading edge and dismiss it to the top:

.transition(
    .asymmetric(
        insertion: .move(edge: .leading).combined(with: .opacity),
        removal: .move(edge: .top).combined(with: .opacity)
    )
)

Accessibility

It is important to consider accessibility requirements when designing and developing any app. When it comes to animations, this is really simple to do. You only need to listen to the accessibilityReduceMotion environment variable and respond to it. This can be achieved by adding the following:

@Environment(\.accessibilityReduceMotion) private var shouldReduceMotion: Bool
...
...
.animation(shouldReduceMotion ? nil : .default, value: showMenu)