When there's a change in state in one tab another tab will also render if precautions isn't made.
Example project
Open a new xcode project and replace ContentView.swift with the following
import SwiftUI
import ComposableArchitecture
struct ContentView: View {
var body: some View {
let initialState = MainState(
health: .init(age: 30),
settings: .init(nickName: "tarball")
)
let store = Store(
initialState: initialState,
reducer: mainReducer,
environment: MainEnvironment()
)
return MainView(store: store)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
// MARK: - Health View
struct HealthState: Equatable {
var age: Int?
var ageString: String {
if let age = age {
return String(age)
}
return "unknown"
}
}
enum HealthAction: Equatable {
case loadAge
case decreaseAge
case increaseAge
}
struct HealthEnvironment {}
var healthReducer = Reducer<HealthState, HealthAction, HealthEnvironment>.init() { state, action, env in
switch action {
case .loadAge:
state.age = 30
return .none
case .decreaseAge:
if let age = state.age {
state.age = age - 1
}
return .none
case .increaseAge:
if let age = state.age {
state.age = age + 1
}
return .none
}
}
struct HealthView: View {
let store: Store<HealthState, HealthAction>
var body: some View {
WithViewStore(store) { viewStore in
VStack {
Print("HealthView.render")
Text("Your age is \(viewStore.state.ageString)")
HStack {
Button(action: {
viewStore.send(.decreaseAge)
}, label: {
Text("-")
})
Button(action: {
viewStore.send(.increaseAge)
}, label: {
Text("+")
})
}
}.onAppear{
print("HealthView.onAppear")
viewStore.send(.loadAge)
}.onDisappear(){
print("HealthView.onDisappear")
}
}
}
}
// MARK: - Utility
extension View {
func Print(_ vars: Any...) -> some View {
for v in vars { print(v) }
return EmptyView()
}
}
// MARK: - Settings View
struct SettingsState: Equatable {
var nickName: String
}
enum SettingsAction: Equatable {
case newNickName
}
struct SettingsEnvironment {}
var settingsReducer = Reducer<SettingsState, SettingsAction, SettingsEnvironment>.init() { state, action, env in
switch action {
case .newNickName:
state.nickName = ["sazzy", "mickey"]
.filter { $0 != state.nickName }
.randomElement() ?? "kim"
return .none
}
}
struct SettingsView: View {
let store: Store<SettingsState, SettingsAction>
var body: some View {
WithViewStore(store) { viewStore in
VStack {
Print("SettingsView.render")
Text("Hello \(viewStore.state.nickName)")
Button(action: {
viewStore.send(.newNickName)
}, label: {
Text("New identity")
})
}
.onAppear{
print("SettingsView.onAppear")
}.onDisappear(){
print("SettingsView.onDisappear")
}
}
}
}
// MARK: - MainView
enum Tabs: Equatable, Hashable {
case health
case settings
}
struct MainState: Equatable {
var health: HealthState
var settings: SettingsState
var currentTab: Tabs = .health
}
enum MainAction: Equatable {
case health(HealthAction)
case settings(SettingsAction)
case selectedTab(Tabs)
}
struct MainEnvironment {}
var _mainReducer = Reducer<MainState, MainAction, MainEnvironment>.init() { state, action, env in
switch action {
case .selectedTab(let tab):
print("--- Switching to tab '\(tab)'")
state.currentTab = tab
return .none
case .health(let action):
print("--- Handling action '\(action)'")
return .none
case .settings(let action):
print("--- Handling action '\(action)'")
return .none
}
}
var mainReducer = Reducer.combine(
_mainReducer,
settingsReducer.pullback(state: \.settings, action: /MainAction.settings, environment: { env in
return SettingsEnvironment()
}),
healthReducer.pullback(state: \.health, action: /MainAction.health, environment: { env in
return HealthEnvironment()
})
)
struct MainView: View {
let store: Store<MainState, MainAction>
var body: some View {
WithViewStore(self.store) { viewStore in
TabView(selection: viewStore.binding(
get: { $0.currentTab },
send: MainAction.selectedTab)
) {
HealthView(
store: store.scope(
state: { $0.health },
action: { MainAction.health($0) }
)
)
.tabItem {
Image(systemName: "heart")
Text("Health")
}
.tag(Tabs.health)
SettingsView(
store: store.scope(
state: { $0.settings },
action: { MainAction.settings($0) }
)
)
.tabItem {
Image(systemName: "gear")
Text("Settings")
}
.tag(Tabs.settings)
}
}
}
}
Reproduction steps
Given the following steps:
- Run the project
- Switch to "Settings" tab
- Tap "New Identity"
The console output I would like to see:
HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
HealthView.render
--- Switching to tab 'settings'
SettingsView.render
SettingsView.onAppear
SettingsView.render
HealthView.onDisappear
--- Handling action 'newNickName'
SettingsView.render
Actual console output:
HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
HealthView.render
--- Switching to tab 'settings'
SettingsView.render
SettingsView.onAppear
SettingsView.render
HealthView.render
HealthView.onDisappear
--- Handling action 'newNickName'
SettingsView.render
HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
SettingsView.render
HealthView.render
Solution
By scoping down the viewStore the output in the console looks very different
struct MainView: View {
let store: Store<MainState, MainAction>
var body: some View {
WithViewStore(self.store.scope(state: { $0.currentTab })) { viewStore in
TabView(selection: viewStore.binding(
get: { $0 },
send: MainAction.selectedTab)
) {
HealthView(
store: store.scope(
state: { $0.health },
action: { MainAction.health($0) }
)
)
.tabItem {
Image(systemName: "heart")
Text("Health")
}
.tag(Tabs.health)
SettingsView(
store: store.scope(
state: { $0.settings },
action: { MainAction.settings($0) }
)
)
.tabItem {
Image(systemName: "gear")
Text("Settings")
}
.tag(Tabs.settings)
}
}
}
}
Expected console output:
HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
HealthView.render
--- Switching to tab 'settings'
SettingsView.render
SettingsView.onAppear
SettingsView.render
HealthView.onDisappear
--- Handling action 'newNickName'
SettingsView.render
Actual console output:
HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
HealthView.render
--- Switching to tab 'settings'
SettingsView.render
SettingsView.onAppear
SettingsView.render
HealthView.render
HealthView.onDisappear
--- Handling action 'newNickName'
SettingsView.render
This works fine. The only thing left now is to avoid that last excessive HealthView.render. It is possible to avoid that by conditionally rendering the views.
struct MainView: View {
let store: Store<MainState, MainAction>
var body: some View {
WithViewStore(self.store.scope(state: { $0.currentTab })) { viewStore in
TabView(selection: viewStore.binding(
get: { $0 },
send: MainAction.selectedTab)
) {
Group {
if viewStore.state == .health {
HealthView(
store: store.scope(
state: { $0.health },
action: { MainAction.health($0) }
)
)
} else {
Text("Empty")
}
}
.tabItem {
Image(systemName: "heart")
Text("HealthView")
}
.tag(Tabs.health)
Group {
if viewStore.state == .settings {
SettingsView(
store: store.scope(
state: { $0.settings },
action: { MainAction.settings($0) }
)
)
} else {
Text("Empty")
}
}
.tabItem {
Image(systemName: "gear")
Text("SettingsView")
}
.tag(Tabs.settings)
}
}
}
}
Which then yields the following console output
HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
HealthView.render
--- Switching to tab 'settings'
SettingsView.render
SettingsView.onAppear
HealthView.onDisappear
--- Handling action 'newNickName'
SettingsView.render
Hurray. We've successfully removed all excessive work and potential unintented behavior.
Closing thoughts
Maybe Stephen or Brandon have some thoughts on this?
Do we like the if-clause?
Should it be necessary to scope down?
Is it okay to rely on .onAppear {} or should stuff like that (initial loading etc) be handled all the way by the reducer?
I think scoping down absolutely makes sense and that TCA is behaving as expected.
I don't like the if-clause and would love to know if there is a better way.