Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save christianselig/b206d10d3d15adfc0200e08f9ad18652 to your computer and use it in GitHub Desktop.

Select an option

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)
}
}
}
}
@christianselig
Copy link
Author

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, and lastVisibleIndex shenanigans: We use these because LazyVGrid 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 like HStack doesn't have this issue but hey we need LazyVGrid because for the actual use case we need multiple rows. We use id because changing that does cause LazyVGrid 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 setting visibleIndex to nil and in doing so would make us unable to identify the visibleIndex for the dismissal animation's zIndex, so we use lastVisibleIndex, just a version of visibleIndex 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment