Okan T.
Okan T.

Reputation: 78

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.

Upvotes: 2

Views: 63

Answers (2)

Benzy Neez
Benzy Neez

Reputation: 21730

When an item is selected, it is shown as an overlay. But then you have 2 items with the same id that are both trying to be source for .matchedGeometryEffect, so this gives an error in the console.

If you want a color to be seen to move seamlessly from its original position in a horizontal scroll view to the overlay position at the top of the screen, then it needs to be above the scroll view in the layout, not inside the scroll view. So one way to get it to work is as follows:

  • Use invisible placeholders for all the colors inside the horizontal scroll views.
  • Use an invisible placeholder for the overlay too.
  • Show the actual colors as the last content in the ZStack.
  • Match the colors to their display positions using .matchedGeometryEffect.

Other implementation notes:

  • A separate matched-geometry id is used for the overlay position. This way, all the placeholders have a unique id and the placeholders do not change position.
  • In order for horizontal scrolling to work, the colors allow gestures to pass through when they are not selected. This is done using the modifier .allowsHitTesting.
  • Tap gestures are attached to the scrolled placeholders for allowing a selection to be made. For this to work, it is necessary to set a .contentShape on the placeholders, because they are invisible.
  • The selection is cleared by attaching a tap gesture to the color. Due to hit testing being disabled when not selectd, the tap gesture is only active when the color is actually selected.
  • Instead of basing the width of the overlay on the (deprecated) UIScreen.main.bounds, just add horizontal padding to the placeholder.

Here is an updated version of the example to show it working this way:

struct ContentView: View {
    @State var currentColor: ColorModel? = nil
    @Namespace var namespace
    private let selectedId = "Selection"

    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) {
            overlayPlaceholder
            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)
                }
            }
            allColors
        }
    }

    private var overlayPlaceholder: some View {
        Color.clear
            .matchedGeometryEffect(id: selectedId, in: namespace)
            .frame(height: 200)
            .padding(.horizontal, 16)
    }

    private func colorView(color: ColorModel) -> some View {
        RoundedRectangle(cornerRadius: 8)
            .foregroundStyle(color.color)
            .matchedGeometryEffect(
                id: color.id == currentColor?.id ? selectedId : color.id,
                in: namespace,
                isSource: false
            )
            .allowsHitTesting(color.id == currentColor?.id)
            .onTapGesture {
                withAnimation() {
                    currentColor = nil
                }
            }
    }

    @ViewBuilder
    private var allColors: some View {
        ForEach(color1) { color in
            colorView(color: color)
        }
        ForEach(color2) { color in
            colorView(color: color)
        }
        ForEach(color3) { color in
            colorView(color: color)
        }
        ForEach(color4) { color in
            colorView(color: color)
        }
    }
}

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) { color in
                    Color.clear
                        .matchedGeometryEffect(id: color.id, in: namespace)
                        .frame(width: 300, height: 200)
                        .contentShape(Rectangle())
                        .onTapGesture {
                            withAnimation() {
                                currentColor = color
                            }
                        }
                }
            }
            .padding(.horizontal, 8)
        }
    }
}

Animation

Upvotes: 2

Sweeper
Sweeper

Reputation: 273540

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

Upvotes: 2

Related Questions