SwiftUI Hero Animation with Matched Geometry Effect

Safwen Debbichi
Bforbank Tech
Published in
5 min readJan 25, 2024
Photo by Mehdi MeSSrro on Unsplash

What is a hero animation ?

A “hero” animation involves animating views as they transition or “fly” from one view to another. An example of this animation is prominently seen in the App Store. On the homepage, a list of featured applications is categorized, and when tapping on a thumbnail, it smoothly transitions to a details view, showcasing more information in an elegant manner.

App Store — Hero Animation

Matched Geometry Effect

Matched Geometry Effect is a ViewModifier that was introduced on iOS 14. Based on the Apple’s developer documentation it simply synchronizes a group of views in a geometry identified by a name space.

The ViewModifier takes five parameters:

  • id: The id of the group that should conform to the protocol Hashable. It generally reflects an id of the shared data between the views.
  • namespace: The id of the geometry namespace. It’s the wrappedValue of the Dynamic Property Wrapper @Namespace.
  • properties: A collection of properties requiring synchronization among a group of views. By default, views are synchronized based on their frames.
  • anchor: The anchor point of the transaction. Useful if we specify the properties.
  • isSource: A view is marked as source if it should be considered as the source of the geometry. There must be only one view considered as source in same view group.

Hero Animation Example

Matched Geometry Effect can be used for a variety of animations, for exemple: transforming the shape of a view, moving views …

In the following example we will use the Matched Geometry Effect to achieve the smooth effect of Hero animation. I will take as an example an application that lists featured movies with thumbnails and when i tap on a movie, the thumbnail expands with more details.

It’s a typical and simple example of Hero animation :

Hero Animation — github.com/SafwenD/iOS-HeroAnimation

Let’s Code 😎

First let’s create the Movie thumbnail view, i call it a MovieView. The view takes as a parameter a Movie which is a struct :

struct Movie: Identifiable {
let id: UUID = UUID()
let title, image, duration, category, rating, description: String
}

and it takes a Binding of a Bool to be able to dismiss the detailed version of the MovieView. It takes a Bool also that indicates if the view has more details or not.

struct MovieView: View {
let movie: Movie
@Binding var isActive: Bool
@State private var showCloseButton: Bool = false
let detailed: Bool

struct ViewConstants {
static let maxCardHeight: CGFloat = 700
static let detailedOffset: CGFloat = 500
}

var body: some View {
ScrollView {
ZStack(alignment: .bottomLeading) {
MoviePosterView(imageUrlStr: movie.image, contentMode: .fill)
.overlay(closeButton , alignment: .topTrailing)
VStack(alignment: .leading, spacing: .zero) {
titleView()
infosTile()
}.offset(y: detailed ? ViewConstants.detailedOffset : .zero)
}
.frame(maxWidth: .infinity, maxHeight: detailed ? .infinity : ViewConstants.maxCardHeight)
}
.clipShape(RoundedRectangle(cornerSize: .init(width: 24, height: 24)))
.onAppear {
if detailed {
withAnimation(.easeInOut.delay(1.0)) {
showCloseButton.toggle()
}
}
}
}

private func dismiss() {
withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.7, blendDuration: 0.7)) {
self.isActive = false
}
}

@ViewBuilder
private var closeButton: some View {
if showCloseButton {
Button(action: {
dismiss()
}, label: {
Image(systemName: "xmark.circle.fill")
.resizable()
.foregroundStyle(.white)
.frame(width: 25, height: 25)
.padding([.top, .trailing], 25)
})
}
}

private func titleView() -> some View {
Text(movie.title)
.font(.title)
.fontWeight(.semibold)
.foregroundStyle(.white)
.padding()
}

private func infosTile() -> some View {
VStack {
HStack(spacing: 14) {
Text("\(movie.rating) \(Image(systemName: "star.fill"))")
.bold()
Text(movie.category)
.font(.caption)
Spacer()
Text(movie.duration)
.font(.caption)
}.frame(maxWidth: .infinity, alignment: .leading)
.padding()
if detailed {
VStack {
Text(movie.description)
.font(.subheadline)
Spacer()
}
.padding()
}
}.background(Rectangle().foregroundStyle(.ultraThinMaterial))
}
}

The MoviePosterView in the code above is just an AsyncImage view.

So…nothing complicated, it’s just a view that can be used as a thumbnail if detailed is false and as movie detailed view if it’s true.

Now let’s move to the navigation: i would like to show the detailed view as an OverView of my main view. I don’t wont to use the fullScreenCover be cause i don’t have alot of control on how the view is being displayed or dismissed so i created my own ViewModifier to present a view as an OverView triggered by a Binding of an Identifiable.

struct ModalViewModifier<Destination: View, Bindable: Identifiable>: ViewModifier {
@Binding var value: Bindable?
let destination: (_ value: Bindable) -> Destination
func body(content: Content) -> some View {
ZStack(alignment: .top) {
content
if let value {
destination(value)
.background(Color.black)
.ignoresSafeArea()
.statusBarHidden(value != nil)
}
}
}
}

I’m not going to go through Data Layer and how i fetch the movies list, so i use hardcoded list in my ViewModel.

Now the last part is MoviesListView :

struct MoviesListView: View {
@StateObject var viewModel: MoviesListViewModel
@State var scale: CGFloat = 1.0
@Namespace var namespace
var body: some View {
NavigationView {
ScrollView {
ForEach(viewModel.movies, id: \.id) { movie in
Button(action: {
viewModel.didTapOnMovie(movie: movie)
}, label: {
movieCardView(movie: movie)
.matchedGeometryEffect(id: movie.id, in: namespace)
}).buttonStyle(ScaleButtonStyle())
}
}.scrollIndicators(.hidden)
.navigationTitle("Featured")
.navigationBarTitleDisplayMode(.large)

}
.modal(bindable: $viewModel.selectedMovie, destination: { movie in
movieCardView(movie: movie)
.matchedGeometryEffect(id: movie.id, in: namespace)

})
.task {
await viewModel.loadMoviesList()
}
}

private func movieCardView(movie: Movie) -> some View {
MovieView(movie: movie, isActive: $viewModel.selectedMovie.toBoolBinding, detailed: viewModel.selectedMovie?.id == movie.id)
}
}

As you can see in the above code, i declared a namespace for my main view in which the geometry of the group of views is defined. Then i simply applied the matchedGeometryEffect ViewModifier on both the thumbnail and on the detailed view and specified the same id for both views and the same namespace.

This shows the power of matchedGeometryEffect.

Conclusion

In the above example, i simply applied the ViewModifier and magic happens ! However we can achieve more sophisticated and advanced animations using the ViewModifier.

Example’s source code: https://github.com/SafwenD/iOS-HeroAnimation

--

--