-
-
Save christianselig/b206d10d3d15adfc0200e08f9ad18652 to your computer and use it in GitHub Desktop.
import SwiftUI | |
struct ContentView: View { | |
private let columns = [ | |
GridItem(.flexible()), | |
GridItem(.flexible()), | |
GridItem(.flexible()) | |
] | |
@State private var visibleIndex: Int? = nil | |
@Namespace private var zoomAnimation | |
private let colors: [Color] = [.orange, .blue, .green] | |
var body: some View { | |
ZStack { | |
LazyVGrid(columns: columns) { | |
ForEach(0...2, id: \.self) { num in | |
ZStack { | |
Button { | |
withAnimation { | |
visibleIndex = num | |
} | |
} label: { | |
colors[num] | |
.aspectRatio(1.0, contentMode: .fit) | |
} | |
.buttonStyle(.plain) | |
.matchedGeometryEffect(id: "\(num)", in: zoomAnimation) | |
} | |
} | |
} | |
if let visibleIndex { | |
Button { | |
withAnimation { | |
self.visibleIndex = nil | |
} | |
} label: { | |
colors[visibleIndex] | |
.aspectRatio(1.0, contentMode: .fit) | |
} | |
.matchedGeometryEffect(id: "\(visibleIndex)", in: zoomAnimation) | |
.transition(.identity) | |
} | |
} | |
} | |
} |
Maybe custom layouts could modify each column's zIndex🤔
This version should work
https://mastodon.social/@ryanlintott/115091770249269075
struct MatchedGeometryTestView: View {
private let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
@State private var visibleIndex: Int? = nil
@State private var lastVisibleIndex: Int? = nil
@Namespace private var zoomAnimation
private let colors: [Color] = [.orange, .blue, .green]
var body: some View {
ZStack {
LazyVGrid(columns: columns) {
ForEach(0...2, id: \.self) { num in
Button {
lastVisibleIndex = num
withAnimation {
visibleIndex = num
}
} label: {
colors[num]
.aspectRatio(1.0, contentMode: .fit)
}
.buttonStyle(.plain)
.zIndex(Double(lastVisibleIndex == num ? 100 : num))
.id(num + (lastVisibleIndex == num ? 100 : 0))
.matchedGeometryEffect(id: "\(num)", in: zoomAnimation)
}
}
if let visibleIndex {
Button {
withAnimation {
self.visibleIndex = nil
}
} label: {
colors[visibleIndex]
.aspectRatio(1.0, contentMode: .fit)
}
.matchedGeometryEffect(id: "\(visibleIndex)", in: zoomAnimation)
.transition(.identity)
}
}
}
}
Great catch @bo2themax and @ryanlintott, I'm still thinking we're doing something off the beaten path though due to that dang console log:
Multiple inserted views in matched geometry group Pair<String, ID>(first: "0", second: SwiftUI.Namespace.ID(id: 140)) have
isSource: true
, results are undefined.
Regardless of what I seem to tweak with isSource
incantations though nothing seems to move the needle.
OK, I think I've cracked it now. The isSource issues are because two views with matchedGeometryEffect modifiers with matching ids are visible at the same time. If you put the other view inside a conditional so it disappears when the hero view appears the errors go away. To remove the cross-fading you use a transition on both of .scale(1). You can't use .identity because that negates any scaling animation. Lastly, the flashing on the buttons is only due to the opacity effect when the button is pressed. If you hold the button down you'll see what's going on. If you change the button style so that it no longer has this opacity effect then you can get rid of any flicker.
struct MatchedGeometryTestView: View {
private let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
@State private var visibleIndex: Int? = nil
@State private var lastVisibleIndex: Int? = nil
@Namespace private var zoomAnimation
private let colors: [Color] = [.orange, .blue, .green]
var body: some View {
ZStack {
LazyVGrid(columns: columns) {
ForEach(0...2, id: \.self) { num in
Color.clear
.aspectRatio(1.0, contentMode: .fit)
.zIndex(Double(lastVisibleIndex == num ? 100 : num))
.id(num + (lastVisibleIndex == num ? 100 : 0))
.overlay {
if visibleIndex != num {
Button {
lastVisibleIndex = num
withAnimation {
visibleIndex = num
}
} label: {
colors[num]
}
.transition(.scale(1))
.matchedGeometryEffect(id: num, in: zoomAnimation)
}
}
}
}
if let visibleIndex {
Button {
withAnimation {
self.visibleIndex = nil
}
} label: {
colors[visibleIndex]
}
.aspectRatio(1.0, contentMode: .fit)
.transition(.scale(1))
.matchedGeometryEffect(id: visibleIndex, in: zoomAnimation)
}
}
}
}
Wow Ryan great job! That's outstanding, thank you so so so much!
Complete breakdown for me when I inevitably come across this in the future and need a refresher:
Key points
.scale(1)
is needed as.scale
alone defaults to 0 which means it will try to scale from 0 to 1, we want it to scale from 1 to 1 (in other words let the natural scale animation play out that matchedGeometryEffect does rather than introducing our own multipliers to the transition)- Similarly we can't use
.transition(.identity)
because that nukes the transition completely. We also need to give.transition()
something because by default it does one that includes opacity which is undesirable matchedGeometryEffect
seems to get grumpy if two views are present in the hierarchy at once with the same ID. The doc parameters don't say this is bad explicitly but the "Discussion" does seem to imply it (emphasis added): "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." THEREFORE, we want to remove the existing view when we transition to the new one, which is easier said than done because if we just fully yoink it the grid itself will re-layout to fill in the newly empty spot. So we use the ol' Color.clear trick where we just overlay the content (in this case, a Color) we want on top of the Color.clear unless it is the selected one, in which case we leave it as only a Color.clear so that the content isn't in the view hierarchy twice.zIndex
,id
, andlastVisibleIndex
shenanigans: We use these becauseLazyVGrid
seems to not let the user control the zIndex of its items very nicely (or at least you can't change them easily once set), something likeHStack
doesn't have this issue but hey we needLazyVGrid
because for the actual use case we need multiple rows. We useid
because changing that does causeLazyVGrid
to allow zIndex changes because it effectively creates a new instance of the cell.lastVisibleIndex
is the final item of this recipe and is needed because we dismiss the view by settingvisibleIndex
tonil
and in doing so would make us unable to identify thevisibleIndex
for the dismissal animation's zIndex, so we uselastVisibleIndex
, just a version ofvisibleIndex
that stays around longer so can serve as a more stable ID.- Lastly, one must say "Praise Ryan Lintott" under their breath every morning for 5 consecutive weeksx
I suspect it might be related to GridViews. Each column’s z-index was incremented internally, and there’s no clear way to change it. Using
HStack
worked properly after modifying z-index before and after the transition.Simulator.Screen.Recording.-.iPhone.16.Pro.-.2025-08-25.at.22.42.27.mov