Created
August 25, 2025 18:59
-
-
Save christianselig/b206d10d3d15adfc0200e08f9ad18652 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } | |
| } | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.scalealone 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).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 undesirablematchedGeometryEffectseems 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, andlastVisibleIndexshenanigans: We use these becauseLazyVGridseems 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 likeHStackdoesn't have this issue but hey we needLazyVGridbecause for the actual use case we need multiple rows. We useidbecause changing that does causeLazyVGridto allow zIndex changes because it effectively creates a new instance of the cell.lastVisibleIndexis the final item of this recipe and is needed because we dismiss the view by settingvisibleIndextoniland in doing so would make us unable to identify thevisibleIndexfor the dismissal animation's zIndex, so we uselastVisibleIndex, just a version ofvisibleIndexthat stays around longer so can serve as a more stable ID.