Building Engaging User Interfaces with SwiftUI

Mar 12 2025 · Swift 5.9, iOS 17.0, XCode 15.0

Lesson 01: Mastering SwiftUI Animations

Linking View Transitions

Episode complete

Play next episode

Next
Transcript

In this segment, you’ll use the matchedGeometryEffect method to synchronize multiple views’ animations. Open AwardsView.swift under the AwardsView group. When you tap an award, it transitions to a view displaying its details. You’ll change it to pop up the award details over the grid.

Add the following code to the top of the view after the flightNavigation:

@State var selectedAward: AwardInformation?

When the user taps an award, you’ll store it in this optional state variable. Otherwise, the property will be nil. Because that tap action occurs in a subview, you need to pass this into that subview.

Open AwardGrid.swift. Add the following binding after the awards property:

@Binding var selected: AwardInformation?

Using this binding, you’ll pass the state from the AwardsView to the AwardGrid. Replace the NavigationLink in the ForEach loop with:

AwardCardView(award: award)
  .foregroundColor(.black)
  .aspectRatio(0.67, contentMode: .fit)
  .onTapGesture {
    selected = award
  }

You’ve removed the navigation link and added an onTapGesture(count:perform:) modifier to set the binding to the tapped award.

You also must update the preview to add the new binding parameter. Change it to:

AwardGrid(
  title: "Test",
  awards: AppEnvironment().awardList,
  selected: .constant(nil)
)

Now, go back to AwardsView.swift and change the view’s body to:

ZStack {
  // 1
  if let award = selectedAward {
    // 2
    AwardDetails(award: award)
      .background(Color.white)
      .shadow(radius: 5.0)
      .clipShape(RoundedRectangle(cornerRadius: 20.0))
      // 3
      .onTapGesture {
        selectedAward = nil
      }
      // 4
      .navigationTitle(award.title)
  } else {
    ScrollView {
      LazyVGrid(columns: awardColumns) {
        AwardGrid(
          title: "Awarded",
          awards: activeAwards,
          selected: $selectedAward
        )
        AwardGrid(
          title: "Not Awarded",
          awards: inactiveAwards,
          selected: $selectedAward
        )
      }
    }
    .navigationTitle("Your Awards")
  }
}
.background(
  Image("background-view")
    .resizable()
    .frame(maxWidth: .infinity, maxHeight: .infinity)
)

You now have a ZStack that shows one of two views depending on the if statement results. The code in the else condition didn’t change other than passing the binding to the selectedAward state variable. Some changes are worth noting:

  1. The first change is that you attempt to unwrap the selectedAward state variable. If that fails, you show the grid as before in the else part of the statement.
  2. If the unwrapping succeeds, you display the AwardDetails view that was previously the NavigationLink target.
  3. When the user taps the view, you set the selectedAward state variable back to nil. This change removes the AwardDetails view and displays the grid.
  4. You set the title to the current award’s name.

Run the app. Tap Your Awards and then tap any award in the grid. You see the view changes to the large details display for the award. Tap once more and the grid reappears.

The transition is abrupt. You already know you can fix that by adding a view transition. Find the onTapGesture modifier in AwardsView, under comment three, and change it to:

.onTapGesture {
  withAnimation {
    selectedAward = nil
  }
}

Hopefully, you remember from the last section that this tells SwiftUI to animate events caused by the state change in the closure. For the other end of the transition in AwardGrid.swift, find the onTapGesture modifier in AwardGrid and change it to:

.onTapGesture {
  withAnimation {
    selected = award
  }
}

Run the app, and you’ll find the change works better. Now, you have the default fade-out/fade-in effect that smooths the previously harsh transitions between the views. But there’s still no sense of connection between the views. That’s where matchedGeometryEffect(id:in:properties:anchor:isSource:) comes in. It lets you connect the two view transitions.

You must specify the first two parameters. The id works much like other IDs you’ve encountered in SwiftUI. It uniquely identifies a connection, so giving two items the same id links their animations. You pass a Namespace to the in property. The namespace groups related items, and the two together define unique links between views.

Creating a namespace is simple. At the top of AwardsView, add the following code after the selectedAward state variable.

@Namespace var cardNamespace

Now, you have a unique namespace for the method.

After the onTapGesture modifier is attached to AwardDetails (before comment 4), add the following:

.matchedGeometryEffect(
  id: award.hashValue,
  in: cardNamespace,
  anchor: .topLeading
)

You use the existing hashValue property as the identifier along with your created namespace. You can use any identifier as long as it’s unique in the namespace and consistent. You also specify the anchor parameter to identify a location in the view used to produce the shared values. It’s not always needed, but in this case, it improves the animation.

Now, you have one side set up but need to link the state change in the subview. To do so, you must pass the namespace into that view. Change the AwardGrids in the LazyVGrid to add the namespace as a parameter:

AwardGrid(
  title: "Awarded",
  awards: activeAwards,
  selected: $selectedAward,
  namespace: cardNamespace
)
AwardGrid(
  title: "Not Awarded",
  awards: inactiveAwards,
  selected: $selectedAward,
  namespace: cardNamespace
)

Now, open AwardGrid. First, you must add a property to capture the passed namespace. After the selected binding, add the following code:

var namespace: Namespace.ID

When you pass a namespace into a view, it comes in as a Namespace.ID type. Add the following code after the onTapGesture(count:perform:) call:

.matchedGeometryEffect(
  id: award.hashValue,
  in: namespace,
  anchor: .topLeading
)

Notice this uses the namespace you passed in and therefore is the same namespace as the parent view. Again, you also use the hashValue property on the award, the same as you used in the parent view. SwiftUI knows to link the transitions when you pass in the same namespace.

You also need to update the preview to pass this new parameter:

#Preview {
  @Namespace var namespace

  return AwardGrid(
    title: "Test",
    awards: AppEnvironment().awardList,
    selected: .constant(nil),
    namespace: namespace
  )
}

Run the app now. When you tap an award in the small grid, it shifts and expands while changing to the AwardDetails view. Similarly, when you tap AwardDetails view, it appears to shrink and move back to the smaller view in the grid.

Adding matchedGeometryEffect() only arranges for the views’ geometry to be linked. The usual transition mechanisms applied to the views still occur during the transition.

See forum comments
Cinema mode Download course materials from Github
Previous: Using View Transitions Next: Conclusion