matchedGeometryEffect Animations in SwiftUI
SwiftUI's declarative nature makes creating delightful user interfaces a breeze, and animations are no exception. For many common scenarios, simply wrapping state changes in withAnimation { ... } is enough to bring your UI to life. But what happens when you want to animate a view that conceptually "moves" from one position or parent view to another, perhaps even changing its size or shape along the way? Standard withAnimation often results in the old view fading out and the new one fading in, breaking the visual continuity.
This is where SwiftUI's powerful matchedGeometryEffect modifier comes into play. It's designed specifically for creating smooth, identity-based transitions between views that represent the same underlying content but exist in different layout states or even different view hierarchies. Think of an image thumbnail seamlessly expanding into a full-screen viewer, or an item in a list flying into a detail card. These are the kinds of magical transitions matchedGeometryEffect enables.
Understanding matchedGeometryEffect
At its core, matchedGeometryEffect tells SwiftUI: "These two (or more) views are actually the same thing, just presented differently." When a state change causes one view to disappear and another to appear (or change position), SwiftUI will automatically animate the transition of the "matching" view's geometry.
To use matchedGeometryEffect, you need three main components:
- A unique
id: This is aHashablevalue that uniquely identifies the view you want to animate. It tells SwiftUI which view instances are conceptually the same. - A
Namespace.ID: This acts as a scope or container for your matched animations. All views that should animate together (i.e., share the sameidfor a specific transition) must belong to the same namespace. - The
matchedGeometryEffectmodifier: Applied to the views participating in the animation, linking them via theiridandnamespace.
Let's look at the signature:
func matchedGeometryEffect<ID>(
id: ID,
in: Namespace.ID,
properties: MatchedGeometryProperties = .frame,
anchor: UnitPoint = .center,
isSource: Bool = true
) -> some View where ID : Hashable
id: The unique identifier for the view.in: The namespace for this animation.properties: Which geometric properties of the view to animate. The default is.frame, which animates both position and size. Other options include.position,.size, or.identity(which animates nothing, useful for settingisSourcetofalse).anchor: The point within the view that acts as the anchor for the animation. Defaults to.center.isSource: A boolean indicating if this view is the "source" of the animation. If multiple views share the sameidin a namespace, only one should typically be the source. When the source view is removed, the non-source view will animate from its position. Defaults totrue.
Basic Usage Example: A Moving Rectangle
Let's start with a simple example where a rectangle moves from one side of the screen to another and changes size based on a tapped state.
First, you need to declare a namespace. This is typically done as a @Namespace property wrapper in the parent view where the animation occurs.
import SwiftUI
struct MatchedGeometryBasicExample: View {
@State private var showLargeRectangle = false
@Namespace private var animationNamespace // Declare the namespace
var body: some View {
VStack {
Spacer()
if showLargeRectangle {
Rectangle()
.fill(.blue)
.frame(width: 200, height: 150)
.matchedGeometryEffect(id: "myRectangle", in: animationNamespace) // Source
.onTapGesture {
withAnimation(.easeInOut(duration: 0.7)) {
showLargeRectangle.toggle()
}
}
} else {
Rectangle()
.fill(.green)
.frame(width: 100, height: 80)
.matchedGeometryEffect(id: "myRectangle", in: animationNamespace) // Destination
.onTapGesture {
withAnimation(.easeInOut(duration: 0.7)) {
showLargeRectangle.toggle()
}
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.gray.opacity(0.1))
}
}
struct MatchedGeometryBasicExample_Previews: PreviewProvider {
static var previews: some View {
MatchedGeometryBasicExample()
}
}
In this example:
- We declare
@Namespace private var animationNamespace. - Both
Rectangleviews usematchedGeometryEffect(id: "myRectangle", in: animationNamespace). - When
showLargeRectangletoggles, oneRectangledisappears and the other appears. Because they share the sameidandnamespace, SwiftUI understands they are the "same" visual element and animates the transition of their frame (position and size). - The
fillcolor also changes, but that's not part ofmatchedGeometryEffectitself; SwiftUI's standard animation system handles that.
The isSource parameter defaults to true. In scenarios where one view is conditionally removed and another appears, like in an if/else block, SwiftUI is smart enough to figure out which view is "disappearing" and which is "appearing" and handles the source/destination roles automatically. You typically only need to explicitly set isSource: false in more complex scenarios, like when you have multiple instances of a view with the same ID, but only one should be the "primary" animator.
┌─────────────────┐
│ @Namespace │
│ animationNamespace│
└─────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ Parent View │
│ (e.g., MatchedGeometryBasicExample) │
│ │
│ ┌───────────────────────────────────┐ │
│ │ View 1 (e.g., Small Rectangle) │ │
│ │ .matchedGeometryEffect(id: "item", in: animationNamespace)│
│ └───────────────────────────────────┘ │
│ (When `showLargeRectangle` is false) │
│ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ View 2 (e.g., Large Rectangle) │ │
│ │ .matchedGeometryEffect(id: "item", in: animationNamespace)│
│ └───────────────────────────────────┘ │
│ (When `showLargeRectangle` is true) │
│ │
└───────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ SwiftUI Animation Engine │
│ - Tracks "item" geometry within "animationNamespace" │
│ - Animates transition between View 1 and View 2's │
│ geometry (position, size) when state changes. │
└───────────────────────────────────────────────────────────┘
Diving Deeper into properties and anchor
The properties parameter is crucial for fine-tuning your animations:
.frame(default): Animates both the view's position and size. This is the most common choice..position: Animates only the view's center point. The size remains fixed or changes instantly..size: Animates only the view's dimensions. The position remains fixed or changes instantly..identity: Animates nothing. Useful if you want to use thematchedGeometryEffectto establish a match but don't want any geometry animation for that specific view (e.g., if another view with the same ID is the primary animator).
The anchor parameter determines the point within the view that SwiftUI uses as a reference for the animation. For example, if you animate a view from top-left to bottom-right, and its size also changes, the animation will pivot around the specified anchor.
.center(default): The animation pivots around the center of the view..topLeading,.bottomTrailing, etc.: The animation pivots around the specified corner or edge.
Experimenting with properties and anchor can lead to subtle but significant differences in the feel of your animations.
Practical Use Case: Expanding/Collapsing Card
A classic use case for matchedGeometryEffect is an item in a list or grid that expands into a full-screen detail view, or a card that flips/expands in place. Let's build a simplified version of an expanding card.
import SwiftUI
struct ExpandableCardView: View {
@State private var isExpanded = false
@Namespace private var cardNamespace
var body: some View {
ZStack { // Use ZStack to place views on top of each other
if isExpanded {
ExpandedCardView(namespace: cardNamespace, isExpanded: $isExpanded)
.zIndex(1) // Ensure expanded view is on top
} else {
CollapsedCardView(namespace: cardNamespace, isExpanded: $isExpanded)
.zIndex(0) // Ensure collapsed view is below
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(isExpanded ? 0.4 : 0).ignoresSafeArea()) // Dim background
}
}
struct CollapsedCardView: View {
let namespace: Namespace.ID
@Binding var isExpanded: Bool
var body: some View {
VStack(alignment: .leading) {
Image(systemName: "photo.fill")
.resizable()
.scaledToFit()
.frame(width: 80, height: 80)
.cornerRadius(10)
.matchedGeometryEffect(id: "cardImage", in: namespace) // Match the image
Text("Card Title")
.font(.headline)
.matchedGeometryEffect(id: "cardTitle", in: namespace) // Match the title
Text("A short description of the card content.")
.font(.subheadline)
.foregroundColor(.gray)
.lineLimit(1)
.matchedGeometryEffect(id: "cardDescription", in: namespace) // Match the description
}
.padding()
.frame(width: 180, height: 220, alignment: .topLeading)
.background(Color.white)
.cornerRadius(15)
.shadow(radius: 5)
.matchedGeometryEffect(id: "cardBackground", in: namespace) // Match the entire card background
.onTapGesture {
withAnimation(.spring(response: 0.5, dampingFraction: 0.7, blendDuration: 0)) {
isExpanded.toggle()
}
}
}
}
struct ExpandedCardView: View {
let namespace: Namespace.ID
@Binding var isExpanded: Bool
var body: some View {
VStack(alignment: .center) {
Image(systemName: "photo.fill")
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
.cornerRadius(20)
.matchedGeometryEffect(id: "cardImage", in: namespace) // Match the image
Text("Detailed Card Title")
.font(.largeTitle)
.matchedGeometryEffect(id: "cardTitle", in: namespace) // Match the title
Text("This is a much longer and more detailed description of the card content. It can span multiple lines and provide more information about the item.")
.font(.body)
.padding(.horizontal)
.multilineTextAlignment(.center)
.matchedGeometryEffect(id: "cardDescription", in: namespace) // Match the description
Spacer()
Button("Close") {
withAnimation(.spring(response: 0.5, dampingFraction: 0.7, blendDuration: 0)) {
isExpanded.toggle()
}
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
.cornerRadius(25)
.shadow(radius: 10)
.matchedGeometryEffect(id: "cardBackground", in: namespace) // Match the entire card background
}
}
struct ExpandableCardView_Previews: PreviewProvider {
static var previews: some View {
ExpandableCardView()
}
}
In this example:
- We have two separate views,
CollapsedCardViewandExpandedCardView. - Each view contains elements (Image, Text, Text) that are conceptually the same across both states.
- Each of these matching elements, as well as the background itself, gets its own
matchedGeometryEffectwith a uniqueidwithin the sharedcardNamespace. - When
isExpandedtoggles, SwiftUI animates the geometry of eachidfrom its collapsed state to its expanded state (and vice-versa). ZStackandzIndexare used to ensure the expanded card appears on top of the collapsed card's space during the animation. The dimming background also animates its opacity.
This pattern allows for incredibly fluid and visually appealing transitions, making your app feel polished and responsive.
Common Pitfalls and Best Practices
While powerful, matchedGeometryEffect can sometimes be tricky. Here are some common issues and tips:
- Unique
ids within aNamespace: Ensure each view that you want to animate independently has a uniqueidwithin its namespace. If two distinct views share the sameid, SwiftUI won't know which one to animate. If you have a list of items, use the item's unique ID (e.g.,UUID) formatchedGeometryEffect. - Namespace Scope: Declare your
@Namespaceat a common ancestor view that encompasses all the views participating in thematchedGeometryEffectanimation. If the namespace is declared too low in the hierarchy, or if participating views are in differentNavigationViewstacks, it might not work. - Conditional View Existence: For
matchedGeometryEffectto work, the view with a givenidmust logically exist in both the "before" and "after" states of the animation. If you useif someCondition { ViewA } else { ViewB }, this works perfectly. However, if you completely remove a view (e.g.,if someCondition { ViewA }), and there's no corresponding "destination" view with the sameid, no animation will occur. - Modifier Order:
matchedGeometryEffectshould generally be applied after layout modifiers likeframe,padding,cornerRadius, etc. This ensures SwiftUI calculates the correct final geometry before attempting to match it.`swift // Good: matchedGeometryEffect applied after frame and padding Text("Hello") .frame(width: 100, height: 50) .padding() .matchedGeometryEffect(id: "myText", in: namespace)
// Potentially problematic if layout modifiers are applied after Text("Hello") .matchedGeometryEffect(id: "myText", in: namespace) .frame(width: 100, height: 50) // This frame might not be animated correctly ` 5. zIndex for Overlapping Views: When views animate between different positions, they might temporarily overlap. Use the .zIndex() modifier to ensure the animating view appears on top of other content, preventing visual glitches. (As seen in the ExpandableCardView example). 6. Avoid matchedGeometryEffect on the same view in different states: matchedGeometryEffect is for animating between different views that represent the same conceptual element. Applying it to a single view that merely changes its own frame or position via @State will not yield the desired effect; standard withAnimation is sufficient there.
Summary
matchedGeometryEffect is an incredibly powerful tool in SwiftUI's animation arsenal, enabling you to create fluid and visually continuous transitions between different views that represent the same content. By leveraging a shared id and Namespace.ID, you can easily animate changes in position, size, and other geometric properties, significantly enhancing the user experience of your iOS applications. Remember to manage your namespaces, ensure unique IDs, and consider the order of modifiers for the best results.
Happy Swifting!