Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save christianselig/b206d10d3d15adfc0200e08f9ad18652 to your computer and use it in GitHub Desktop.
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)
}
}
}
}
@bo2themax
Copy link

‪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.

image
Simulator.Screen.Recording.-.iPhone.16.Pro.-.2025-08-25.at.22.42.27.mov
import SwiftUI

struct ContentView: 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 {
            HStack /*LazyVGrid(columns: columns)*/ { // HStack works but notLazyVGrid
                ForEach(0...2, id: \.self) { num in
                    ZStack {
                        Button {
                            lastVisibleIndex = num
                            withAnimation {
                                visibleIndex = num
                            }
                        } label: {
                            colors[num]
                                .aspectRatio(1.0, contentMode: .fit)
                        }
                        .buttonStyle(.plain)
                        .matchedGeometryEffect(id: "\(num)", in: zoomAnimation)
                    }
                    .zIndex(lastVisibleIndex == num ? 1 : 0)
                }
            }

            if let visibleIndex {
                Button {
                    withAnimation {
                        self.visibleIndex = nil
                    } completion: {
                        if self.visibleIndex == nil { // incase interrupted and called when visible
                            lastVisibleIndex = nil
                        }
                    }
                } label: {
                    colors[visibleIndex]
                        .aspectRatio(1.0, contentMode: .fit)
                }
                .matchedGeometryEffect(id: "\(visibleIndex)", in: zoomAnimation)
                .transition(.identity)
            }
        }
    }
}

@bo2themax
Copy link

bo2themax commented Aug 25, 2025

Maybe custom layouts could modify each column's zIndex🤔

@ryanlintott
Copy link

ryanlintott commented Aug 25, 2025

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)
            }
        }
    }
}

@christianselig
Copy link
Author

christianselig commented Aug 26, 2025

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.

@ryanlintott
Copy link

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)
            }
        }
    }
}

@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