SwiftUI: Understanding identity via transitions

There is a lot to learn about identity in SwiftUI. One way to go about it is to understand the role of identity in transitions.

In SwiftUI, identity holds the key to understanding how the rendering system works. A View's identity tells SwiftUI which of that View's values correspond to the same rendered view over time. This identity has strong implications for correctness, performance, and as we will see, transitions.

Types of identities🔗

As we learn from Demystifying SwiftUI, there are two ways in which SwiftUI understands identity:

  • structural identity, which is implicit in how our code is structured. It is inferred statically by the type system.
  • explicit identity, which can be specified with a modifier (.id()), and gives us full control over identity at runtime.

Identity and Transitions🔗

Transitions rely heavily on identity: whenever a View's value has a new identity, that value will be treated as an insertion, and the previous value will be a removal. If there is an animation in effect, the transition will be animated.

Structural Identity🔗

With structural identity in the simplest case, there is no change in identity over time; in the code below, ValueView is always the "same" view, even if the value is changing. Although different values of the ValueView are generated with every button action, the identity of all these values remain the same. And if the identity of the values remain the same, there will be no insertion or removal in the view tree, and thus no transition (code):

The demo code can be run to see all the examples.
ValueView(value: value)
    // This transition has no effect.
    // The type system treats the view above as always the same.
    .transition(transition)

// somewhere else in the view hierarchy:
Button {
    withAnimation {
        value += 1
    }
}
Transitions are orthogonal to animations. An animation is defined internally to a view, here ValueView, and would happen when there is any change in the size, position, color etc. of its children. These animations do not have any direct relation to identity. There is a slight animation in the video as the size of the Text inside of ValueView changes, most obviously between 0 and 1, since we are using a proportional font.

Now, we can trigger a transition by using structural identity alone. Here, the if produces different ValueViews from each of its branches. So whenever the condition changes, we will get a transition. In this case, that would be on the first tap when value changes from 0 to 1. (omitting the Button code here, which stays the same)(code):

Group {
    if value > 0 {
        ValueView(value: value)
    } else {
        ValueView(value: value)
    }
}
.transition(transition)

But building transitions using structural identity is clearly neither obvious nor scalable. So let's use explicit identity.

Explicit Identity🔗

Explicit identity is specified by .id(someMeaningfulId).

If we give the ValueView view an explicit identity with .id(value), we are telling SwiftUI that there is a new view to display with every new value, and so we get a transition (code):

 ValueView(value: value)
    .id(value)
    .transition(transition)

Just to be clear, the fact that the parameter of ValueView (in ValueView(value: value)) is changing, has nothing to do with the transition. We would have the same transition if we passed a constant value instead of passing value, but still giving it a dynamic id:

 ValueView(value: 1000)
    .id(value)
    .transition(transition)

Or on the flip side, we can control the identity more coarsely. Now the transition will only happen once when value switches to from 3 to 4:

 ValueView(value: value)
    .id(value > 3 ? 1 : 0)
    .transition(transition)

The main idea is that with explicit identity, the parent gets to control when the View has changed identity with the id() modifier, and this controls the transition.

Explicit identity is why Views like ForEach and List require their elements to conform to Identifiable. It allows these views to internally provide a stable identifier to each element's view. If the identity is incorrect, then the transitions will look incorrect as the list of element changes. Most commonly this incorrect behavior happens if we just use the index of a collection as the id.

A more complex example🔗

For Memorize, my flash cards app, I wanted a flip and slide animation as part of the study interaction:

The CardView displays the text for either side, the question and the answer. I wanted to have a flip between question and answer, and then a slide from the answer side of one card, to the front of the next card.

The rest of this post describes the two approaches I tried to achieve this effect.

An animation and a transition🔗

(code)

One way to achieve this is to have an animation for the flip and a transition for the slide.

The flip will be an animation internal to the CardView (and has no relation to the identity and transitions).

struct CardView: View {
    let card: Card
    let side: Card.Side

    func sideView(side: Card.Side) -> some View {
        // Different colored faces for front and back
        // ...
    }

    var body: some View {
        ZStack {
            sideView(side: .front)
                .rotation3DEffect(.degrees(side == .front ? 0 : 180), axis: (0, 1, 0))
                .opacity(side == .front ? 1 : 0)
            sideView(side: .back)
                .rotation3DEffect(.degrees(side == .back ? 0 : -180), axis: (0, 1, 0))
                .opacity(side == .back ? 1 : 0)
        }
    }
}

// Contained in another view that drives the animation:

struct FlipAndSlideAnimationPlusTransition: View {
    @State var side = Card.Side.front

    var body: some View {
        CardView(card: card, side: side)
            .onTapGesture {
                withAnimation(animation) {
                    side = .back
                }
            }
    }
}

The two sides are displayed together in the ZStack. The current side view is opaque and facing forward, and the other one is flipped 180° and transparent. The CardView is contained in a container view, which controls the switching of the side from outside on a tap gesture. This gives us the flipped animation thanks to the rotation3DEffect, plus a transparency animation.

Again, there is no transition in place yet, just an animation.

So let's build the transition next: it will drive the slide to the next card. We will do that by explicitly specifying the identity as card.id. That means when the Card is changed we want the insertion and removal transitions. This makes sense logically, and is easy to set up:

CardView(card: card, side: side)
    .id(card.id)
    .transition(transition)

where the transition is:

var transition: AnyTransition {
    .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
}

Only transition🔗

(code)

But --- and this is the control that identity gives you --- we can remove the flip animation from the CardView's internals and do both the flip and the slide as part of the transition, driven by the parent view.

So, first we remove the multiple side views from the CardView so that there is nothing there to animate. It just displays the current side it is given:

CardViewWithoutAnimations {
    let side: Card.Side

    var body: some View {
        sideView(side: side)
    }
}

The most crucial part is that we need to change the id to something that changes whenever the card's side changes and when the card's id changes:

CardViewWithoutAnimations(card: card, side: side)
    // ideally, we would use a hash that's unique 
    // for every combination of card and side. But a + will suffice for the demo.
    .id(card.id + side.id)

Then we change the transition from the parent view to something more sophisticated:

struct FlipAndSlideOnlyTransitionsView: View {
    let side: Card.Side
    
    var body: some View {
        // ...
        CardViewWithoutAnimations(card: card, side: side)
            .id(card.id + side.id)
            .transition(transition)
        // ...
    }

    var fullTransition: AnyTransition {
        if side == .front {
            return .asymmetric(
                insertion: .move(edge: .trailing),
                removal: .flip(direction: 1).combined(with: .opacity)
            )
        } else {
            return .asymmetric(
                insertion: .flip(direction: -1).combined(with: .opacity),
                removal: .move(edge: .leading)
            )
        }
    }
}

(here .flip is a custom transition that's making use of the original rotation3DEffect from the first example).

When the side is .front:

  • insertion means we are bringing in a new card, so we move in the card from the edge
  • removal means we are flipping from front to back

And when side is .back:

  • insertion means flipping from front to back
  • removal means we are removing the current card

Identity holds the key🔗

Identity holds the key when implementing transitions correctly. But the understanding of identity itself is more fundamental. In a way, working through transitions gave me a much clearer view to what identity means, especially explicity identity, and how it can be tweaked.