SwiftUI navigation pop-to-root and TCA optional reducers - nondeterministic behavior

I am trying to implement a SwiftUI application with NavigationView and an option to dismiss to the root view using Swift Composable Architecure.

FirstView → present SecondView → present ThirdView → dismiss to FirstView

State of the FirstView contains an optional state of the SecondView etc. Because of it, I am using the .optional() operator when combining reducers.

When dismissing from ThirdView to FirstView, I noticed a nondeterministic behavior, where sometimes the assertion in the .optional() operator fails, and sometimes not.

I need help with figuring out what is causing the issue. Any hints on how to implement the "dismiss to root" action would be appreciated.

I am using Xcode 12.2 with Swift 5.3.1, iOS 14.2 and Swift Composable Architecure 0.9.0.

Here is the exact code that I am using (copy&paste in Xcode, and it should run):

import ComposableArchitecture
import SwiftUI

@main
struct DemoApp: App {
  var body: some Scene {
    WindowGroup {
      NavigationView {
        FirstView(store: Store(
          initialState: FirstState(),
          reducer: firstReducer,
          environment: ()
        ))
      }
      .navigationViewStyle(StackNavigationViewStyle())
    }
  }
}

// MARK: - First

struct FirstState: Equatable {
  var second: SecondState?
}

enum FirstAction: Equatable {
  case presentSecond
  case dismissSecond
  case second(SecondAction)
}

let firstReducer = Reducer<FirstState, FirstAction, Void>.combine(
  secondReducer.optional().pullback(
    state: \.second,
    action: /FirstAction.second,
    environment: { _ in () }
  ),
  Reducer { state, action, _ in
    switch action {
    case .presentSecond:
      state.second = SecondState()
      return .none

    case .dismissSecond:
      state.second = nil
      return .none

    case .second(.third(.dismissToFirst)):
      return .init(value: .dismissSecond)

    case .second:
      return .none
    }
  }
)

struct FirstViewState: Equatable {
  let isPresentingSecond: Bool

  init(state: FirstState) {
    isPresentingSecond = state.second != nil
  }
}

struct FirstView: View {
  let store: Store<FirstState, FirstAction>

  var body: some View {
    WithViewStore(store.scope(state: FirstViewState.init(state:))) { viewStore in
      VStack {
        NavigationLink(
          destination: IfLetStore(
            store.scope(
              state: \.second,
              action: FirstAction.second
            ),
            then: SecondView.init(store:)
          ),
          isActive: viewStore.binding(
            get: \.isPresentingSecond,
            send: { $0 ? .presentSecond : .dismissSecond }
          ),
          label: { Text("Present Second") }
        )
      }
      .navigationTitle("First")
      .navigationBarTitleDisplayMode(.inline)
    }
  }
}

// MARK: - Second

struct SecondState: Equatable {
  var third: ThirdState?
}

enum SecondAction: Equatable {
  case presentThird
  case dismissThird
  case third(ThirdAction)
}

let secondReducer = Reducer<SecondState, SecondAction, Void>.combine(
  thirdReducer.optional().pullback(
    state: \.third,
    action: /SecondAction.third,
    environment: { _ in () }
  ),
  Reducer { state, action, _ in
    switch action {
    case .presentThird:
      state.third = ThirdState()
      return .none

    case .dismissThird:
      state.third = nil
      return .none

    case .third:
      return .none
    }
  }
)

struct SecondViewState: Equatable {
  let isPresentingThird: Bool

  init(state: SecondState) {
    isPresentingThird = state.third != nil
  }
}

struct SecondView: View {
  let store: Store<SecondState, SecondAction>

  var body: some View {
    WithViewStore(store.scope(state: SecondViewState.init(state:))) { viewStore in
      VStack {
        NavigationLink(
          destination: IfLetStore(
            store.scope(
              state: \.third,
              action: SecondAction.third
            ),
            then: ThirdView.init(store:)
          ),
          isActive: viewStore.binding(
            get: \.isPresentingThird,
            send: { $0 ? .presentThird : .dismissThird }
          ),
          label: { Text("Present Third") }
        )
      }
      .navigationTitle("Second")
      .navigationBarTitleDisplayMode(.inline)
    }
  }
}

// MARK: - Third

struct ThirdState: Equatable {}

enum ThirdAction: Equatable {
  case dismissToFirst
}

let thirdReducer = Reducer<ThirdState, ThirdAction, Void> { state, action, _ in
  switch action {
  case .dismissToFirst:
    return .none
  }
}

struct ThirdViewState: Equatable {
  init(state: ThirdState) {}
}

struct ThirdView: View {
  let store: Store<ThirdState, ThirdAction>

  var body: some View {
    WithViewStore(store.scope(state: ThirdViewState.init(state:))) { viewStore in
      VStack {
        Button(action: { viewStore.send(.dismissToFirst) }) {
          Text("Dismiss to First")
        }
      }
      .navigationTitle("Third")
      .navigationBarTitleDisplayMode(.inline)
    }
  }
}

I had some time to investigate the problem, and I found a solution. I have created a demo project that shows how navigation with pop-to-root can be implemented when using Composable Architecture.

The whole concept relies on separating the state of the view that is being presented from the state that determines if the corresponding SwiftUI NavigationLink is active. In addition, I have included the logic responsible for handling long-living effects on views that are being presented on the stack in the demo app. I hope this will help someone when struggling with similar issues.

@stephencelis @mbrandonw please let me know what do you think about my solution.

6 Likes

Thank you for the project, I was facing the same issue with NavigationView your comment and project really saved my life.

Great Work.

1 Like

This does not work if the NavigationLink is inside a List or ForEach view.

      List {
        ForEach(0 ..< 1) { idx in
          NavigationLink(
            destination: IfLetStore(
              store.scope(
                state: \.second,
                action: FirstAction.second
              ),
              then: SecondView.init(store:)
            ),
            isActive: viewStore.binding(get: \.isPresentingSecond, send: FirstAction.presentSecond),
            label: {
              Text("Present Second")
                .padding()
            }
          )
        }
      }

Any ideas?

It won't work as you are using one variable "isPresentingSecond" for all the different cases in the List.

Hmh, maybe I don't understand. I was just showing a maybe too simple modification of @darrarski's code which is not using anything from the list. The code is not meant to be perfect. But this will show a list with one item. If selected it will show SecondView and the ThirdView. If I press "Dismiss to First" nothing happens.

There are no different cases in the list. We could have a list showing items of the list embedded in NavigationLinks. The SecondView could e.g. show the index of the item or the name of the item, but it is always a SecondView. So regardless which row in the list the user selected at some time he will want to "Dismiss to First".

So my question (also to the original poster @darrarski, who's work on this topic is very much appreciated): How is this functionality achieved with TCA when the Navigation is embedded in a List/ForEach?

@baldvader First sight at your code tells me that it should work. It's hard to tell just from the fragment of the app what could be wrong. Please check if you have composed the reducers correctly (using optional and pullback operators). You can try to debug the app by applying debug operator to your reducer, and observe console to see if the actions are handled and state mutated.

As @PabloDev noticed, in the mentioned example code there is only a single, shared presentation for all the items in the list. Perhaps this is causing a problem. Assuming the list displays two rows - it's like having two NavigationLinks driven by the same state. This could confuse SwiftUI with what should be presented when (you can't have two NavigationLinks, one next to another, which are active at the same time). So, perhaps it does not matter if your list has one or many items.

1 Like

@darrarski Thank you for responding. The code fragment postet by me is just the exact code from your tca-swiftui-navigation-demo with First.swift modified with

      List {
        ForEach(0 ..< 1) { idx in

and

        }
      }

around the NavigationLink part. So you could give it a shot. Of course this list implementation is senseless, but it shows the problem.
So the handling of pullback and optional is just your app's.

I see. I don't have time to deeply investigate the issue at the moment, but I think it's connected to having a NavigationLink inside the List (I don't think it's connected to Composable Architecture or my example, I think it's a pure SwiftUI issue). As a workaround you can do it like this:

List {
  ForEach(0..<1) { idx in
    Button(action: {
      viewStore.send(.presentSecond(true))
    }, label: {
      Text("Present Second")
        .padding()
    })
  }
}

NavigationLink(
  destination: IfLetStore(
    store.scope(
      state: \.second,
      action: FirstAction.second
    ),
    then: SecondView.init(store:)
  ),
  isActive: viewStore.binding(get: \.isPresentingSecond, send: FirstAction.presentSecond),
  label: EmptyView.init
)

It should work as expected. Please notice that the only change here (comparing to your code) is that I extracted the NavigationLink, so it's no longer embedded inside the List.

Terms of Service

Privacy Policy

Cookie Policy