SwiftUI Hero Animations with NavigationTransition

Replicating the App Store Hero Animation


Jun 21, 2024 • 9 min read

We all know and love the App Store’s hero animation - it’s great for visually rich UIs, such as the App Store’s Today view, the Apple TV app, the Explore stream in AirBnB, and many other apps.

With SwiftUI’s NavigationTransition protocol, introduced in iOS 18, implementing a hero animation is a matter of just three lines of code.

In this article, you will learn how to implement a hero animation that looks similar to the one in the App Store’s Today view. Achieving this look and feel requires more than just three lines of code, so we will also look into making this a reusable SwiftUI component.

Zooming from zero to hero

Let’s imagine we’re working on an app that allows users to browse gradients. The following snippet displays a list of meshed gradients. The user can navigate to a details screen by tapping on any of the cards.

import SwiftUI
 
struct PlainNavigationDemo: View {
  var gradients = GradienConfiguration.samples
 
  var body: some View {
    NavigationStack {
      ScrollView {
        ForEach(gradients) { gradient in
          NavigationLink(value: gradient) {
            GradientView(configuration: gradient)
              .frame(height: 450)
              .padding(16)
          }
        }
        .navigationDestination(for: GradienConfiguration.self) { gradient in
          GradientView(configuration: gradient)
        }
      }
    }
  }
}
 
#Preview {
  PlainNavigationDemo()
}

This drill-down navigation is common for many iOS apps, and has been around since the very first version of iOS. It’s well suited for navigating from list items to their details (like in the Contacts app). However, for our beautiful meshed gradients (or a movie poster, a product card, or any other visually rich views), it doesn’t feel snazzy enough.

Thanks to SwiftUI’s new NavigationTransition protocol and its associated view modifiers, we can make this more appealing with just a few lines of code:

import SwiftUI
 
struct HeroNavigationDemo: View {
  @Namespace var namespace 
  var gradients = GradienConfiguration.samples
 
  var body: some View {
    NavigationStack {
      ScrollView {
        ForEach(gradients) { gradient in
          NavigationLink(value: gradient) {
            GradientView(configuration: gradient)
              .matchedTransitionSource(id: gradient.id, in: namespace)
              .frame(height: 450)
              .padding(16)
          }
        }
        .navigationDestination(for: GradienConfiguration.self) { gradient in
          GradientView(configuration: gradient)
            .navigationTransition(.zoom(sourceID: gradient.id, in: namespace))
        }
      }
    }
  }
}
 
#Preview {
  HeroNavigationDemo()
}

There are three main steps to implementing a hero animation:

  1. Define a namespace to allow SwiftUI to track the identity of the source and target view across the animation.
  2. Add the matchedTransitionSource view modifier to the view you want to transition from, and specify the namespace you defined in the first step, and an id for the view within that namespace.
  3. On the target view, use the navigationTransition view modifier, and specify the animnation you want to use. At the moment, the only animations available are .automatic (which is the default slide-over animation), and .zoom , which enables the hero animation. When using .zoom, you need to specify the same namespace and id you used on the source view.

Adjusting the appearance of the source view

The design of the source view leaves a bit to be desired (even though we’re using a gradient…), but thankfully there is an overloaded version of the . matchedTransitionSource view modifier that we can use to adjust the appearance of the source view.

GradientView(configuration: gradient)
  .matchedTransitionSource(id: gradient.id, in: namespace, configuration: { source in
    source 
      .background(.accentColor)
      .clipShape(RoundedRectangle(cornerRadius: 16))
      .shadow(radius: 8)
  })
  .frame(height: 450)
  .padding(16)

Inside the configuation closure of the .matchedTransformationSource view modified, we can use the source parameter to adjust the appearance of the source view.

It’s worth noting that source does not conform to View and is not a direct representation of the source view itself. Instead, it conforms to the EmptyMatchedTransitionSourceConfiguration protocol.

This means we can only control the shadow, background color, and clip shape of the source view. It’s also not possible to wrap the source view inside another view, as the return type of the closure is MatchedTransitionSourceConfiguration.

It’s surprising that the clipShape modifier only supprts RoundedRectangle - so if you were hoping to use UnevenRoundedRectangle for some fancy asymtrical rounded corners, you might need to file a feedback with Apple to get supports for this in one of the next betas.

But even with the limited number of view modifiers, we can achieve a much more appealing visual appearance of the source view.

Adding a dismiss button

If you compare the details view with the details screen of the App Store’s Today section, you will notice that the App Store details screen doesn’t have a back button - instead, the user can dismiss the screen by either tapping on the dismiss button in the top right corner, or by pulling down on the screen.

Let’s implement the dismiss button first.

SwiftUI’s zoom animation can transition from any view to any other view, so we can create a wrapper view that hides the nvigatino bar and overlays the GradientView with a dismiss button.

To make the code easier to read and more maintainable, it’s a good idea to create a separate view for this. This will also allow us to use the Dismiss action from the detail view’s environment.

struct DismissableView<Content: View>: View {
  @Environment(\.dismiss) private var dismiss
  private var content: () -> Content
 
  init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }
 
  var dismissButton: some View {
    HStack {
      Spacer()
      Button {
        dismiss()
      } label: {
        Image(systemName: "xmark")
          .frame(width: 30, height: 30)
          .foregroundColor(Color(uiColor: .label))
          .background(Color(uiColor: .systemBackground))
          .clipShape(Circle())
      }
      .padding([.top, .trailing], 30)
    }
  }
 
  var body: some View {
    ZStack(alignment: .topLeading) {
      content()
      dismissButton
    }
    .ignoresSafeArea()
    .navigationBarBackButtonHidden()
    .statusBarHidden()
  }
}

By wrapping the content in a ZStack, we can place the dismiss button on top of the main content. We can now get rid of the navigation back button and the status bar, and finally use ignoresSafeArea() to allow the content to take up the entire space of the screen.

We can now use the DismissableView at the call site:

.navigationDestination(for: GradienConfiguration.self) { gradient in
  DismissableView {
    GradientView(configuration: gradient)
  }
  .navigationTransition(.zoom(sourceID: gradient.id, in: namespace))
}

Dragging down to dismiss

In the App Store Today section, users can also drag down the details view to dismiss it. To implement this feature, we can make use of SwiftUI’s new onScrollGeometryChange view modifier. It provides an elegant way to trigger actions based on changes of a scroll view’s geometry.

In our case, we want to dismiss the view when the user pulls the view down for a certain amount.

var body: some View {
  ScrollView {
    ZStack(alignment: .topLeading) {
      content()
      dismissButton
    }
  }
  .ignoresSafeArea()
  .navigationBarBackButtonHidden()
  .onScrollGeometryChange(for: Bool.self) { geometry in
    geometry.contentOffset.y < -50
  } action: { _, isTornOff in
    if isTornOff {
      dismiss()
    }
  }
}

onScrollGeometryChange takes three parameters: the first one specifies the type we want to map scroll geometry changes to. We want to detect whether or not we should dismiss the view, so this is a Bool.

The second parameter is a closure in which we map scroll geometry changes to the type we specified in the first parameter. Here, we return true if the user drags the view down for more than 50 pixels.

The final parameter is a clousre that will be called when the value returned from the transform closure changes. It receives both the old and new value. In our case, we just need the new value - if it’s true, we know that the user has dragged the view down beyond the tear-off point, and we can dismiss the view.

Scaling down

To implement the scaling effect of the App Store’s Today details view, we can apply what we’ve learned in the previous section.

This time, we want to translate the scroll geometry changes to a scaleFactor of type CGFloat so we can scale the entire view accordingly. In addition, we change the opacity of the dissmiss button in response to the amount the user drags down.

@State var scaleFactor: CGFloat = 1
@State var cornerRadius: CGFloat = 16
@State var opacity: CGFloat = 1
 
var body: some View {
  ScrollView {
    ZStack(alignment: .topLeading) {
      content()
      dismissButton
        .opacity(opacity)
    }
    .scaleEffect(scaleFactor)
  }
  .ignoresSafeArea()
  .navigationBarBackButtonHidden()
  .background(Color(UIColor.secondarySystemBackground))
  .scrollIndicators(scaleFactor < 1 ? .hidden : .automatic, axes: .vertical)
  .onScrollGeometryChange(for: CGFloat.self) { geometry in
    geometry.contentOffset.y
  } action: { oldValue, newValue in
    if newValue >= 0 {
      scaleFactor = 1
      cornerRadius = 16
      opacity = 1
    }
    else {
      scaleFactor = 1 - (0.1 * (newValue / -50)) 
      cornerRadius = 55 - (35 / 50 * -newValue) 
      opacity = 1 - (abs(newValue) / 50) 
    }
  }
  .onScrollGeometryChange(for: Bool.self) { geometry in
    geometry.contentOffset.y < -50
  } action: { _, isTornOff in
    if isTornOff {
      dismiss()
    }
  }
}

Disable highlighting the source view

The final tweak is to get rid of the highlight SwiftUI applies to the source view when the user taps it. To achieve this, we can define a new ButtonStyle that doesn’t implement any highlighting:

import SwiftUI
 
struct NoHighlightButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
  }
}
 
extension ButtonStyle where Self == NoHighlightButtonStyle {
  static var noHighlight: NoHighlightButtonStyle {
    get { NoHighlightButtonStyle() }
  }
}

Apply the style on the NavigationLink like so:

NavigationLink(value: gradient) {
  GradientView(configuration: gradient)
    .matchedTransitionSource(id: gradient.id, in: namespace, configuration: { source in
      source
        .background(gradient.colors.last ?? .gray)
        .clipShape(RoundedRectangle(cornerRadius: 16))
        .shadow(radius: 8)
    })
    .frame(height: 450)
    .padding(16)
}
.buttonStyle(.noHighlight)

Conclusion

SwiftUI 16 makes it easier than ever before to implement hero animations that allow us to implement UIs that delight our users. With just three lines of code, we were able to add a transition that looks similar to the App Store’s Today view hero animation. And with a little bit of extra effort, we were able to improve the user experience even more.

If you’re interested in learning more about building custom SwiftUI components, check out my interactive tutorial Building Reusable SwiftUI Components, and don’t forget to follow me on Twitter - I regularly post about SwiftUI, Firebase, AI, and other topics.

Newsletter
Enjoyed reading this article? Subscribe to my newsletter to receive regular updates, curated links about Swift, SwiftUI, Combine, Firebase, and - of course - some fun stuff 🎈