matteopuc / swiftui-navigation-stack

An alternative SwiftUI NavigationView implementing classic stack-based navigation giving also some more control on animations and programmatic navigation.
MIT License
926 stars 86 forks source link

Broken behavior when the view is changing during animation #55

Open ln-12 opened 3 years ago

ln-12 commented 3 years ago

First of all: thanks for that awesome library! It's so much better that what is currently available in SwiftUI!

Now to describe the actual problem: when I am navigating between static views, everything works just fine. But as soon as a view is changing, it is not animated anymore. This does not happen when the text of a Text() view is changing for example. It only occurs when a view is replaced during the transition, like when showing a ProgressView() and switching to the Text() once the content is available. The error looks like this during the animation:

EE7DE9D6-3CCE-4F02-874C-415268FB41DC

The Text() should be fully contained within the red area, but as you can see, it is located where it should be after the transition. I setup a minimal working example below. The transition is slowed down to visualize the error. The asyncAfter operation stands for some network request.

@main
struct MyApp: App {
    @StateObject private var viewModel = ViewModel()
    @StateObject var navigationStack = NavigationStack(easing: Animation.easeOut(duration: 3))

    var body: some Scene {
        WindowGroup {
            NavigationStackView(navigationStack: navigationStack) {
                VStack(alignment: .center) {
                    HStack { Spacer() }
                    Spacer()

                    Button(action: {
                        self.navigationStack.push(SecondView())
                        DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 0.2) {
                            DispatchQueue.main.sync {
                                self.viewModel.someText = "Test ### Test ### Test ### Test ### Test ### Test ###"
                            }
                        }
                    }){
                        Text("Go")
                    }

                    Spacer()
                }.background(Color.green)
            }.environmentObject(viewModel)
        }
    }
}

final class ViewModel: NSObject, ObservableObject {
    @Published var someText: String = ""
}

struct SecondView: View {
    @EnvironmentObject var viewModel: ViewModel

    var body: some View {
        VStack(alignment: .center) {
            HStack { Spacer() }
            Spacer()

            if(viewModel.someText.isEmpty) {
                ProgressView()
                    .progressViewStyle(CircularProgressViewStyle())
            } else {
                Text(viewModel.someText)
            }

            Spacer()
        }.background(Color.red)
    }
}

Is it possible to fix this? I can only load the content, after the user taps the button which starts the transition. If not, the only idea which comes to my mind is that any view updates could be delayed until the transition is finished.

matteopuc commented 3 years ago

Hi @ln-12, the problem here is due to the if-else statement in the SecondView. The transition between the ProgressView and the Text (there's always a transition in SwiftUI when you write an if-else like that) conflicts somehow with the screen transition. You can try to fix it by removing the else branch:

struct SecondView: View {
    @EnvironmentObject var viewModel: ViewModel

    var body: some View {
        VStack(alignment: .center) {
            HStack { Spacer() }
            Spacer()

            if(viewModel.someText.isEmpty) {
                ProgressView()
                    .progressViewStyle(CircularProgressViewStyle())
            }

            Text(viewModel.someText)

            Spacer()
        }
        .background(Color.red)
    }
}
ln-12 commented 3 years ago

Yeah, I already noticed that. Unfortunately, my real UI is much complexer (I have multiple else branches), so that this won't work.

So you think this is a SwiftUI problem and not specific to your library, right?

matteopuc commented 3 years ago

Well... basically yes. It depends on how SwiftUI manages the if-else transition in those cases. In your example you have two if-else transitions "overlapping" (the one involving the screens and the one involving the content of the SecondView). The second transition is not performed correctly. But it doesn't strictly depends on the navigation stack. Take a look at this simple example (it's basically your example without the navigation stack):

@main
struct MyApp: App {
    @StateObject private var viewModel = ViewModel()
    @State private var push = false
    private let transition = AnyTransition.asymmetric(insertion: .move(edge: .trailing),
                                                      removal: .move(edge: .leading))
    private let transitionAnimation = Animation.easeOut(duration: 3)

    var body: some Scene {
        WindowGroup {
            if !push {
                VStack(alignment: .center) {
                    HStack { Spacer() }
                    Spacer()

                    Button(action: {
                        push.toggle()
                        DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 0.2) {
                            DispatchQueue.main.sync {
                                self.viewModel.someText = "Test ### Test ### Test ### Test ### Test ### Test ###"
                            }
                        }
                    }){
                        Text("Go")
                    }

                    Spacer()
                }
                .background(Color.green)
                .transition(transition)
                .animation(transitionAnimation)
            } else {
                SecondView()
                    .transition(transition)
                    .animation(transitionAnimation)
                    .environmentObject(viewModel)
            }
        }
    }
}

final class ViewModel: NSObject, ObservableObject {
    @Published var someText: String = ""
}

struct SecondView: View {
    @EnvironmentObject var viewModel: ViewModel

    var body: some View {
        VStack(alignment: .center) {
            HStack { Spacer() }
            Spacer()

            if(viewModel.someText.isEmpty) {
                ProgressView()
                    .progressViewStyle(CircularProgressViewStyle())
            } else {
                Text(viewModel.someText)
            }

            Spacer()
        }.background(Color.red)
    }
}

As you can see the bug still occurs:

Jun-11-2021 18-41-46

We should try to fix the issue in this example (without the navigation stack) and then see if we can improve the navigation stack by integrating the fix directly into it.

ln-12 commented 3 years ago

I couldn't figure it out after some hours of debugging so I posted a question on StackOverflow. Hopefully, someone can figure it out as it would make your library perfect for my use case :)

ln-12 commented 3 years ago

I got the following working by using the TabView element. The big downside is that animations can't be customized, so only the default tab view page switching animation can be used. I am not sure why the transition is ignored and I can't get a working implementation of a custom TabViewStyle. It would be so much easier if Apple would open source stuff like Google does for Android (or simply provide a working native navigation).

@main
struct TransitionTestApp: App {
    @StateObject private var viewModel = ViewModel()

    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(viewModel)
        }
    }
}

struct ContentView: View {
    @EnvironmentObject var viewModel: ViewModel
    private let transition = AnyTransition.asymmetric(insertion: .move(edge: .top),
                                                      removal: .move(edge: .bottom))
    private let transitionAnimation = Animation.easeOut(duration: 3)

    var body: some View {
        VStack {
            TabView(selection: $viewModel.selectedTab) {
                FirstView()
                    .tag(0)
                    .transition(transition)
                    .animation(transitionAnimation)
                SecondView()
                    .tag(1)
                    .transition(transition)
                    .animation(transitionAnimation)
            }
            .onAppear(perform: {
               UIScrollView.appearance().bounces = false
             })
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
            .transition(transition)
            .animation(transitionAnimation)
        }
    }
}

struct FirstView: View {
    @EnvironmentObject var viewModel: ViewModel

    var body: some View {
        VStack(alignment: .center) {
            HStack { Spacer() }
            Spacer()

            Button(action: {
                withAnimation {
                    viewModel.selectedTab += 1
                }
                DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 0.2) {
                    DispatchQueue.main.sync {
                        self.viewModel.someText = "Test ### Test ### Test ### Test ### Test ### Test ###"
                    }
                }
            }){
                Text("Go")
            }

            Spacer()
        }
        .background(Color.green)
    }
}

final class ViewModel: NSObject, ObservableObject {
    @Published var someText: String = ""
    @Published var selectedTab = 0
}

struct SecondView: View {
    @EnvironmentObject var viewModel: ViewModel

    var body: some View {
        VStack(alignment: .center) {
            HStack { Spacer() }
            Spacer()

            if(viewModel.someText.isEmpty) {
                ProgressView()
                    .progressViewStyle(CircularProgressViewStyle())
            } else {
                Text(viewModel.someText)
            }

            Spacer()
        }.background(Color.red)
    }
}
ln-12 commented 3 years ago

Using the SwiftUIPager library, it works as intended! I don't know why the content is looking correct in this library, but not in yours. Maybe you can have a look what they are doing different.

import SwiftUI
import SwiftUIPager

@main
struct TransitionTestApp: App {
    @StateObject private var viewModel = ViewModel()

    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(viewModel)
        }
    }
}

struct ContentView: View {
    @EnvironmentObject var viewModel: ViewModel

    var items = Array(0..<2)

    var body: some View {
        Pager(
            page: viewModel.page,
            data: items,
            id: \.self
        ) { index in
            if(index == 0) {
                FirstView()
            } else if(index == 1) {
                SecondView()
            }
        }
        .bounces(false)
        .draggingAnimation(.standard)
    }
}

struct FirstView: View {
    @EnvironmentObject var viewModel: ViewModel

    private let transitionAnimation = Animation.easeOut(duration: 3)

    var body: some View {
        VStack(alignment: .center) {
            HStack { Spacer() }
            Spacer()

            Button(action: {
                withAnimation(transitionAnimation) {
                    viewModel.page.update(.next)
                }
                DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 0.2) {
                    DispatchQueue.main.sync {
                        self.viewModel.someText = "Test ### Test ### Test ### Test ### Test ### Test ###"
                    }
                }
            }){
                Text("Go")
            }

            Spacer()
        }
        .background(Color.green)
    }
}

final class ViewModel: NSObject, ObservableObject {
    @Published var someText: String = ""
    @Published var page: Page = .first()
}

struct SecondView: View {
    @EnvironmentObject var viewModel: ViewModel

    var body: some View {
        VStack(alignment: .center) {
            HStack { Spacer() }
            Spacer()

            if(viewModel.someText.isEmpty) {
                ProgressView()
                    .progressViewStyle(CircularProgressViewStyle())
            } else {
                Text(viewModel.someText)
            }

            Spacer()
        }.background(Color.red)
    }
}

With this approach you also get the "swipe to go back" behavior discussed in issue #8

ln-12 commented 3 years ago

I did a quick implementation with your library here: https://github.com/ln-12/swiftui-navigation-stack/tree/animation-fix

With this approach, you have to set your rootView like this:

NavigationStackView(navigationStack: self.navigationStack) {}
.onAppear(perform: {
    self.navigationStack.push(RootView())
})

With that, I get the right push animations in all cases and also have the "swipe to go back" gesture. It's far from perfect, other animations might not work, but it is exactly want I was looking for. Maybe you find a way to properly integrate it or it might just help someone else who is looking for this functionality. You can also tell me how you would integrate it so that I can create a pull request :)