swiftuiswiftui-animationswiftui-matchedgeometryeffect

MatchGeometryEffect not work properly while return come back to start position


I am working on geometryMatchEffect and struggled on one point. In my case, when tap on a item, the item should be fly to page of the top and other items should remain backside. And then when tap on item where top of the page it should be return source. But i am struggling at return the source case. It lost while return to source until come to same stack. I have tried a lot of thing such as zIndex and isSource parameters but failed and confused. So i added sample code below without zIndex and isSource parameters.

Anyone could be give advice ?

Model

struct ColorModel: Identifiable, Hashable {
    let id: String
    let color: Color
}

Horizontal structure

struct HorizontalScrollView: View {
    @Binding var currentColor: ColorModel?
    var colors: [ColorModel]
    var namespace: Namespace.ID
    
    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 8) {
                ForEach(colors, id: \.self) { color in
                    RoundedRectangle(cornerRadius: 8)
                        .foregroundStyle(color.color)
                        .matchedGeometryEffect(id: color.id, in: namespace)
                        .frame(width: 300, height: 200)
                        .onTapGesture {
                            withAnimation() {
                                currentColor = color
                            }
                        }
                }
            }
            .padding(.horizontal, 8)
        }
    }
}

ContentView

struct ContentView: View {
    @State var currentColor: ColorModel? = nil
    @Namespace var namespace
    
    var color1: [ColorModel] = [.init(id: "1", color: .blue),
                            .init(id: "2", color: .green),
                            .init(id: "3", color: .purple)]
    var color2: [ColorModel] = [.init(id: "4", color: .yellow),
                            .init(id: "5", color: .red),
                            .init(id: "6", color: .pink)]
    var color3: [ColorModel] = [.init(id: "7", color: .brown),
                            .init(id: "8", color: .cyan),
                            .init(id: "9", color: .gray)]
    var color4: [ColorModel] = [.init(id: "10", color: .indigo),
                            .init(id: "11", color: .mint),
                            .init(id: "12", color: .orange)]
    
    
    var body: some View {
        ZStack(alignment: .top) {
            
            ScrollView(.vertical, showsIndicators: false) {
                VStack(spacing: 8) {
                    HorizontalScrollView(currentColor: $currentColor, colors: color1, namespace: namespace)
                    HorizontalScrollView(currentColor: $currentColor, colors: color2, namespace: namespace)
                    HorizontalScrollView(currentColor: $currentColor, colors: color3, namespace: namespace)
                    HorizontalScrollView(currentColor: $currentColor, colors: color4, namespace: namespace)
                }
            }

            if let currentColor {
                overlayView(color: currentColor)
            }
        }
    }
    
    private func overlayView(color: ColorModel) -> some View {
        RoundedRectangle(cornerRadius: 8)
            .foregroundStyle(color.color)
            .matchedGeometryEffect(id: color.id, in: namespace)
            .frame(width: UIScreen.main.bounds.width - 32, height: 200)
            .transition(.identity)
            .onTapGesture {
                withAnimation() {
                    currentColor = nil
                }
            }
    }
}

Output here

I have tried zIndex and isSource parameters but couldn't successful. I am expecting selected item fly to top of page and return come back to resource while fly.


Solution

  • The source view depends on which way you are transitioning. When you transition from the item to the overlay, the source should be the overlay. When you transition from the overlay back to the item, the source should be the item.

    The overlayView should be transparent. It should not be the same color as the current color. It should be transparent, or else you will see the current color suddenly appear on screen without any animation. Remember that you are only using it as a "placeholder", for where the actual item should go. The item should just be moved to match the position and size of this placeholder.

    Finally, you should disable scroll-clipping for HorizontalScrollView.

    Here I have added comments to the major changes I have made.

    private func overlayView(color: ColorModel) -> some View {
        RoundedRectangle(cornerRadius: 8)
            .foregroundStyle(.clear)
            .contentShape(.rect(cornerRadius: 8))
            // the overlay should be the source when currentColor has been set to a color (not nil)
            .matchedGeometryEffect(id: color.id, in: namespace, isSource: currentColor != nil)
            .frame(height: 200)
            .padding(.horizontal, 16)
            .transition(.identity)
    
             // this zIndex should be higher than the zIndex of all the items,
             // so that *this* onTapGesture gets triggered when you tap the overlay instead of the onTapGesture for each item
            .zIndex(100)
            .onTapGesture {
                currentColor = nil
            }
    }
    
    // I extracted the rounded rectangle items into this ItemView, so that handling the zIndex is a bit easier
    struct ItemView: View {
        @Binding var currentColor: ColorModel?
        var color: ColorModel
        var namespace: Namespace.ID
        
        @State private var zIndex: Double = 0
        
        var body: some View {
            RoundedRectangle(cornerRadius: 8)
                .foregroundStyle(color.color)
                // each item should be the source of the matched geometry effect when
                // currentColor is set to nil
                .matchedGeometryEffect(id: color.id, in: namespace, isSource: currentColor == nil)
                .frame(width: 300, height: 200)
                .zIndex(zIndex)
                // used an implicit animation here so that I don't need to write
                // withAnimation { ... } everywhere
                .animation(.default, value: currentColor)
                .onTapGesture {
                    // move the item to the top of other items (but below the overlayView)
                    zIndex = 99
                    if currentColor == nil {
                        currentColor = color
                    } else {
                        // if this color is tapped while another color is selected,
                        // first deselect the previous color...
                        currentColor = nil
                        Task {
                            try await Task.sleep(for: .milliseconds(50))
                            // then after a short delay select this color
                            currentColor = color
                        }
                        // this could have probably be done without a delay,
                        // but that would require more drastic changes to your code
                    }
                }
                .onChange(of: currentColor?.id) { _, newValue in
                    // when a different color is selected, move this view back to the bottom
                    if let newValue, newValue != color.id {
                        zIndex = 0
                    }
                }
        }
    }
    
    struct HorizontalScrollView: View {
        @Binding var currentColor: ColorModel?
        var colors: [ColorModel]
        var namespace: Namespace.ID
        
        var body: some View {
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 8) {
                    ForEach(colors, id: \.self) { color in
                        ItemView(currentColor: $currentColor, color: color, namespace: namespace)
                    }
                }
                .padding(.horizontal, 8)
            }
            // disable the scroll clipping
            .scrollClipDisabled()
        }
    }