This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We start reimplementing SwiftUI's matched geometry effect to gain a better understanding of how it works.

00:06 In the previous two episodes, we used SwiftUI's matchedGeometryEffect to transition between two views, but we've always wondered: how does this modifier actually work?

As we found out with SwiftUI's layout system, reimplementing an API gives us a thorough understanding of how it works and why it works the way it does. So today, we'll try to reimplement (the basics of) the matched geometry effect.

Definition

00:47 When we call the matchedGeometryEffect modifier (passing in the same identifier and namespace) on two or more views, it synchronizes the geometry of these views. One of the views needs to be marked as the "source" view, and the other view(s) take on the geometry of that source view.

01:15 What it means for a view to take on the geometry of another view is that the view is proposed the size of the source view and that it gets positioned at the same location as the source view.

01:37 Replicating this behavior will involve using a geometry reader to measure the source view's frame in the global coordinate system, propagating this measurement up to a common ancestor of the group, and then propagating it back down to the other view(s) in the group.

Setting Up

02:06 We write a function that can apply either the built-in effect or our implementation. We start out by just applying the built-in effect:

extension View {
    func myMatchedGeometryEffect<ID: Hashable>(useBuiltin: Bool = true, id: ID, in ns: Namespace.ID, isSource: Bool = true) -> some View {
        self.matchedGeometryEffect(id: id, in: ns, isSource: isSource)
    }
}

03:29 Then we create a sample view with a large red rectangle and a smaller green circle wrapped in an HStack. We call the above function on both shapes, passing isSource: false to the green circle, which makes the rectangle act as the source view for the matched geometry effect:

struct Sample: View {
    var builtin = true
    @Namespace var ns
    
    var body: some View {
        HStack {
            Rectangle()
                .fill(Color.red)
                .myMatchedGeometryEffect(useBuiltin: builtin, id: "ID", in: ns)
                .frame(width: 200, height: 200)
            Circle()
                .fill(Color.green)
                .myMatchedGeometryEffect(useBuiltin: builtin, id: "ID", in: ns, isSource: false)
                .frame(width: 100, height: 100)
        }
    }
}

04:57 When we place this sample view in ContentView and run the app, we can already see the matched geometry effect in action: the green circle becomes as large as the rectangle, and it's drawn on top of the rectangle. The circle left its original spot in the HStack empty, but it's still there, which we can see very clearly if we add a blue border to the fixed frame around the circle view:

struct Sample: View {
    var builtin = true
    @Namespace var ns
    
    var body: some View {
        HStack {
            Rectangle()
                .fill(Color.red)
                .myMatchedGeometryEffect(useBuiltin: builtin, id: "ID", in: ns)
                .frame(width: 200, height: 200)
            Circle()
                .fill(Color.green)
                .myMatchedGeometryEffect(useBuiltin: builtin, id: "ID", in: ns, isSource: false)
                .frame(width: 100, height: 100)
                .border(Color.blue)
        }
    }
}

struct ContentView: View {
    var body: some View {
        Sample()
            .padding(100)
    }
}

05:38 This tells us that the matched geometry effect is purely a rendering effect; it doesn't affect the layout.

05:48 For now, let's only focus on the size aspect of the matched geometry effect. We can let the effect apply just the source view's size by specifying the properties parameter:

extension View {
    func myMatchedGeometryEffect<ID: Hashable>(useBuiltin: Bool = true, id: ID, in ns: Namespace.ID, isSource: Bool = true) -> some View {
        self.matchedGeometryEffect(id: id, in: ns, properties: .size, isSource: isSource)
    }
}

06:28 We make both shapes a bit smaller so that we can have two sample views onscreen side by side — one using the built-in effect, and one using our implementation:

struct Sample: View {
    var builtin = true
    @Namespace var ns
    
    var body: some View {
        HStack {
            Rectangle()
                .fill(Color.red)
                .myMatchedGeometryEffect(useBuiltin: builtin, id: "ID", in: ns)
                .frame(width: 100, height: 100)
            Circle()
                .fill(Color.green)
                .myMatchedGeometryEffect(useBuiltin: builtin, id: "ID", in: ns, isSource: false)
                .frame(width: 50, height: 50)
                .border(Color.blue)
        }
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Sample()
            Sample(builtin: false)
        }
        .padding(100)
    }
}

06:58 Next up is measuring views and propagating the frames upward.

Measuring Frames

07:04 In myMatchedGeometryEffect, we apply a new view modifier, MatchedGeometryEffect, in which we'll write our own implementation:

extension View {
    func myMatchedGeometryEffect<ID: Hashable>(useBuiltin: Bool = true, id: ID, in ns: Namespace.ID, isSource: Bool = true) -> some View {
        Group {
            if useBuiltin {
                self.matchedGeometryEffect(id: id, in: ns, properties: .size, isSource: isSource)
            } else {
                modifier(MatchedGeometryEffect(id: id, namespace: ns, isSource: isSource))
            }
        }
    }
}

09:13 The view modifier needs to measure its content view if isSource is equal to true:

struct MatchedGeometryEffect<ID: Hashable>: ViewModifier {
    var id: ID
    var namespace: Namespace.ID
    var isSource: Bool = true
    
    func body(content: Content) -> some View {
        Group {
            if isSource {
                content
                    .overlay(GeometryReader { proxy in
                        let frame = proxy.frame(in: .global)
                        Color.clear.preference(key: FrameKey.self, value: frame)
                    })
            } else {
                content
            }
        }
    }
}

10:16 To propagate the frame up as a preference, we need a preference key. The key's reduce method — which combines values from multiple views — shouldn't get called, since there should never be more than one source view. So we print out a warning if that happens:

struct FrameKey: PreferenceKey {
    static var defaultValue: CGRect = .zero
    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        print("Multiple views with isSource=true")
    }
}

Applying the Frame

12:04 The next step is to read out the preference. We do this at the root view level, where we can collect all values from the subviews. We write a view modifier to encapsulate this behavior, and we call this modifier in ContentView:

struct ApplyGeometryEffects: ViewModifier {
    @State var sourceFrame: CGRect = .zero
    
    func body(content: Content) -> some View {
        content
            .onPreferenceChange(FrameKey.self) {
                sourceFrame = $0
            }
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Sample()
            Sample(builtin: false)
        }
        .modifier(ApplyGeometryEffects())
        .padding(100)
    }
}

13:38 After reading the source frame preference, we have to propagate it back down through the environment. We can extend FrameKey to also act as an EnvironmentKey, since it already implements the defaultValue requirement:

struct FrameKey: PreferenceKey, EnvironmentKey {
    static var defaultValue: CGRect = .zero
    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        print("Multiple views with isSource=true")
    }
}

14:01 We also need an extension on EnvironmentValues to access the FrameKey value of the environment. In case we want to change the frame key's type later on, it'll be easier if we don't hardcode CGRect as the type of this value, but instead refer to the key's generic parameter, FrameKey.Value:

extension EnvironmentValues {
    var frameKey: FrameKey.Value {
        get { self[FrameKey.self] }
        set { self[FrameKey.self] = newValue }
    }
}

14:39 We read out the environment value, and we set the frame of the content view:

struct MatchedGeometryEffect<ID: Hashable>: ViewModifier {
    var id: ID
    var namespace: Namespace.ID
    var isSource: Bool = true
    @Environment(\.frameKey) var frame
    
    func body(content: Content) -> some View {
        Group {
            if isSource {
                content
                    .overlay(GeometryReader { proxy in
                        let frame = proxy.frame(in: .global)
                        Color.clear.preference(key: FrameKey.self, value: frame)
                    })
            } else {
                content
                    .frame(width: frame.size.width, height: frame.size.height)
            }
        }
    }
}

15:32 Running this, we get the warning from the reduce function of FrameKey. It still gets called even though we only marked one view as the source view, so we make the value optional, and we update the function body to always use the first non-nil value:

struct FrameKey: PreferenceKey, EnvironmentKey {
    static var defaultValue: CGRect? = nil
    static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
        value = value ?? nextValue()
    }
}

16:41 Now we see our effect in action. The sizes of the shapes are matched, but compared to SwiftUI, our alignment of the circle is wrong:

17:02 But it's not just a matter of choosing a different anchor point for the circle — by setting the frame, we're also modifying the layout. We can see this more clearly if we only set the height of the circle's fixed frame:

struct Sample: View {
    var builtin = true
    @Namespace var ns
    
    var body: some View {
        HStack(spacing: 0) {
            Rectangle()
                .fill(Color.red)
                .myMatchedGeometryEffect(useBuiltin: builtin, id: "ID", in: ns)
                .frame(width: 100, height: 100)
            Circle()
                .fill(Color.green)
                .myMatchedGeometryEffect(useBuiltin: builtin, id: "ID", in: ns, isSource: false)
                .frame(height: 50)
                .border(Color.blue)
        }.frame(width: 150, height: 100)
    }
}

18:09 SwiftUI doesn't resize the blue border, but we do. Instead of setting the frame, we should draw the original content and hide it, and then use an overlay to place the resized content. This way, the frame doesn't change, and we have the opportunity to specify a top-leading alignment for the overlay:

struct MatchedGeometryEffect<ID: Hashable>: ViewModifier {
    var id: ID
    var namespace: Namespace.ID
    var isSource: Bool = true
    @Environment(\.frameKey) var frame
    
    func body(content: Content) -> some View {
        Group {
            if isSource {
                content
                    .overlay(GeometryReader { proxy in
                        let frame = proxy.frame(in: .global)
                        Color.clear.preference(key: FrameKey.self, value: frame)
                    })
            } else {
                content
                    .hidden()
                    .overlay(
                        content
                            .frame(width: frame?.size.width, height: frame?.size.height)
                        , alignment: .topLeading
                    )
            }
        }
    }
}

19:29 By keeping the original view in the view hierarchy (albeit hidden), we're preserving the layout. This is much better than the previous solution where we set the frame directly. The downside is that we have to render the view twice, but we can't avoid doing so with the options available to us today.

Next Up

20:14 The next step is to take the source's origin and apply it to the other views. Let's continue next time.

Resources

  • Sample Code

    Written in Swift 5.3

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

135 Episodes · 48h28min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes