iosswiftuimatchedgeometryeffect

How to use .matchedGeometryEffect in a scrollview, but maintain the scroll position


I am trying to use .matchedGeometryEffect in a scrollview, and using other stack posts I figured out how to do so without seeing any warning messages, such as:

Multiple inserted views in matched geometry group Pair<String, ID>(first: "AB0D062C-C14D-4A2A-8AC4-32CE12AF289FText", second: SwiftUI.Namespace.ID(id: 136)) have isSource: true, results are undefined.

I am wondering if the above message is serious, or is it something that can be lived with. it does mess with the animation but not as much as the issue below.

I used these to figure it out:

SwiftUI matchedGeometryEffect "Multiple inserted views in matched geometry group Pair warning" when View embedded in Button

and

Why am I encountering "Multiple inserted views in matched geometry group" for a conditional render?

However because of the solutions in the first link, it separates the 2 views, and in a scroll view when the expanded view is dismissed it brings it back to the first cell in the scrollview. This is the issue. I want to keep my animated transition, but also keep the scroll position when I dismiss back to the main view.

It makes the animation not look as clean as it should be, and sometimes it makes mistakes and causes weird things to happen, such as the image. The green square is the 3rd square in the scroll, and when dismissed you can see the red square behind it, which happens to be the 1st square.

enter image description here

I am wondering if there is a solution that works with both of them, I feel like I have to make a choice between the animation or the scroll position.

this is all my code:

 struct Home: View {
    @State private var colorStructs: [ColorStruct] = [
            ColorStruct(color: .red),
            ColorStruct(color: .blue),
            ColorStruct(color: .green),
            ColorStruct(color: .yellow)
        ]

    @Namespace private var resultsNamespace
    @State private var isPressed: Bool = false
    @State var selectedColor: ColorStruct? = nil

    @State private var scrollPosition: String? = nil // Track scroll position
    @State private var lastScrollPosition: String? = nil // Save last position

    var body: some View {
        ZStack {
            if !isPressed {
                VStack {
                    ScrollView(.horizontal) {
                        LazyHStack(spacing: 0) {
                            ForEach(colorStructs, id: \.id) { color in
                                    RoundedRectangle(cornerRadius: 25)
                                        .fill(color.color.gradient)
                                        .matchedGeometryEffect(id: color.id + "Color", in: resultsNamespace)
                                        .padding(.horizontal, 15)
                                        .containerRelativeFrame(.horizontal)
                                        .overlay {
                                            Text("\(color.id)")
                                                .matchedGeometryEffect(id: color.id + "Text", in: resultsNamespace)
                                                .padding(.horizontal)

                                        }
                                        .onTapGesture {
                                            selectedColor = color
                                            lastScrollPosition = scrollPosition
                                            withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
                                                isPressed.toggle()
                                            }
                                        }
                                        .id(color.id)
                            }
                        }
                        .scrollTargetLayout()
                    }
                    .scrollPosition(id: $scrollPosition)
                    .scrollTargetBehavior(.viewAligned)
                    .scrollIndicators(.hidden)
                    .frame(height: 250)
                    .safeAreaPadding(.vertical, 15)
                    .safeAreaPadding(.horizontal, 25)
                }
            }

            if isPressed {
                ExpandedView(
                    colorStruct: selectedColor ?? ColorStruct(color: .red),
                    dismiss: $isPressed,
                    namespace: resultsNamespace,
                    onDismiss: {
                        if let lastPos = lastScrollPosition {
                            scrollPosition = lastPos
                        }
                    }
                )
            }

        }
        .navigationTitle("Custom Indicator")
    }
}





struct ExpandedView: View {
    let colorStruct: ColorStruct
    @Binding var dismiss: Bool
    var namespace: Namespace.ID
    var onDismiss: () -> Void

    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 25)
                .matchedGeometryEffect(id: colorStruct.id + "Color", in: namespace, isSource: false)
                .frame(height: 550)
                .foregroundStyle(colorStruct.color)

            VStack {
                Text("\(colorStruct.id)")
                    .matchedGeometryEffect(id: colorStruct.id + "Text", in: namespace, isSource: false)
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .offset(y: -150)
                    .padding(.horizontal)
            }
        }
        .onTapGesture {
            withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
                dismiss.toggle()
                onDismiss()
            }
        }
        .padding()
    }
}

I have tried other things, such as using zindex() on each view, and changing the isSource in the matchedGeometryEffect, but it either didnt animate or had the same issue as above.

I would appreciate your help.

if there is anything I can help with please ask.

UPDATE:

Desired Behavior: I would like the animation of a square expanding while also keeping the scroll position when I leave the Home view.


Solution

  • Regarding your first point:

    I am wondering if the above message is serious, or is it something that can be lived with

    The error "Multiple inserted views in matched geometry group" is most certainly serious and should not be ignored. It means that .matchedGeometryGroup is not being used correctly, because there is more than one item having the same id and flagged as isSource: true (this being the default).

    However, the issue with the mis-placed position has nothing to do with .matchedGeometryEffect. It still happens if you comment out all the cases of this modifier.

    There may be a combination of reasons:

    To fix, the following changes are needed:

    The updated version below shows how the changes can be applied. It uses the following improvised version of ColorStruct:

    struct ColorStruct {
        let color: Color
        var id: String {
            String(describing: color)
        }
    }
    
    // Home
    
    ZStack {
        if !isPressed {
            VStack {
                ScrollView(.horizontal) {
                    LazyHStack(spacing: 0) {
                        ForEach(colorStructs, id: \.id) { color in
                            RoundedRectangle(cornerRadius: 25)
                                .fill(color.color.gradient)
                                .matchedGeometryEffect(
                                    id: color.id + "Color",
                                    in: resultsNamespace
                                )
                                .padding(.horizontal, 15)
                                .containerRelativeFrame(.horizontal)
                                .overlay {
                                    Text("\(color.id)")
                                        .matchedGeometryEffect(
                                            id: color.id + "Text",
                                            in: resultsNamespace,
                                            properties: .position // 👈 added
                                        )
                                        .padding(.horizontal)
                                }
                                .onTapGesture {
                                    selectedColor = color
                                    lastScrollPosition = scrollPosition
                                    scrollPosition = nil // 👈 added
                                    withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
                                        isPressed.toggle()
                                    }
                                }
                                .id(color.id)
                        }
                    }
                    .scrollTargetLayout()
                }
                .scrollPosition(id: $scrollPosition)
                .scrollTargetBehavior(.viewAligned)
                .scrollIndicators(.hidden)
                .frame(height: 250)
                .safeAreaPadding(.vertical, 15)
                .safeAreaPadding(.horizontal, 25)
                .onAppear { // 👈 added
                    if let lastPos = lastScrollPosition { // 👈 moved from onDismiss
                        scrollPosition = lastPos
                    }
                }
            }
        }
        if isPressed {
            ExpandedView(
                colorStruct: selectedColor ?? ColorStruct(color: .red),
                dismiss: $isPressed,
                namespace: resultsNamespace,
                onDismiss: {} // 👈 empty
            )
        }
    }
    .navigationTitle("Custom Indicator")
    
    // ExpandedView
    
    ZStack {
        RoundedRectangle(cornerRadius: 25)
            .matchedGeometryEffect(id: colorStruct.id + "Color", in: namespace) // 👈 use default isSource: true
            .frame(height: 550)
            .foregroundStyle(colorStruct.color)
    
        VStack {
            Text("\(colorStruct.id)")
                .matchedGeometryEffect(
                    id: colorStruct.id + "Text",
                    in: namespace,
                    properties: .position // 👈 added, + default isSource: true
                )
                .font(.largeTitle)
                .fontWeight(.bold)
                .offset(y: -150)
                .padding(.horizontal)
        }
    }
    .onTapGesture {
        withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
            dismiss.toggle()
            onDismiss()
        }
    }
    .padding()
    

    An alternative and perhaps simpler way to get it working is to hide the ScrollView when an item is selected, instead of removing it from the view. The ScrollView can be hidden but kept in place by changing its opacity to 0. This way, there is no need to track and restore the scroll position at all, because it remains unchanged.

    By hiding ExpandedView in a similar way, the animation can also be improved.

    // @State private var scrollPosition: String? = nil // not needed
    // @State private var lastScrollPosition: String? = nil // not needed
    
    // Home
    
    ZStack {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                ForEach(colorStructs, id: \.id) { color in
                    RoundedRectangle(cornerRadius: 25)
                        .fill(color.color.gradient)
                        .matchedGeometryEffect(
                            id: color.id + "Color",
                            in: resultsNamespace,
                            isSource: !isPressed // 👈 added
                        )
                        .padding(.horizontal, 15)
                        .containerRelativeFrame(.horizontal)
                        .overlay {
                            Text("\(color.id)")
                                .matchedGeometryEffect(
                                    id: color.id + "Text",
                                    in: resultsNamespace,
                                    properties: .position,
                                    isSource: !isPressed // 👈 added
                                )
                                .padding(.horizontal)
                        }
                        .onTapGesture {
                            selectedColor = color
                            withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
                                isPressed.toggle()
                            }
                        }
                        .id(color.id)
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
        .scrollIndicators(.hidden)
        .frame(height: 250)
        .safeAreaPadding(.vertical, 15)
        .safeAreaPadding(.horizontal, 25)
        .opacity(isPressed ? 0 : 1) // 👈 added
    
        ExpandedView(
            colorStruct: selectedColor ?? ColorStruct(color: .red),
            dismiss: $isPressed,
            namespace: resultsNamespace,
            onDismiss: {}
        )
        .opacity(isPressed ? 1 : 0) // 👈 added
    }
    .navigationTitle("Custom Indicator")
    
    // ExpandedView
    
    ZStack {
        RoundedRectangle(cornerRadius: 25)
            .matchedGeometryEffect(
                id: colorStruct.id + "Color",
                in: namespace,
                isSource: dismiss // 👈 added
            )
            .frame(height: 550)
            .foregroundStyle(colorStruct.color)
    
        VStack {
            Text("\(colorStruct.id)")
                .matchedGeometryEffect(
                    id: colorStruct.id + "Text",
                    in: namespace,
                    properties: .position,
                    isSource: dismiss // 👈 added
                )
                .font(.largeTitle)
                .fontWeight(.bold)
                .offset(y: -150)
                .padding(.horizontal)
        }
    }
    .onTapGesture {
        withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
            dismiss.toggle()
            onDismiss()
        }
    }
    .padding()
    

    This is how it looks when doing it this way:

    Animation


    For an altogether smoother animation, you could consider using placeholders for the positions, then showing the visible versions as overlays. Examples of where this technique is used can be seen in the answers to the following posts:

    SwiftUI .matchGeometryEffect not working smoothly
    Photos App-style .matchedGeometryEffect working unexpectedly
    MatchGeometryEffect not work properly while return come back to start position