MatchedGeometryEffect Overview

Thursday, 25 March 2021
Updated 2 years ago
This is a new layout, but it is still in BETA.
I will start sending email newsletters once in a while, so in order to get notified when I release new content, make sure to follow me on twitter @liquidcoder .

Introduction

Apple says: "Defines a group of views with synchronized geometry using an identifier and namespace that you provide."

// $$liquidcoderID-MatchedGeometryEffectIntro

func matchedGeometryEffect<ID>(id: ID, in namespace: Namespace.ID, properties: MatchedGeometryProperties = .frame, anchor: UnitPoint = .center, isSource: Bool = true) -> some View where ID : Hashable

Also this method sets the geometry of each view in the group from the inserted view with isSource = true (known as the “source” view), updating the values marked by properties.

If inserting a view in the same transaction that another view with the same key is removed, the system will interpolate their frame rectangles in window space to make it appear that there is a single view moving from its old position to its new position. The usual transition mechanisms define how each of the two views is rendered during the transition (e.g. fade in/out, scale, etc), the matchedGeometryEffect() modifier only arranges for the geometry of the views to be linked, not their rendering.

Take a look at this code. The animate boolean state will be toggled when either the VStack or the Image is tapped, and when this happens, we want to hide one and show the other.

We don't have any transition for now.

// $$liquidcoderID-MatchedGeometryEffect2
// $$liquidcoderFilename-ContentView

import SwiftUI

struct ContentView: View {

    // highlight-start
    @State private var animate = false
    // highlight-end

    var body: some View {

        VStack {
            if animate {
    // highlight-start
                VStack {
                    Image("image")
                        .resizable()
                        .scaledToFill()
                        .frame(height: 300, alignment: .center)
                }.frame(maxHeight: .infinity, alignment: .top)
                .edgesIgnoringSafeArea(.all)
                .onTapGesture {
                    self.animate.toggle()
                }
    // highlight-end
            }

            if !animate {
    // highlight-start
                Image("image")
                    .resizable()
                    .scaledToFill()
                    .frame(width: 250, height: 300, alignment: .center)
                    .onTapGesture {
                        self.animate.toggle()
                    }
    // highlight-end
            }
        }.animation(.easeIn)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

https://res.cloudinary.com/liquidcoder/image/upload/v1615971432/Blog/matchedGeometryEffect/xk9u7ors7kckkoa8okx8.gif

Now let's add the matchedGeometryReader modifier into the mix.

// $$liquidcoderID-MatchedGeometryEffect4
// $$liquidcoderFilename-ContentView

import SwiftUI

struct ContentView: View {

    // highlight-start
    @Namespace var animation
    // highlight-end
    @State private var animate = false

    var body: some View {

        VStack {
            if animate {
                VStack {
                    Image("image")
                        .resizable()
                        .scaledToFill()
                        // highlight-start
                        .matchedGeometryEffect(id: "ID", in: animation)
                        // highlight-end
                        .frame(height: 300, alignment: .center)
                }.frame(maxHeight: .infinity, alignment: .top)
                .edgesIgnoringSafeArea(.all)
                .onTapGesture {
                    self.animate.toggle()
                }
            }

            if !animate {
                Image("image")
                    .resizable()
                    .scaledToFill()
                    // highlight-start
                    .matchedGeometryEffect(id: "ID", in: animation)
                    // highlight-end
                    .frame(width: 250, height: 300, alignment: .center)
                    .onTapGesture {
                        self.animate.toggle()
                    }
            }
        }.animation(.easeIn)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

We first need declare this animation with this @Namespace property wrapper that we then pass in the matchedGeometryReader modifier to both Image views.

https://res.cloudinary.com/liquidcoder/image/upload/v1615971430/Blog/matchedGeometryEffect/ijbpbizxxokajbd25clv.gif

NOTE: The id must be the same for both views involved in the transition, and the matchedGeometryReader modifier must be added before the frame.

Multiple MGR Modifiers

It is also possible to add multiple matchedGeometryReader modifiers on a single views pointing to multiple views.

// $$liquidcoderID-MatchedGeometryEffect6
// $$liquidcoderFilename-ContentView

import SwiftUI

struct ContentView: View {

    @Namespace var animation
    @State private var animate = false

    var body: some View {

        VStack {
            if animate {
                HStack {

                    Rectangle()
                        .foregroundColor(.green)
                        // highlight-start
                        .matchedGeometryEffect(id: "backward.end.alt.fill", in: animation)
                        // highlight-end
                        .frame(width: 50, height: 50, alignment: .center)

                    Rectangle()
                        .foregroundColor(.red)
                        // highlight-start
                        .matchedGeometryEffect(id: "stop.fill", in: animation)
                        // highlight-end
                        .frame(width: 50, height: 50, alignment: .center)

                    Rectangle()
                        .foregroundColor(.blue)
                        // highlight-start
                        .matchedGeometryEffect(id: "forward.end.alt.fill", in: animation)
                        // highlight-end
                        .frame(width: 50, height: 50, alignment: .center)

                }.frame(height: 300, alignment: .top)
                .edgesIgnoringSafeArea(.all)
                .onTapGesture {
                    self.animate.toggle()
                }
            }

            if !animate {
                Rectangle()
                    // highlight-start
                    .matchedGeometryEffect(id: "backward.end.alt.fill", in: animation)
                    .matchedGeometryEffect(id: "stop.fill", in: animation)
                    .matchedGeometryEffect(id: "forward.end.alt.fill", in: animation)
                    // highlight-end
                    .frame(width: 30, height: 30, alignment: .center)
                    .onTapGesture {
                        self.animate.toggle()
                    }
            }
        }.animation(.easeIn)
    }
}

https://res.cloudinary.com/liquidcoder/image/upload/v1616006181/Blog/matchedGeometryEffect/i9or0da3z052sze4zoca.gif

No more than one visible view

If the number of currently-inserted views in the group with isSource = true is not exactly one results are undefined, due to it not being clear which is the source view.

For the effect to work, only one view from the 2 views with the same id must be visible at any given time.

The matchedGeometryEffect modifier is magical, it's mind-blowing what swiftUI has accomplished with just one function call.