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()
}
}
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.
NOTE: The
id
must be the same for both views involved in the transition, and thematchedGeometryReader
modifier must be added before theframe
.
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)
}
}
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.