Hi!
Long answer incoming, I'm sorry 
I'm currently in the same situation, where I have a TabView encapsulated in a ViewStore so that I'm able to change the selectedTab programmaticaly (when receiving a deeplink, for example)
// MARK: - App
enum TabItem {
case tab1, tab2, tab3
}
struct AppState: Equatable {
var selectedTab: TabItem = .tab1
var tab1State: TabContentState = .init()
var tab2State: TabContentState = .init()
var tab3State: TabContentState = .init()
}
struct AppEnvironment {}
enum AppAction {
case selectTab(TabItem)
case tab1(TabContentAction)
case tab2(TabContentAction)
case tab3(TabContentAction)
}
let appReducer: Reducer<AppState, AppAction, AppEnvironment> = .combine(
Reducer { state, action, _ in
switch action {
case .selectTab(let tab):
state.selectedTab = tab
return .none
case .tab1, .tab2, .tab3:
return .none
}
},
tabContentReducer.pullback(
state: \.tab1State,
action: /AppAction.tab1,
environment: { _ in .init() }),
tabContentReducer.pullback(
state: \.tab2State,
action: /AppAction.tab2,
environment: { _ in .init() }),
tabContentReducer.pullback(
state: \.tab3State,
action: /AppAction.tab3,
environment: { _ in .init() })
)
struct MainView: View {
let store: Store<AppState, AppAction>
var body: some View {
WithViewStore(store.scope(state: \.selectedTab)) { viewStore in
TabView(
selection: viewStore.binding(send: AppAction.selectTab)
) {
TabContentView(store: store.scope(
state: \.tab1State,
action: AppAction.tab1),
tab: .tab1)
.tabItem { Text("Tab1") }
.tag(TabItem.tab1)
TabContentView(store: store.scope(
state: \.tab2State,
action: AppAction.tab2),
tab: .tab2)
.tabItem { Text("Tab2") }
.tag(TabItem.tab2)
TabContentView(store: store.scope(
state: \.tab3State,
action: AppAction.tab3),
tab: .tab3)
.tabItem { Text("Tab3") }
.tag(TabItem.tab3)
}
}
}
}
// MARK: - TabContent
struct TabContentState: Equatable {
var count = 0
}
struct TabContentEnvironment {}
enum TabContentAction {
case increment
case decrement
}
let tabContentReducer: Reducer<TabContentState, TabContentAction, TabContentEnvironment> = Reducer { state, action, _ in
switch action {
case .increment:
state.count += 1
return .none
case .decrement:
state.count -= 1
return .none
}
}
struct TabContentView: View {
let store: Store<TCATabViewFeatureState, TCATabViewFeatureAction>
init(
store: Store<TCATabViewFeatureState, TCATabViewFeatureAction>
) {
self.store = store
print("TabContentView.init")
}
var body: some View {
print("TabContentView.body")
return WithViewStore(store) { viewStore in
VStack {
Text("Hello, World!")
HStack(spacing: 10) {
Button("-") { viewStore.send(.decrement) }
Text("\(viewStore.count)")
Button("+") { viewStore.send(.increment) }
}
.padding()
}
}
}
}
After we have visited each tab once, any time we select another tab, the MainView's ViewStore rerenders all of its tabs (call to .body) which can lead to obvious performance issues
The solution proposed by @hfossli works, but it feels kind of hacky and not very self-explanatory on why we do this in the code if we're not aware of the possible issue. And it adds some boilerplate as well. Although I think that in terms of performances only it looks the best.
I've tried different solutions, and I came up with one that almost mimic the SwiftUI's @State version of tab selection
The idea is to take advantage of the ViewStore's removeDuplicates closure to render its content only when we explicitly want to (in my case, it's when the app set a new selectedTab programmaticaly)
In the state, we would have the selectedTab value, but also another one that will give us the context of the selection; wether the user selected another tab, or it was done programmaticaly
enum TabItem {
enum SelectionSource {
case userTap, programmaticaly
}
case tab1, tab2, tab3
}
struct AppState: Equatable {
var selectedTab: TabItem = .tab1
var tabSelectionSource: TabItem.SelectionSource = .userTap
var tab1State: TabContentState = .init()
var tab2State: TabContentState = .init()
var tab3State: TabContentState = .init()
}
enum AppAction {
case selectTab(TabItem, source: TabItem.SelectionSource)
case tab1(TabContentAction)
case tab2(TabContentAction)
case tab3(TabContentAction)
}
let appReducer: Reducer<AppState, AppAction, AppEnvironment> = .combine(
Reducer { state, action, _ in
switch action {
case .selectTab(let tab, let source):
state.selectedTab = tab
state.selectionSource = source
return .none
case .tab1, .tab2, .tab3:
return .none
}
},
...
)
And we can use the newly created selectionSource from the AppState in the MainView to render only when intended
struct MainView: View {
struct State {
let selectedTab: TabItem
let source: TabItem.SelectionSource
}
let store: Store<AppState, AppAction>
var body: some View {
WithViewStore(
store.scope(state: Self.State.init),
removeDuplicates: { previousState, newState in
previousState == newState ||
newState.source != .programmaticaly
}
) { viewStore in
TabView(
selection: viewStore.binding(
get: \.selectedTab,
send: { .selectTab($0, source: .userTap })
) {
... tab views ...
}
}
}
}
extension MainView.State {
init(appState: AppState) {
selectedTab = appState.selectedTab
source = appState.selectionSource
}
}
With this implementation, the view isn't rendered each time the user taps a tab (like vanilla SwiftUI with an @State value)
But I still get all tabs' views being rendered when I programmaticaly set the selected tab. To remove the undisplayed tabs rendering, I would still have to implement @hfossli's solution, and only then we would have, I think, a perfect mimic of vanilla SwiftUI's behaviour
What do you think of this approach guys ?
PS: One last desperate thought I had was to simply not use the TCA architecture for this view only, but I'm not sure that it can be done, to put a vanilla SwiftUI view in the middle of the TCA architecture views, because I'll ovisouly have this kind of view flow:
Launchscreen -> Gatekeeper (for login) -> MainView (the tabView) -> tabs
And they would all use TCA, but not the MainView