1

I have a scroll view that includes a bunch of elements and when an element in the scroll view is clicked it expands using a matched geometry effect and goes to the top of the screen (creates a new view over the previous).

And now in this new view, if I close the keyboard, it causes the element at the top of the screen to return to its previous position in the scroll view.

I tried adding a modifier "ignoreSafeArea(.keyboard)", but this didn't work.

I think when I run the code to close the keyboard it causes the view to update and then the element is animated back.
Also, what is weird is if the element is higher up on the screen (over where the keyboard is) then this issue doesn't happen.
This only occurs when the element is lower on the screen.

Does anyone know how to fix this?

import SwiftUI

struct NewsReplyView: View {
    @Namespace var namespace
    @State var text: String = ""
    @State var selected: String = ""
    @State var show = 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]
                            }
                        }
                        .matchedGeometryEffect(id: selected, in: namespace)
                }
            }.opacity(show ? 0.0 : 1.0)
            if show {
                VStack{
                    RoundedRectangle(cornerRadius: 20).frame(width: 100, height: 100)
                        .id(selected)
                        .matchedGeometryEffect(id: selected, in: namespace)
                    Spacer()
                    TextField("", text: $text)
                        .onSubmit {
                            UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
                            // code to close keyboard, after this the news view moves back
                        }
                }
                
            }
        }
    }
}
HangarRash
  • 7,314
  • 5
  • 5
  • 32
ahmed
  • 341
  • 2
  • 9

3 Answers3

1

The workaround I found is adding an additional bool var to represent when the view fully finishes animating. And when the view finishes animating (loaded = true) the ids switch so that the geometry effect has no way of animating back (prevent back animation when keyboard dismisses). And when the user is done with the screen, loaded is flipped to false (also flipping the ids back so they're equal not "A" and "B") and the view dismisses properly. The only issue with this is that I receive a 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."

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
                                }
                            }
                        }
                        .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)
                        .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")
                            }
                        }
                    }
                }
            }
        }
    }
}
ahmed
  • 341
  • 2
  • 9
  • Well done, that should work too. I have edited my answer to compare and contrast it with yours. – VonC Jul 29 '23 at 20:22
0

I see the matchedGeometryEffect can be used for synchronizing animations from one view to another (as illustrated in this article by Paul Hudson)

The problem is, when the keyboard appears on the screen, it changes the size of the "safe area" – the area of the screen in which it is safe to display content without it being obscured by system UI like the status bar, the home indicator, or in this case, the keyboard. SwiftUI uses the safe area to lay out your views, and when it changes, SwiftUI will recalculate your view layout.

I tried adding a modifier "ignoreSafeArea(.keyboad)", but this didn't work.

Adding .ignoreSafeArea(.keyboard) to a view causes the view to ignore the safe area insets that the keyboard introduces, meaning the view will extend under the keyboard. See also "How to access safe area size in SwiftUI?", and, for illustration of the "inset" concept "Mastering Safe Area in SwiftUI" by fatbobman (东坡肘子).
However, it does not prevent the view hierarchy from being recalculated when the keyboard appears or disappears. That means that even with .ignoreSafeArea(.keyboard), SwiftUI will still recompute the view hierarchy when the keyboard appears or disappears, which can still lead to unintended side effects, like restarting animations.

The matchedGeometryEffect in particular can be sensitive to these recalculations, as it is used to create a seamless transition between two views by interpolating between their frames during an animation. When a recalculation happens mid-transition (like when the keyboard disappears), it can cause the transition to restart or behave unexpectedly.

To control the behavior of the matchedGeometryEffect more directly, you would need to manage the lifecycle of the transition itself.


A possible solution for you to test would be to manually control the lifecycle of the transition.

In your original code, you have a single SwiftUI View, NewsReplyView, which is responsible for displaying both the list of items and the detailed view of a selected item (when show was true). The detailed view is contained within an if show { ... } block and included the larger RoundedRectangle and the TextField.

You could encapsulate your detail view within another view and control its presentation with a conditional statement. This way, you can ensure that the transition only occurs when it should.

That means the DetailView is a separate SwiftUI View that encapsulates the detailed view of a selected item.
Instead of having the detailed view be part of the NewsReplyView's body and controlled by the show state variable, it is now its own standalone View.

DetailView takes in the Namespace.ID, the selected item ID, the text binding, and a dismissAction closure as parameters. That means you can create a DetailView when an item is selected, and it will manage its own presentation and dismissal.

struct DetailView: View {
    var namespace: Namespace.ID
    var selected: String
    @Binding var text: String
    var dismissAction: ()->Void
    // ...
}

In the NewsReplyView, the if show { ... } block creates and presents a DetailView when an item is selected (when show is true).

if show {
    DetailView(namespace: namespace, selectedItem: $selected, text: $text, show: $show)
        .transition(.move(edge: .bottom)) 
        .zIndex(1) 
}

When the TextField in DetailView is submitted (i.e., when the user presses return on the keyboard), it sets show to false, which causes the DetailView to be removed from the view hierarchy.

That would be:

import SwiftUI

struct NewsReplyView: View {
    @Namespace var namespace
    @State var text: String = ""
    @State var selected: String? = nil
    @State var arr = ["1", "2", "3", "4", "5"]
    var body: some View {
        ZStack {
            ScrollView {
                VStack {
                    ForEach(arr.indices, id: \.self) { i in
                        RoundedRectangle(cornerRadius: 20).frame(width: 100, height: 100)
                            .id(arr[i])
                            .onTapGesture {
                                withAnimation(.spring()){
                                    selected = arr[i]
                                }
                            }
                            .matchedGeometryEffect(id: arr[i], in: namespace)
                    }
                }
            }.zIndex(1)

            if let selected = selected {
                DetailView(namespace: namespace, selected: selected, text: $text) {
                    withAnimation(.spring()) {
                        self.selected = nil
                    }
                }.zIndex(2)
            }
        }
    }
}

struct DetailView: View {
    var namespace: Namespace.ID
    var selected: String
    @Binding var text: String
    var dismissAction: ()->Void
    var body: some View {
        VStack{
            RoundedRectangle(cornerRadius: 20).frame(width: 100, height: 100)
                .id(selected)
                .matchedGeometryEffect(id: selected, in: namespace)
            Spacer()
            TextField("", text: $text)
                .onSubmit {
                    UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
                    dismissAction()
                }
        }
    }
}

That approach (above) differs from the OP's solution: instead of encapsulating the detail view in a separate view, it uses a loaded state variable to determine when the animation to the detail view has completed.

When loaded is true, the IDs for the matchedGeometryEffect are set to different values ("A" and "B") so that SwiftUI does not try to animate the view back to its original position in the list when the keyboard is dismissed.

  • I proposed: Encapsulating the detail view in a separate view: That solution separates the concerns of displaying the list of items and handling the detailed view, which can help avoid the issues you were facing.
    It can be considered a more 'clean' or 'Swifty' solution, as it respects SwiftUI's declarative nature and results in a clearer separation of responsibilities between different parts of the UI.
    However, it might require a bit more refactoring if you have a lot of existing code in your detail view.

  • Using an additional loaded state variable (the OP's solution): That solution avoids refactoring the detail view into a separate view, which could make it easier to integrate with existing code.
    However, it uses a fixed delay (DispatchQueue.main.asyncAfter) to determine when the animation has finished, which might not always be accurate (e.g., if the device is slow or if the animation duration changes).
    It also results in a warning about multiple source views in a matched geometry group, which could potentially cause unpredictable behavior.

VonC
  • 1,262,500
  • 529
  • 4,410
  • 5,250
  • Thanks so much, your explanation was perfect. I understand what was happening now and I was able to fix it with my solution below. Can you have a look and see if it is a sound approach. Im also modifying my approach to fit your solution in order to get ride of the warning I receive. – ahmed Jul 29 '23 at 20:08
  • Thanks for your help. I will be submitting my app to the App Store soon so maybe you can find a solution for the warning I was getting, also it's bountied. Thanks.: https://stackoverflow.com/questions/76795863/changing-element-id-after-animation-causes-error-multiple-inserted-views-in-mat. – ahmed Aug 13 '23 at 06:55
  • @ahmed Sorry for the delay. Do not think you have to put a bounty: I would answer any of your question if I can, bounty or not. – VonC Aug 13 '23 at 18:17
-3

This dilemma is a frequent one that pertains to the manner SwiftUI manages keyboard avoidance and its impact on the UI layout. When the keyboard surfaces, it modifies the safe area and potentially diminishes the overall area available for your view, which can incite your view to re-layout. When the keyboard vanishes, the opposite happens, which can also trigger a re-layout.

In this scenario, the SwiftUI View is redrawing due to the keyboard altering the safe area, and in SwiftUI, anything that prompts the view to redraw can also incite animations to restart or to end prematurely.

To circumnavigate this issue, you could incorporate a separate property to track whether the keyboard is presently visible or not. This property will not influence your UI layout directly but will permit you to prevent the matched geometry effect from launching when the keyboard is dismissed.

Here's how you could adjust your code:

import SwiftUI
import Combine

struct NewsReplyView: View {
    @Namespace var namespace
    @State var text: String = ""
    @State var selected: String = ""
    @State var show = false
    @State var isKeyboardVisible = false // new state property
    @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]
                            }
                        }
                        .matchedGeometryEffect(id: isKeyboardVisible ? nil : selected, in: namespace)
                }
            }.opacity(show ? 0.0 : 1.0)
            
            if show {
                VStack{
                    RoundedRectangle(cornerRadius: 20).frame(width: 100, height: 100)
                        .id(selected)
                        .matchedGeometryEffect(id: isKeyboardVisible ? nil : selected, in: namespace)
                    Spacer()
                    TextField("", text: $text)
                        .onSubmit {
                            UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
                            isKeyboardVisible = false // update keyboard visibility state
                        }
                        .onAppear {
                            isKeyboardVisible = true // update keyboard visibility state
                        }
                }
            }
        }
    }
}

In this, the isKeyboardVisible state property is updated within the onAppear and onSubmit callbacks of the TextField. Whilst the keyboard is visible, the matchedGeometryEffect's ID is assigned to nil, effectively disabling the effect until the keyboard is dismissed.

This method, you prevent the matched geometry effect from being interrupted when the keyboard is dismissed, and the animation should work as anticipated. Please take into account that you need to adjust this code to fit into your existing code base as it might necessitate some additional changes based on your existing implementation.

  • 1
    Hello this does not work. This makes it worse actually anytime the keyboard is used the view fails and the element returns to its previous position. – ahmed Jul 29 '23 at 18:21