2

I have a scroll view that contains elements, and I use a matched geometry effect to animate an element above the screen when it is clicked (kind of creating a new view on top). I have to change the IDS in the matched geometry modifier, and this solution details why: https://stackoverflow.com/a/76795258/18070009. But in doing so, I raise the warning below.

Does anyone know why this occurs and how I can fix it?

The warning:

Multiple inserted views in matched geometry group Pair<String, ID>(first: "B", second: SwiftUI.Namespace.ID(id: 3543)) have isSource: true, results are undefined.

The code:

struct NewsReplyView: View {
    @Namespace var namespace
    @State var text: String = ""
    @State var selected: String = ""
    @State var show = false
    @State var loaded = false
    @State var arr = ["1", "2", "3", "4", "5"]
    var body: some View {
        ZStack {
            VStack{
                ForEach(arr.indices) { i in
                    RoundedRectangle(cornerRadius: 20).frame(width: 100, height: 100)
                        .id(arr[i])
                        .onTapGesture {
                            withAnimation(.spring()){
                                show.toggle()
                                selected = arr[i]
                                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                                    loaded = true
                                }
                            }
                        }
                //changing here causes warning 
                        .matchedGeometryEffect(id: loaded ? "A" : selected, in: namespace)
                }
            }.opacity(show ? 0.0 : 1.0)
            if show {
                VStack{
                    RoundedRectangle(cornerRadius: 20).frame(width: 100, height: 100)
                        .id(selected)
             //changing here causes warning 
                        .matchedGeometryEffect(id: loaded ? "B" : selected, in: namespace) 
                    Spacer()
                    if loaded {
                        HStack{
                            TextField("", text: $text)
                                .onSubmit {
                                    UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
                                }
                            Button {
                                withAnimation(.spring()){
                                    loaded = false
                                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                                        withAnimation(.spring()){
                                            show.toggle()
                                        }
                                    }
                                }
                            } label: {
                                Image(systemName: "xmark.circle.fill")
                            }
                        }
                    }
                }
            }
        }
    }
}
VonC
  • 1,262,500
  • 529
  • 4,410
  • 5,250
ahmed
  • 341
  • 2
  • 9

1 Answers1

1

I am confused by:

ForEach(arr.indices) { i in
    ...
    //changing here causes warning 
    .matchedGeometryEffect(id: loaded ? "A" : selected, in: namespace)
}

That could be an issue for when you use matchedGeometryEffect:

if show {
    VStack{
        RoundedRectangle(cornerRadius: 20).frame(width: 100, height: 100)
            .id(selected)
     //changing here causes warning 
            .matchedGeometryEffect(id: loaded ? "B" : selected, in: namespace) 
        ...
    }
}

The matchedGeometryEffect(id:in:properties:anchor:isSource:) needs unique identifiers to understand how to transition from one view to another.
See also "How to synchronize animations from one view to another with matchedGeometryEffect()" from Paul Hudson for additional examples.

In your code, there is a potential scenario where multiple views in the ForEach loop have the same ID. That happens when loaded is false, and multiple elements in the scroll view might use the selected ID, causing ambiguity. That will not sit well with .matchedGeometryEffect's requirements.

For each item in the list, you are switching the ID of the .matchedGeometryEffect dynamically based on the loaded state. That can cause issues, especially if SwiftUI tries to render the view during the transitional phase when the ID is being switched. The same applies for the detail view, which also switches IDs dynamically.

When the animation triggers, you may have both the original item and the "detail" view using the same ID (selected). That is likely to confuse SwiftUI: both are trying to claim to be the source of the animation for the specified ID, and I suppose this is where the warning originates.


To resolve this, a consistent and unambiguous identification mechanism for .matchedGeometryEffect is required. Avoid dynamically changing the ID during the lifecycle of the view. Instead, ensure that the source and target views always maintain their respective unique IDs during the entire transition.
You can also simplify the transition by just using .opacity to show/hide the list and detail views.

Something like:

struct NewsReplyView: View {
    @Namespace var namespace
    @State var text: String = ""
    @State var selected: String = ""
    @State var showDetail = false

    @State var arr = ["1", "2", "3", "4", "5"]

    var body: some View {
        ZStack {
            if !showDetail {
                VStack{
                    ForEach(arr, id: \.self) { item in
                        RoundedRectangle(cornerRadius: 20)
                            .frame(width: 100, height: 100)
                            .onTapGesture {
                                withAnimation(.spring()){
                                    selected = item
                                    showDetail = true
                                }
                            }
                            .matchedGeometryEffect(id: item, in: namespace)
                    }
                }
            } else {
                VStack{
                    RoundedRectangle(cornerRadius: 20)
                        .frame(width: 100, height: 100)
                        .matchedGeometryEffect(id: selected, in: namespace)
                        .onTapGesture {
                            withAnimation(.spring()){
                                showDetail = false
                            }
                        }
                    Spacer()
                    HStack{
                        TextField("", text: $text)
                            .onSubmit {
                                UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
                            }
                        Button {
                            withAnimation(.spring()){
                                showDetail = false
                            }
                        } label: {
                            Image(systemName: "xmark.circle.fill")
                        }
                    }
                }
            }
        }
    }
}

When you tap an item, the detail view with the same item is shown, and the list is hidden. This is achieved by using simple if-else conditions within the ZStack to switch between list and detail views. That ensures a consistent ID is kept for the .matchedGeometryEffect and that should avoid the warning.


The OP confirms:

This does in fact get rid of the warning, but again the keyboard will now cause the element to animate back, as discussed in "Closing keyboard causes matched Geometry effect to execute".

Your solution in this would work, but due to my very large file it would need too much refactoring. So in my case, I have to dynamically change the ID of the element to prevent the keyboard from triggering the animation.

Given your constraints and the need to avoid animating back when the keyboard appears, you can employ a trick to ensure that .matchedGeometryEffect works correctly even when IDs are dynamically changed.

You can create an intermediary view that always has the same ID, regardless of the state.
By placing this intermediary view between your content and the .matchedGeometryEffect modifier, you can keep the ID constant and manage transitions yourself.

That would mean:

  • a wrapper View that will handle the .matchedGeometryEffect with a constant ID.
  • the actual content will decide what to display based on your logic (either the list item or the expanded detail).
struct MatchedWrapperView: View {
    var content: AnyView
    var body: some View {
        content
            .matchedGeometryEffect(id: "constantID", in: namespace)
    }
}

You would then utilize this MatchedWrapperView in your main code and provide the correct content to it.

For instance:

ForEach(arr.indices) { i in
    MatchedWrapperView(content: AnyView(
        RoundedRectangle(cornerRadius: 20)
        // ... other modifiers
    ))
}

// ...

if show {
    MatchedWrapperView(content: AnyView(
        VStack{
            RoundedRectangle(cornerRadius: 20)
            // ... other modifiers
        }
    ))
}

The idea here is to make sure that .matchedGeometryEffect always sees the same ID, eliminating potential ambiguities. The actual content (either a list item or the detail view) changes, but as far as SwiftUI's layout system is concerned, the ID stays the same.

Do wrap your views with AnyView when passing them as content to MatchedWrapperView. That way, the dynamic content switching happens inside the wrapper, which should help prevent unwanted animations due to keyboard presence or other external factors.

VonC
  • 1,262,500
  • 529
  • 4,410
  • 5,250
  • Thanks this does in fact get ride of the warning but again the keyboard will now cause the element to animate back as discussed in this https://stackoverflow.com/questions/76792454/closing-keyboard-causes-matched-geometry-effect-to-execute. Your solution in this would work but due to my very large file it would need to much refactoring. So in my case I have to dynamically change the id of the element to prevent the keyboard from triggering the animation. – ahmed Aug 13 '23 at 20:11
  • @ahmed OK. I have edited the answer to address your comment. – VonC Aug 13 '23 at 20:21
  • Thanks a lot, a little off topic but what's too large for a swift ui view? I have a few files that are 800 lines long of dense code. Is that too much? Other than making it harder to read and edit/fix is there any downsides to it. Would appreciate your quick input. – ahmed Aug 14 '23 at 08:37
  • @ahmed I am not a strict line count that qualifies a SwiftUI view as "too large". What is more important is how those lines are structured and organized. If the code is modular, readable, and maintainable, then the exact line count is less of an issue. However, if you or your team feel that it is becoming challenging to work with, it might be time to consider some refactoring. – VonC Aug 14 '23 at 09:26
  • Thanks for your input. I will bounty this question for you if you'd like to look at it: https://stackoverflow.com/questions/76903444/mapview-swift-causes-warning-has-mesh-errors – ahmed Aug 15 '23 at 04:47
  • @ahmed please, no more bounty, keep your points. I am here because the questions are interesting. – VonC Aug 15 '23 at 10:37
  • 1
    Thanks ill send you my app link once its live on the App Store you helped me a lot. – ahmed Aug 16 '23 at 00:52