SwiftUI: control zIndex on views with matchedGeometryEffect

749 Views Asked by At

I am building a custom SegmentedPicker in SwiftUI where the selector adjusts its size to fit the frame of each picker item. I did it already using PreferenceKeys as inspired by this post (Inspecting the View Tree) for uniformly sized items like shown below:

enter image description here

I think I can simplify my implementation considerably and avoid using PreferencyKeys altogether by using a .matchedGeometryEffect(). My idea was to present a selector behind each item only when that item has been selected and sync the transition using the .matchedGeometryEffect(). Almost everything is working except for an issue where the selector will be in front of the previously selected item. I tried explicitly setting the zIndex, but it does not seem to affect the result:

enter image description here

The code:

struct MatchedGeometryPicker: View {
    @Namespace private var animation
    @Binding var selection: Int
    let items: [String]
    
    var body: some View {
        HStack {
            ForEach(items.indices) { index in
                ZStack {
                    if isSelected(index) {
                        Color.gray.clipShape(Capsule())
                            .matchedGeometryEffect(id: "selector", in: animation)
                            .animation(.easeInOut)
                            .zIndex(0)
                    }
                    
                    itemView(for: index)
                        .padding(7)
                        .zIndex(1)
                }
                .fixedSize()
            }
        }
        .padding(7)
    }
    
    func itemView(for index: Int) -> some View {
        Text(items[index])
            .frame(minWidth: 0, maxWidth: .infinity)
            .foregroundColor(isSelected(index) ? .black : .gray)
            .font(.caption)
            .onTapGesture { selection = index }
    }
    
    func isSelected(_ index: Int) -> Bool { selection == index }
}

And in ContentView:

struct ContentView: View {
    @State private var selection = 0
    
    let pickerItems = [ "Item 1", "Long item 2", "Item 3", "Item 4", "Long item 5"]
    
    var body: some View {
        MatchedGeometryPicker(selection: $selection, items: pickerItems)
            .background(Color.gray.opacity(0.10).clipShape(Capsule()))
            .padding(.horizontal, 5)
        
    }
}

Any ideas how to fix this?

1

There are 1 best solutions below

0
On

I managed to solve all the animation issues I had with the picker implementation that uses PreferenceKeys when the items have different frame sizes. This does not solve the issue I have with the zIndex and the .matchedGeometryEffect(), so I will not accept my own answer, but I'll post it as a reference in case anyone needs it in the future.

The code:

public struct PKPicker: View {
    @Binding var selection: Int
    @State private var frames: [CGRect] = []
    let items: [String]
    
    public init(
        selection: Binding<Int>,
        items: [String])
    {
        self._selection = selection
        self._frames = State(wrappedValue: Array<CGRect>(repeating: CGRect(),
                                                         count: items.count))
        self.items = items
    }
    
    public var body: some View {
        ZStack(alignment: .topLeading) {
            selector
            HStack {
                ForEach(items.indices)  { index in
                    itemView(for: index)
                        
                }
            }
        }
        .onPreferenceChange(PKPickerItemPreferenceKey.self) { preferences in
            preferences.forEach { frames[$0.id] = $0.frame }
        }
        .coordinateSpace(name: "picker2")
        
    }
    
    var selector: some View {
        Color.gray.opacity(0.25).clipShape(Capsule())
            .frame(width: frames[selection].size.width,
                   height: frames[selection].size.height)
            .offset(x: frames[selection].minX, y: frames[selection].minY)
    }
    
    func itemView(for index: Int) -> some View {
        Text(items[index])
            .fixedSize()
            .padding(7)
            .foregroundColor(isSelected(index) ? .black : .gray)
            .font( .caption)
            .onTapGesture { selection = index }
            .background(PKPickerItemPreferenceSetter(id: index))
            
    }
    
    func isSelected(_ index: Int) -> Bool {
        index == selection
    }
}

struct PKPickerItemPreferenceData: Equatable {
    let id: Int
    let frame: CGRect
}

struct PKPickerItemPreferenceKey: PreferenceKey {
    typealias Value = [PKPickerItemPreferenceData]

    static var defaultValue: [PKPickerItemPreferenceData] = []
    
    static func reduce(
        value: inout [PKPickerItemPreferenceData],
        nextValue: () -> [PKPickerItemPreferenceData])
    {
        value.append(contentsOf: nextValue())
    }
}

struct PKPickerItemPreferenceSetter: View {
    let id: Int
    let coordinateSpace = CoordinateSpace.named("picker2")
    
    var body: some View {
        GeometryReader { geometry in
            Color.clear
                .preference(key: PKPickerItemPreferenceKey.self,
                            value: [PKPickerItemPreferenceData(
                                        id: id, frame: geometry.frame(in: coordinateSpace))])
        }
    }
}

And in ContentView

struct ContentView: View { @State private var selection = 0

let pickerItems = [ "Item 1", "Long item 2", "Item 3", "Item 4", "Long Item 5"]

var body: some View {
    PKPicker(selection: $selection.animation(.easeInOut), items: pickerItems)
        .padding(7)
        .background(Color.gray.opacity(0.10).clipShape(Capsule()))
        .padding(5)
    
}

Result:

enter image description here