iosxcodeswiftuitvosmatchedgeometryeffect

Assistance understanding SwiftUI view hierarchy and .zIndex


I'm trying to learn SwiftUI and am getting along pretty well, but I've come across an issue with .zIndex / view placement which the tutorials and AI answers can't seem to help me with. Huge thanks to BenzyNeez for his input and I have solved one of my issues of making it go full screen. The sub views should go full screen and be on top of all other views just like the final picture. But the sub views are on top of the full screen view.

struct PeopleView: View {
    
    struct Person: Identifiable {
        var id: UUID = UUID()
        var first: String
        var last: String
        var isFullScreen: Bool
        var tag: Int
    }
    
    func getColor(index: Int) -> Color {
        switch index {
        case 1: return .blue
        case 2: return .green
        case 3: return .yellow
        case 4: return .orange
        case 5: return .purple
        case 6: return .pink
        default: return .gray
        }
    }
    @Namespace private var animationNamespace
    
    @State private var selectedPerson: Person? = nil
    private let personId = UUID()
    private let cardWidth: CGFloat = UIScreen.main.bounds.width / 3
    @State private var fullscreen: Bool = false
    
    var columns: [GridItem] { Array(repeating: .init(.flexible()), count: 2) }
    
    @State private var people: [Person] = [
        Person(first: "John", last: "Doe", isFullScreen: false, tag: 1),
        Person(first: "Jane", last: "Doe", isFullScreen: false, tag: 2),
        Person(first: "Fred", last: "Doe", isFullScreen: false, tag: 3),
        Person(first: "Bill", last: "Doe", isFullScreen: false, tag: 4),
        Person(first: "Jack", last: "Doe", isFullScreen: false, tag: 5),
        Person(first: "Mary", last: "Doe", isFullScreen: false, tag: 6)
    ]
    
    
    private func personView(person: Person) -> some View {
        RoundedRectangle(cornerRadius: 5)
            .foregroundStyle(getColor(index: person.tag))
            .shadow(radius: 5)
            .overlay {
                Text(person.first)
            }
            .matchedGeometryEffect(
                id: selectedPerson?.id == person.id ? personId : person.id,
                in: animationNamespace,
                isSource: false
            )
    }
    
    private var floatingPersonViews: some View {
        LazyVGrid(columns: columns, spacing: 20) {
            ForEach(people.indices, id: \.self) { index in
                personView(person: people[index])
                    .allowsHitTesting(false)
                    .frame(height: 320)
            }
        }
    }
    
    
    private var cardBases: some View {
        HStack {
            LazyVGrid(columns: columns, spacing: 20) {
                ForEach(people.indices, id: \.self) { index in
                    RoundedRectangle(cornerRadius: 5)
                        .fill(Color.black)
                        .frame(width: cardWidth, height: 100)
                        .onTapGesture {
                            withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)) {
                                selectedPerson = people[index]
                                people[index].isFullScreen = true
                                fullscreen = true
                            }
                        }
                        .matchedGeometryEffect(
                            id: people[index].id,
                            in: animationNamespace,
                            isSource: true
                        )
                        .zIndex(people[index].isFullScreen ? 1 : -1)
                }
            }
        }
        .padding()
    }
    
    
    private var detailBase: some View {
        Rectangle()
            .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
            .opacity(0)
            .matchedGeometryEffect(
                id: personId,
                in: animationNamespace,
                isSource: true
            )
    }
    
    
    private var detailView: some View {
        VStack {
            detailBase
        }
        .contentShape(Rectangle())
        .onTapGesture {
            withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)) {
                selectedPerson?.isFullScreen = false
                fullscreen = false
                selectedPerson = nil
            }
        }
    }
    
    var body: some View {
        ZStack {
            cardBases//.zIndex(fullscreen ? -3 : 0)
            floatingPersonViews//.zIndex(fullscreen ? -2 : 0)
            detailView.zIndex(fullscreen ? 1 : -1)
        }
    }
}

This now works for going full screen and taking up the whole view, however the views in the LazyGrid are still visible on top apart from the last one on the right.

Home Screen John Selected Mary Selected

I've tried setting the .zIndex of the DetailView high, and also setting the other two views to low using the fullscreen variable. But it doesn't seem to matter what the .zIndex is set to.


Solution

  • You certainly got quite close to a solution already. With the following changes you can get it working:

    Other suggestions:

    Here is the updated example:

    struct PeopleView: View {
    
        struct Person: Identifiable {
            var id: UUID = UUID()
            var first: String
            var last: String
            var tag: Int
        }
    
        func getColor(index: Int) -> Color {
            switch index {
            case 1: .blue
            case 2: .green
            case 3: .yellow
            case 4: .orange
            case 5: .purple
            case 6: .pink
            default: .gray
            }
        }
    
        @Namespace private var animationNamespace
        @State private var selectedPerson: Person? = nil
        private let fullScreenId = UUID()
        private let columns: [GridItem] = Array(repeating: .init(.flexible()), count: 2)
        private let people: [Person] = [
            Person(first: "John", last: "Doe", tag: 1),
            Person(first: "Jane", last: "Doe", tag: 2),
            Person(first: "Fred", last: "Doe", tag: 3),
            Person(first: "Bill", last: "Doe", tag: 4),
            Person(first: "Jack", last: "Doe", tag: 5),
            Person(first: "Mary", last: "Doe", tag: 6)
        ]
    
        private func personView(person: Person) -> some View {
            RoundedRectangle(cornerRadius: 5)
                .foregroundStyle(getColor(index: person.tag))
                .shadow(radius: 5)
                .overlay {
                    Text(person.first)
                }
                .zIndex(selectedPerson?.id == person.id ? 1 : 0)
                .onTapGesture {
                    withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)) {
                        selectedPerson = selectedPerson == nil ? person : nil
                    }
                }
                .matchedGeometryEffect(
                    id: selectedPerson?.id == person.id ? fullScreenId : person.id,
                    in: animationNamespace,
                    isSource: false
                )
        }
    
        private var floatingPersonViews: some View {
            ZStack {
                ForEach(people) { person in
                    personView(person: person)
                }
            }
        }
    
        private var cardBases: some View {
            GeometryReader { proxy in
                LazyVGrid(columns: columns, spacing: 20) {
                    ForEach(people) { person in
                        RoundedRectangle(cornerRadius: 5)
                            .fill(Color.black)
                            .frame(width: proxy.size.width / 3, height: 100)
                            .matchedGeometryEffect(
                                id: person.id,
                                in: animationNamespace,
                                isSource: true
                            )
                    }
                }
                .padding()
            }
        }
    
        private var detailView: some View {
            Color.clear
                .matchedGeometryEffect(
                    id: fullScreenId,
                    in: animationNamespace,
                    isSource: true
                )
                .ignoresSafeArea()
        }
    
        var body: some View {
            ZStack {
                cardBases
                detailView
                floatingPersonViews
            }
        }
    }
    

    Animation


    EDIT For tvOS, it seems that gestures work a bit differently. One way to solve is to change the person views into buttons. You can change the way the button is styled by applying a .buttonStyle.

    While we're at it, you may have noticed that the selected view was going behind the other cards when it was going back to its position in the grid. You notice it more if you slow down the animation (on iOS at least, not sure if it is still a problem on tvOS). This is because, the zIndex reverts to 0 before the view has returned to its position.

    A way to solve the zIndex issue is to use a second state variable to store the id of the top-most view:

    @State private var topPersonId: UUID?
    

    This should be set in the button callback at the same time as selectedPerson. Actually, it can be set whenever the button is selected, it doesn't have to depend on whether the person was already selected. There is also no need to reset it when the view returns into position, it can just remain the top-most view, even when it is in the grid.

    So here is the updated personView with these changes applied:

    private func personView(person: Person) -> some View {
        Button {
            topPersonId = person.id
            withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.8)) {
                selectedPerson = selectedPerson == nil ? person : nil
            }
        } label: {
            RoundedRectangle(cornerRadius: 5)
                .foregroundStyle(getColor(index: person.tag))
                .shadow(radius: 5)
                .overlay {
                    Text(person.first)
                }
        }
        .buttonStyle(.plain) // or try .borderless
        .zIndex(topPersonId == person.id ? 1 : 0)
        .matchedGeometryEffect(
            id: selectedPerson?.id == person.id ? fullScreenId : person.id,
            in: animationNamespace,
            isSource: false
        )
    }
    

    Animation