I've caught a very strange problem (THAT ONLY OCCURS ON REAL DEVICES) that has to do with using @NameSpace and MatchedGeometryEffect using NavigationStack. The problem is that if I go from the first screen to the second screen (on the second screen I have a View with MatchedGeometryEffect) , I get a strange bug where the MatchedGeometryEffect animation starts to work very badly (ONLY ON REAL DEVICE), it feels like the animation frame count drops to a minimum, but on simulator or preview everything works fine as it should. However, if I use the screen without NavigationStack, there is no such animation problem. What can this be related to ? And how can this be fixed ? It only takes a couple of lines of code to catch the animation problem, but it took all day to figure out what the problem is.
FirstView
struct NameSpaceTest2Navigation: View {
@State private var nameSpaceTest2Path: [String] = []
var body: some View {
NavigationStack(path: $nameSpaceTest2Path) {
Button(action: {
nameSpaceTest2Path.append("nameSpaceTest2")
}, label: {
Text("Button")
})
.navigationDestination(for: String.self) { path in
NameSpaceTest2()
}
}
}
}
SecondView
struct NameSpaceTest2: View {
@State private var selection: Int = 0
var body: some View {
TabView(selection: $selection) {
ForEach(0..<5, id: \.self) { _ in
Color.white
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.overlay(alignment: .top) {
NameSpaceTest2Header(selection: $selection)
}
}
}
ThirdView
struct NameSpaceTest2Header: View {
@Binding var selection: Int
@Namespace var sectionUnderline
var body: some View {
ScrollViewReader { scrollReader in //START: ScrollViewReader
ScrollView(.horizontal, showsIndicators: false) { //START: ScrollView
HStack(spacing: 0) { //START: HStack
ForEach(0..<5, id: \.self) { index in
VStack { //START: VStack
Text("Name: \(index)")
.foregroundStyle(Color.green)
.padding(.horizontal, 24)
.padding(.vertical, 15)
.overlay(alignment: .bottom) {
if selection == index {
Rectangle()
.fill(Color.red)
.frame(height: 5)
.matchedGeometryEffect(id: "sectionUnderline", in: sectionUnderline, properties: .frame)
.transition(.scale(scale: 1))
}
}
.animation(.smooth, value: selection)
} //END: VStack
.onTapGesture {
withAnimation(.smooth) {
scrollReader.scrollTo(index)
selection = index
}
}
.tag(index)
}
} //END: HStack
} //END: ScrollView
.onChange(of: selection, perform: { value in
withAnimation(.smooth) {
scrollReader.scrollTo(value, anchor: .center)
}
})
} //END: ScrollViewReader
}
}
Try to repeat this code and run it on a real device with NavigationStack and you will get a bug with the animation, it will twitch like it has 5-10 frames per second.
Then try to run Second View without Navigation Stack and you will get smooth animation, which is what it should be.
What could be the problem ? How to get the smooth animation back ?
Once again, you should only test on a real device.
I tried wrapping selection = index in DispacthQueue.main.ascync , but it didn't work. I tried changing the animation, but that didn't work either. If I set the animation, I can see how the Rectangle changes size almost frame by frame. Checked the performance in Instruments, nothing highly loaded is happening. Didn't expect to have a problem with this. Can't imagine what the problem could be, I expected the animation to be smooth, just like on the simulator
I wasn't able to reproduce the issue with the stuttering animation, your code works for me even on a real device (iPhone 8 running iOS 16).
Anyway, I would suggest the following changes:
HStack
, which is always visible..matchedGeometryEffect
, so they have isSource: true
.isSource: false
.selection
as id to match to..animation
modifier from the Text
to the bar.VStack
enclosing each label is redundant and can be dropped.All the changes concern the content of the horizontal ScrollView
in NameSpaceTest2Header
:
// NameSpaceTest2Header
HStack(spacing: 0) { //START: HStack
ForEach(0..<5, id: \.self) { index in
Text("Name: \(index)")
.foregroundStyle(.green)
.padding(.horizontal, 24)
.padding(.vertical, 15)
.matchedGeometryEffect(id: index, in: sectionUnderline, isSource: true)
.onTapGesture {
withAnimation(.smooth) {
scrollReader.scrollTo(index)
selection = index
}
}
.tag(index)
}
} //END: HStack
.overlay(alignment: .bottom) {
Rectangle()
.fill(.red)
.frame(height: 5)
.matchedGeometryEffect(id: selection, in: sectionUnderline, isSource: false)
.animation(.smooth, value: selection)
}
It is conceivable that the problem is happening because a TabView
is normally used as the parent for a NavigationStack
and not the other way around. In your case, you are using a paged TabView
, so if the changes above don't help and the problem still persists, you could try changing the TabView
to a paged ScrollView
. This involves using scrollTargetLayout
, scrollPosition
and scrollTargetBehavior(.paging)
. The answer to SwiftUI - TabView Safe Area shows an example.
BTW, you might like to take a look at the answer to How can I dynamically select things using ScrollView? (which was also my answer). This uses a similar selection marker, but with the difference that if you scroll the labels at the top, the selection automatically switches, to prevent the bar from disappearing off screen. This automatic switching is missing in your version - if you scroll the labels, the bar can be scrolled off screen (but maybe you don't mind).