Programatic dismiss navigation animation based on State binding

Hi there! This post is not really a question, but more about curiosity and perception of a "problem". Maybe such a question would lead to the enhancement of TCA :)

Foreword:
In the new fabulous world of SwiftUI and TCA-like architectures in iOS development State is everything. Your App is a State. Any View in it is just a graphic representation of its state, which means that any change to a State causes re-rendering of a View. And vice versa - any changes/interactions with a view would cause state mutation. TCA was created with this postulate in mind.

The problem:
Imagine that we have 2 views (screens) in one NavigationView - second screen is shown after first one in a stack. And here is a "non-trivial" task - dismiss shown 2d view programmatically (get back to the 1st one), for e.g. native navigation bar is hidden, so no back button and we need to show custom one OR view should be dismissed by timer etc.

Looks like as easy peasy task according State development paradigm:

  • Create and store a state
  • View reacts on it by showing a view for this state
  • Remove a state (state = nil)
  • View reacts on it by dismissing a view since the state is no longer exist

Unfortunately, it's not so obvious in TCA. But why? Let's move to the concrete examples using TCA and take the NavigateAndLoad case study as an example (I've removed Activity Indicator view and delay functionality)

Refresh NavigateAndLoad screen in memory

And let's add programmatic Close feature to the Counter screen

enum CounterAction: Equatable {
....
  case close
}

let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, _ in
....
  case .close:
    return .none
  }
}

struct CounterView: View {
  let store: Store<CounterState, CounterAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      ZStack {
      Color(.red)
      HStack {
         ....
        **Button("Close") { viewStore.send(.close) }**
      }
      }
    }
  }
}

Creator/Presenter of a screen should also be responsible for killing/removing Counter. Which means that NavigateAndLoad screen should handle close action. And it's 2 possible ways to do so

.....
        case .optionalCounter(.close):
          state.isNavigationActive = false
          return .none
.....

OR

.....
        case .optionalCounter(.close):
          return Effect(value: .setNavigation(isActive: false))
.....

I'd prefer the second one.

The Issue:
Unfortunately, in such implementation, pop animation in NavigationView is with side effect: Counter view removes from screen (exchanged with blank EmptyView) and then pop animation invokes.

giphy

It's happening because of IfLetStore in NavigationLinks destination:

NavigationLink(
            destination: IfLetStore(
              self.store.scope(
                state: { $0.optionalCounter }, action: NavigateAndLoadAction.optionalCounter),
              then: CounterView.init(store:)
            ),
            isActive: viewStore.binding(
              get: { $0.isNavigationActive },
              send: NavigateAndLoadAction.setNavigation(isActive:)
            )
          ) {
            HStack {
              Text("Load optional counter")
            }
          }

When CounterState becomes nil, IfLetStores reacts on it and removes view from screen.

The Question
Is it OK? :smiley: I mean, it can be fixed by using presentationMode.wrappedValue.dismiss() in Close button instead of sending close action directly to the store, but is it how it should be done? According SwiftUI state paradigm - removing of a state should properly remove a view. But in TCA we need to remove (change) a view programatically and then state can react on it. In other words, we need to simulate users touch on native back button.

What do you think, guys? Should this be fixed or it's completely normal? Let's discuss :)

By the way, the same issue applies to the transition animations related to the IfLetStore.
When I need to move from Login to the Main flow by swapping views with animation transition, I can't do it, because changing of views is based on state changing:

case .authorised:
  state.login = nil
  state.main = MainState()

case .logout:
  state. main = nil
  state.login = LoginState()

Even if Transition is specified for both views and swap actions wrapped in the withAnimation closure, disappear animation for niled state can't be performed.

I certainly can’t offer any insight, but I would like to ask for clarification: What is TCA? I found other questions here referencing it, but none with enough context to clue me into what it is. It seems to have something to do with reactive design like SwiftUI.

Edit: I’m guessing the “CA” is “Composable Architecture” based on the “Swift Composable Architecture” tag on this and other related topics.

Yeah, u r totally right :)

1 Like

TCA = The Composable Architecture

From the FAQ:

1 Like

@thehavre, @stephencelis do you have any solution for this problem?

Here's another way to dismiss a navigation/model from within the child. You can hold a boolean in the child's state to determine whether or not it is presented:

struct ChildState {
  var isPresented = true 
  ...
}

And you can have an action that causes it to flip to false:

case .closeButtonTapped:
  state.isPresented = false
  return .none

And then finally you can use the PresentationMode environment variable to dismiss when the isPresented boolean flips:

struct ChildView {
  @Environment(\.presentationMode) var presentationMode
  let store: Store<ChildState, ChildAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      VStack {
        ...
      }
      .onChange(viewStore.isPresented) { isPresented in
        if !isPresented {
          self.presentationMode.dismiss()
        }
      }
    }
  }
}
6 Likes

And what would popToRoot look like, we are currently trying to deal with this problem and we still have a situation that state is already nil and gets actions from the navigation link. Of course we use the IfLetStore. Child reducer is before main reducer.

Yeah, I see. So the problem of changing state instead of asking view to be dismissed immediately is formally fixed. But the overall general problem is still the same: we need to ask SwiftUI manually to dismiss view to send signal to the parent view/state that it was dismissed.

Seems that this is mixed issue of SwiftUI development paradigm and TCA :frowning:

1 Like