I've run in to an issues on devices running the 14.5 beta with a couple of production apps (which use TCA) around views being unexpectedly popped from the nav stack.
I've published a repo with an overly simplified implementation that illustrates the problem.
In this example, immediately after pushing Text("Pushed"), it gets popped. This only happens when more than one NavigiationLink is declared on the pushing view (SecondView) AND state is changed. In this example I'm changing the state in an onAppear {} handler by setting loading.
This is definitely something new in 14.5 and I'm concerned something has changed in SwiftUI that TCA may not be handling correctly.
The only hint in the release notes that I could find was this:
The destination of NavigationLink that only differs by local state now resets that state when switching between links as expected. (72117345)
Please have a look at the example project and lmk if anything stands out as something that could be improved.
I came across this issue on the TCA repo, which is very similar to what I'm seeing.
The github issue suggests the problem occurs when there are exactly 2 NavigationLinks on the pushing view. This holds true in both my production apps and my test project.
My workaround for the time being is to add this to any view that has 2 navlinks:
This seems absolutely crazy and for sure points to a bug in SwiftUI. Definitely hope it is addressed before 14.5 releases.
Nevertheless I am curious why this is happening. Is it related to 72117345 from the release notes? I don't really understand enough about that issue to say.
After reading you topic I downloaded the beta which broke my app. Thanks for the "fix".
This whole navigationLink thing definitely had issue since the beginning of SwiftUI, I hope Apple will fix this I'd rather not have this everywhere in the app.
I tried for a couple hours yesterday but I didn't manage to reproduce the issue without TCA. I'll give it another shot soon. From what I've tried, I think the issue lies into TCA.
Hi,
I am facing the same issue in my without using TCA. It seems like it's still not fixed in iOS 14.5 RC
The workaround of adding an empty NavigationView works for me as well. I would have preferred see it fixed in SwiftUI though as this seems a bit hacky.
There were some problems in the past isActive bindings were incorrectly updated in NavigationLinks when pushing a screen on a screen and StackNavigationViewStyle mostly fixed this.
My issues persist too without the empty navigation trick, using StackNavigationViewStyle as well.
Strangely I have one place where the empty navigation trick didn't work.
I was dispatching multiple commands (pop, switch tab, push). I solved the issue by delaying each step.
I've been unable to reproduce it in a simple vanilla SwiftUI app.
For people experiencing this bug, are you triggering some state change that causes the original view (the one with the navigation links) to re-calculate it's view body? Perhaps the links are inside a WithViewStore whose store is triggering a body change on the WithViewStore view?
I'm seeing the same issue on simulator and device (Not using TCA).
For me it happens when my view has exactly two destinations so was able to fix it temporarily making sure I have at least three destinations.
Here's the simplest example I could manage that illustrates the bug. You'll notice that when navigating to screen 3 the view pops off as soon as it's presented
enum Screen: Hashable {
case second
case third
}
enum MyAction {
case selectScreen(Screen?)
}
struct MyState: Equatable {
var selectedScreen: Screen? = nil
}
struct Environment {}
let myReducer = Reducer<MyState, MyAction, Environment> { state, action, environment in
switch action {
case .selectScreen(let screen):
state.selectedScreen = screen
return .none
}
}
struct FirstScreen: View {
let store: Store<MyState, MyAction>
public var body: some View {
WithViewStore(self.store) { viewStore in
NavigationView {
VStack {
Button("Go to screen 2") {
viewStore.send(.selectScreen(.second))
}
NavigationLink(
destination: SecondScreen(store: store),
tag: Screen.second,
selection: viewStore.binding(get: \.selectedScreen, send: .selectScreen(.second)),
label: {
EmptyView()
}
)
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
struct SecondScreen: View {
let store: Store<MyState, MyAction>
public var body: some View {
WithViewStore(self.store) { viewStore in
VStack {
Button("Go to screen 3") {
viewStore.send(.selectScreen(.third))
}
NavigationLink(
destination: Text("Third Screen"),
tag: Screen.third,
selection: viewStore.binding(get: \.selectedScreen, send: .selectScreen(.third)),
label: {
EmptyView()
}
)
}
}
}
}
Here's a working example of the same navigation just using local state. The pop doesn't happen when navigating to the third screen
struct FirstScreen: View {
@State var secondScreenActive: Bool = false
public var body: some View {
NavigationView {
VStack {
Button("Go to screen 2") {
secondScreenActive = true
}
NavigationLink(
destination: SecondScreen(),
isActive: $secondScreenActive,
label: {
EmptyView()
}
)
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct SecondScreen: View {
@State var thirdScreenActive: Bool = false
public var body: some View {
VStack {
Button("Go to screen 3") {
thirdScreenActive = true
}
NavigationLink(
destination: Text("Third Screen"),
isActive: $thirdScreenActive,
label: {}
)
}
}
}
Based on the example given I’d expect the behaviour you’re describing because you’re trying to bind two navigation links to the same piece of state. As soon as you try and navigate to screen three you’re going to trigger a pop on the original link as it’s state has now changed. This does not seem like a bug to me.
You don’t see this issue with the second example because you’re not sharing state.
Interesting, thanks for the feedback on that. I refactored the example to use discrete state for each screen and you're right, I don't see the pop happening. I'll try to retrofit this into my project to see if it fixes my issue
enum MyAction {
case setSecondScreenVisible(Bool)
case setThirdScreenVisible(Bool)
}
struct MyState: Equatable {
var isSecondScreenVisible: Bool = false
var isThirdScreenVisible: Bool = false
}
struct Environment {}
let myReducer = Reducer<MyState, MyAction, Environment> { state, action, environment in
switch action {
case .setSecondScreenVisible(let isVisible):
state.isSecondScreenVisible = isVisible
return .none
case .setThirdScreenVisible(let isVisible):
state.isThirdScreenVisible = isVisible
return .none
}
}
struct FirstScreen: View {
let store: Store<MyState, MyAction>
public var body: some View {
WithViewStore(self.store) { viewStore in
NavigationView {
VStack {
Button("Go to screen 2") {
viewStore.send(.setSecondScreenVisible(true))
}
NavigationLink(
destination: SecondScreen(store: store),
isActive: viewStore.binding(get: \.isSecondScreenVisible, send: MyAction.setSecondScreenVisible(_:)),
label: {
EmptyView()
}
)
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
struct SecondScreen: View {
let store: Store<MyState, MyAction>
public var body: some View {
WithViewStore(self.store) { viewStore in
VStack {
Button("Go to screen 3") {
viewStore.send(.setThirdScreenVisible(true))
}
NavigationLink(
destination: Text(verbatim: "Third Screen"),
isActive: viewStore.binding(get: \.isThirdScreenVisible, send: MyAction.setThirdScreenVisible(_:)),
label: {
EmptyView()
}
)
}
}
}
}
A quick follow up as I've totally solved the problem on my project:
in my case I had 3 onboarding screens, all sharing OnboardingState, OnboardingActions. A navigation stack might look like
WelcomeScreen > LoginScreen > TwoFactorScreen
When on the TwoFactorScreen, any change to the onboarding state would cause a re-render in both the Login and Welcome Screens. During this re-render, the NavigationLinks would be either nilled, or read with the incorrect value (still not entirely sure which). This would lead to the TwoFactorScreen to be popped from the navigation stack and I would be taken back to the login screen.
To solve the issue I had to split any state in each of the screens to ensure that nothing was being shared, ie
public var isLoginVisible: Bool = false
public var isTwoFactorVisible: Bool = false
public var isLoginRequestInProgress: Bool = false
public var isTwoFactorRequestInProgress: Bool = false
And I also needed to scope my viewStores to only the state that the individual Screen cared about, ie
let store: Store<OnboardingState, OnboardingAction>
@ObservedObject var viewStore: ViewStore<ViewState, OnboardingAction>
struct ViewState: Equatable {
var email: String
var password: String
var isTwoFactorVisible: Bool
var requestInProgress: Bool
init(state: OnboardingState) {
email = state.email
password = state.password
isTwoFactorVisible = state.isTwoFactorVisible
requestInProgress = state.isLoginRequestInProgress
}
}
public init(store: Store<OnboardingState, OnboardingAction>) {
self.store = store
self.viewStore = ViewStore(self.store.scope(state: ViewState.init))
}
Hope that helps others in this thread. Not sure why this just started coming up in iOS 14.5, but this seems to be the solution
Glad you sorted it - some advice from somebody who has literally just implemented a near identical flow (Landing -> Login -> Maybe TFA) - it can be helpful to divide up your state into discrete domains, so your initial root state might be:
struct AppState: Equatable {
var loginState: LoginState?
}
enum AppAction {
case tappedStartLoginButton // reducer will initialise `LoginState`
}
You can use the presence of the login state to drive the navigation to the login screen.
Then you might have:
struct LoginState: Equatable {
var twoFactorAuthState: TwoFactorAuthState?
}
Breaking your state up in this way makes it much easier to reason about discrete parts of your state, especially through a navigation hierarchy like this and will help avoid over-rendering your views.