A first look at matchedGeometryEffect

⋅ 8 min read ⋅ SwiftUI WWDC20 matchedGeometryEffect

Table of Contents

After watching What's new in SwiftUI, one feature the caught my attention is matchedGeometryEffect. It is a new SwiftUI effect which can interpolate position and size between two views. Let's see what we can do with it.

matchedGeometryEffect is WWDC20's session
matchedGeometryEffect is WWDC20's session

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

The basic

SwiftUI already interpolates view's property for us without the need for matchedGeometryEffect. In the following example, we can animate a rectangle size with a tap gesture.

struct ContentView: View {
@State private var isExpanded = false

var body: some View {
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: isExpanded ? 100: 60, height: isExpanded ? 100: 60)
.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}

}
}

Here is the result:

Basic SwiftUI animation
Basic SwiftUI animation

What is matchedGeometryEffect for?

SwiftUI interpolates a value changes in view when an animation happens. In our previous example, we change frame value based on isExpanded state. This is great most of the time, but there would be a time when just value change is not enough or not possible to serve your layout. Let's say you want to change your layout from HStack to VStack.

struct ContentView: View {
@State private var isExpanded = false

var body: some View {
Group() {
if isExpanded {
VStack {
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: 60, height: 60)
Text("Hello SwiftUI!").fontWeight(.semibold)
}
} else {
HStack {
Text("Hello SwiftUI!").fontWeight(.semibold)
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: 60, height: 60)
}
}
}.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
}
}

Because what SwiftUI sees here is just showing and hiding of different views. The best SwiftUI can do is fade in and out between two views.

SwiftUI use fade in and out for a show and hide
SwiftUI use fade in and out for a show and hide

This kind of inter-view animation is where matchedGeometryEffect comes into play. matchedGeometryEffect can animate position and size between two views. What we need to do is link two views together. Here is how we do it.

struct ContentView: View {
@State private var isExpanded = false
@Namespace private var namespace // <1>

var body: some View {
Group() {
if isExpanded {
VStack {
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: 60, height: 60)
.matchedGeometryEffect(id: "rect", in: namespace) // <2>
Text("Hello SwiftUI!").fontWeight(.semibold)
.matchedGeometryEffect(id: "text", in: namespace) // <3>
}
} else {
HStack {
Text("Hello SwiftUI!").fontWeight(.semibold)
.matchedGeometryEffect(id: "text", in: namespace) // <4>
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: 60, height: 60)
.matchedGeometryEffect(id: "rect", in: namespace) // <5>
}
}
}.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
}
}

<1> First, we need to define a namespace, a new property wrapper.
<2>, <5> We link two rectangle views together by specified the same id to .matchedGeometryEffect(id: "rect", in: namespace).
<3>, <4> We link two texts together by specified the same id to .matchedGeometryEffect(id: "text", in: namespace).

With these extra lines of code, our animation can interpolate nicely. It no longer treats our animation as a show and hides of views anymore, but a change of position and size of the same view.

matchedGeometryEffect map animation between two views
matchedGeometryEffect map animation between two views

Caveats

Since it is still in beta, I'm not sure whether this is a limitation or a bug. I will update once we have a final release. If it is my misunderstanding on the API, please let me know on Twitter (DM is open).

Size is not interpolating nicely (Updated with a solution)

Not like basic size change animation, .matchedGeometryEffect can't animate size change properly.

Basic SwiftUI size change animation
Basic SwiftUI size change animation

Instead of interpolating size from source to destination, it just uses fades in and out (I also change color to make it more visible).

Size not interpolate nicely with matchedgeometryeffect
Size not interpolate nicely with matchedgeometryeffect

Updated

To make a size interpolate correctly, we need to put matchedGeometryEffect before .frame. The order really makes a difference here.

var body: some View {
Group() {
if isExpanded {
VStack {
RoundedRectangle(cornerRadius: 10)
.matchedGeometryEffect(id: 1, in: namespace, properties: .frame)
.foregroundColor(Color.pink)
.frame(width: 100, height: 100)
Text("Hello SwiftUI!").fontWeight(.semibold)
.matchedGeometryEffect(id: "text", in: namespace)
}
} else {
HStack {
Text("Hello SwiftUI!").fontWeight(.semibold)
.matchedGeometryEffect(id: "text", in: namespace)
RoundedRectangle(cornerRadius: 10)
.matchedGeometryEffect(id: 1, in: namespace, properties: .frame)
.foregroundColor(Color.blue)
.frame(width: 60, height: 60)
}
}
}.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}

}

Work only on the same namespace (Updated with a solution)

Since @Namespace is a required parameter for matchedGeometryEffect and I can't find a way to pass this around, that's mean everything must happen on the same struct. So, we can't extract any views to their own struct.

We can't extract our animated views to VerticalView and HorizontalView because we can't pass @Namespace into those views.

struct ContentViewNameSpace: View {
@State private var isExpanded = false
@Namespace private var namespace

var body: some View {
Group() {
if isExpanded {
VerticalView()
} else {
HorizontalView()
}
}.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
}
}

struct VerticalView: View {
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: 60, height: 60)
Text("Hello SwiftUI!").fontWeight(.semibold)
}
}
}

struct HorizontalView: View {
var body: some View {
HStack {
Text("Hello SwiftUI!").fontWeight(.semibold)
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: 60, height: 60)
}
}
}

Declare a property wrapper at the top-level also not supported. So, something like this won't work.


@Namespace private var namespace

struct ContentViewNameSpace: View {
@State private var isExpanded = false

...
}

struct VerticalView: View {
...
}

struct HorizontalView: View {
...
}

Updated

Turn out we have a way to pass a namespace around. By declaring a variable of type Namespace.ID, you can share a namespace across different view and file.

struct ContentViewNameSpace: View {
@State private var isExpanded = false
@Namespace private var namespace


var body: some View {
Group() {
if isExpanded {
VerticalView(namespace: namespace)
} else {
HorizontalView(namespace: namespace)
}
}.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
}
}

struct VerticalView: View {
var namespace: Namespace.ID

var body: some View {
VStack {
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: 60, height: 60)
.matchedGeometryEffect(id: "rect", in: namespace, properties: .frame)
Text("Hello SwiftUI!").fontWeight(.semibold)
.matchedGeometryEffect(id: "text", in: namespace)
}
}
}

struct HorizontalView: View {
var namespace: Namespace.ID

var body: some View {
HStack {
Text("Hello SwiftUI!").fontWeight(.semibold)
.matchedGeometryEffect(id: "text", in: namespace)
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: 60, height: 60)
.matchedGeometryEffect(id: "rect", in: namespace, properties: .frame)
}
}
}

Not work with modal

I expected this to work, but no luck.

struct ContentView: View {
@Namespace private var namespace
@State private var isExpanded = false

var body: some View {

HStack {
Text("Hello SwiftUI!").fontWeight(.semibold)
.matchedGeometryEffect(id: "text", in: namespace)
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.blue)
.frame(width: 60, height: 60)
.matchedGeometryEffect(id: "rect", in: namespace, properties: .frame)
}.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}.sheet(isPresented: $isExpanded) {
VStack {
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: 100, height: 100)
.matchedGeometryEffect(id: "rect", in: namespace, properties: .frame)
Text("Hello SwiftUI!").fontWeight(.semibold)
.matchedGeometryEffect(id: "text", in: namespace)
}
}

}
}

Not work with a navigation view

This doesn't work, and the destination view gets pushed with the default animation.

struct ContentView: View {
@Namespace private var namespace
@State private var isExpanded = false

var body: some View {
NavigationView {
List {
NavigationLink(destination: VStack {
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.pink)
.frame(width: 100, height: 100)
.matchedGeometryEffect(id: "rect", in: namespace, properties: .frame)
Text("Hello SwiftUI!").fontWeight(.semibold)
.matchedGeometryEffect(id: "text", in: namespace)
}) {
HStack {
Text("Hello SwiftUI!").fontWeight(.semibold)
.matchedGeometryEffect(id: "text", in: namespace)
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.blue)
.frame(width: 60, height: 60)
.matchedGeometryEffect(id: "rect", in: namespace, properties: .frame)
}
}

}
}

}
}

Conclusion

My first thought is this matchedGeometryEffect is going to help me do all hero transition effects for modal and navigation view, but it turned out it is not that powerful (at least in this beta version). But this is still in beta, so I hope the animation is getting better when it release (at least for size transition). If they can do this right, a transition between modal and navigation shouldn't be that far.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.
Read more article about SwiftUI, WWDC20, matchedGeometryEffect, or see all available topic

Enjoy the read?

If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.

Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.

If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.

Become a patron Buy me a coffee Tweet Share
Previous
Should I learn UIKit or SwiftUI

The most popular question since the introduction of SwiftUI. Here is my thought after WWDC20.

Next
Custom navigation bar title view in SwiftUI

Learn how to set a navigation bar title view in SwiftUI.

← Home