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.