Search code examples
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:

    • The floating person views should be the top layer of the ZStack and the tap gestures should be attached to these views.

    • The position of the floating views is determined using .matchedGeometryEffect, so there is no need for these views to be inside a LazyVGrid. In fact, this seems to cause problems with zIndex working. A simple ZStack can be used as the container for the person views instead.

    • The placeholders should all have fixed ids for .matchedGeometryEffect. The ids of the person views are then used to control, which placeholder a person view is matched to (this being either a card in the grid, or the full screen detail view).

    • There is no need for any boolean flags. The zoom effect can be controlled with just the state variable selectedPerson. This means the array of persons can be defined using let, instead of as a state variable (for the purpose of the example anyway).

    • When a person is selected, its zIndex needs to be raised, so that it floats above the other cards.

    Other suggestions:

    • The struct Person is Identifable, so the ForEach does not need to use the indices of the array for iteration.

    • Don't use UIScreen.main to find the size of the screen. It doesn't work for iPad split screen and it's deprecated anyway. Use a GeometryReader instead.

    • Color.clear can be used as the placeholder for the full screen view. Like all colors, this is greedy, so it uses as much space as possible (the full screen). Add .ignoresSafeArea() if it should go into the safe area insets too.

    • There is no need for a return in every case of the switch statement. You can just use the same switch statement but without the returns.

    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