Search code examples
swiftuiswiftui-navigationstackswiftui-animationswiftui-matchedgeometryeffect

Animation problem when using Namespace and MatchedGeometryEffect


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


Solution

  • 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:

    • Instead of one bar per label, just have one bar for the whole HStack, which is always visible.
    • The labels are used as the source for the .matchedGeometryEffect, so they have isSource: true.
    • Each label uses its index value as the id for the matched geometry effect.
    • The bar has isSource: false.
    • The bar is matched to the geometry of the selected label by using selection as id to match to.
    • Move the .animation modifier from the Text to the bar.
    • The 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).