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.
It's happening because of IfLetStore
in NavigationLink
s 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? 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 :)