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.