-
In a recent discussion the situation where you have a tabview was brought up, because it seemingly makes the use of But it seems that even following all the recommendations, we still won't be able to avoid re-rendering all the child views whenever the tab item changes, which seems like a shame. For vanilla SwiftUI the tabs are created once and for all, and not re-rendered when changing tabs. (We might wish for a lazy tabview but apparently that's not what it is). In the scenario below, the enclosing view, and all the child views, are re-rendered every time you change tabs: WithViewStore(store, observe: \.viewState) { viewStore in
TabView(selection: viewStore.binding(get: \.tab, send: Parent.Action.setTab)) {
ChildView(store: store.scope(state: \.child0, action: Parent.Action.child))
.tag(0)
ChildView(store: store.scope(state: \.child1, action: Parent.Action.child))
.tag(1)
ChildView(store: store.scope(state: \.child2, action: Parent.Action.child))
.tag2)
}
.tabViewStyle(.page)
}
} This seems to be due to using I wonder if there is another way to avoid the re-rendering, since I'm relying on knowing what tab the user is on. Here's a full example, ready to run
import SwiftUI
import ComposableArchitecture
import IdentifiedCollections
@main
struct TestingTCAApp: App {
let store = StoreOf<Parent>(initialState: .init(), reducer: Parent())
var body: some Scene {
WindowGroup {
ContentView(store: store)
}
}
}
struct Parent: ReducerProtocol {
struct State: Equatable {
var tapCount = 0
var tab: UUID = .init()
var child0: Child.State = .init(id: .init(), name: "Child 1")
var child1: Child.State = .init(id: .init(), name: "Child 2")
var child2: Child.State = .init(id: .init(), name: "Child 3")
var viewState: ViewState {
.init(tab: tab, tapCount: tapCount)
}
}
struct ViewState: Equatable {
let tab: UUID
let tapCount: Int
}
enum Action: Equatable {
case setTab(UUID)
case child(Child.Action)
}
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .child(let childAction):
state.tapCount += 1
print("PARENT: child action \(childAction), taps: \(state.tapCount)")
case .setTab(let tab):
print("PARENT: tab changed to \(tab)")
state.tab = tab
}
return .none
}
}
}
struct ContentView: View {
let store: StoreOf<Parent>
// @State var page: Int = 0
var body: some View {
WithViewStore(store, observe: \.viewState) { viewStore in
let _ = print("Render: Parent")
VStack {
Text("Taps: \(viewStore.tapCount)")
// let pageBinding = $page
let pageBinding = viewStore.binding(get: \.tab, send: Parent.Action.setTab)
TabView(selection: pageBinding) {
ChildView(store: store.scope(state: \.child0, action: Parent.Action.child))
.tag(0)
ChildView(store: store.scope(state: \.child1, action: Parent.Action.child))
.tag(1)
ChildView(store: store.scope(state: \.child2, action: Parent.Action.child))
.tag(2)
}
.tabViewStyle(.page)
}
.background(.gray)
}
}
}
struct Child {
struct State: Equatable, Identifiable {
var id: UUID
var name: String
}
enum Action: Equatable {
case tapped
}
}
struct ChildView: View {
let store: Store<Child.State, Child.Action>
init(store: Store<Child.State, Child.Action>) {
self.store = store
print(" Init child")
}
var body: some View {
WithViewStore(store) { viewStore in
let _ = print(" Render: \(viewStore.name)")
Button {
viewStore.send(.tapped)
} label: {
Text(viewStore.name)
.font(.largeTitle)
}
.frame(width: 300, height: 300)
.border(.black)
.tag(viewStore.id)
}
}
} |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 20 replies
-
@GreatApe It is true that the tabs are re-computed when changing the selected tab, but in practice have you found that to be a performance problem? Changing tabs is a very infrequent action in an application, so I would not expect it to matter. The main thing that the performance article is trying to explain is that if the root tab observes all state, then any action inside each child feature will cause the tabs to be re-computed. It is not saying that if you properly observe the essentials of state that tabs will not be recomputed when the selected tab changes. |
Beta Was this translation helpful? Give feedback.
-
Own opinion: When adding I think this tracking of "accessed directly at all" is a step between tracking what sub-value was accessed (#1451 (reply in thread)) and the current TCA state of afairs (always re-rerender). We can't implement it directly with our dumb One way to fix it with a hack: use struct CopyValue<T: Equatable>: View {
@Binding var sourceBinding: T
@Binding var targetBinding: T
// Runs every time TCA updates.
init(sourceBinding: Binding<T>, targetBinding: Binding<T>) {
self._sourceBinding = sourceBinding
self._targetBinding = targetBinding
DispatchQueue.main.async {
targetBinding.wrappedValue = sourceBinding.wrappedValue
}
}
var body: some View {
let _ = print("Rerendering copy")
return EmptyView()
.onChange(of: targetBinding, perform: { newValue in
sourceBinding = newValue
})
}
}
struct ContentView: View {
let store: StoreOf<Parent>
@State var page: UUID = .init()
var body: some View {
let _ = print("Render: Parent")
VStack {
WithViewStore(store, observe: \.viewState) { viewStore in
CopyValue(sourceBinding: viewStore.binding(get: \.tab, send: Parent.Action.setTab), targetBinding: $page)
Text("Taps: \(viewStore.tapCount)")
}
let pageBinding = $page
// Text("page: \(pageBinding.wrappedValue)")
TabView(selection: pageBinding) {
ChildView(store: store.scope(state: \.child0, action: Parent.Action.child))
.tag(0)
ChildView(store: store.scope(state: \.child1, action: Parent.Action.child))
.tag(1)
ChildView(store: store.scope(state: \.child2, action: Parent.Action.child))
.tag(2)
}
}
.background(.gray)
}
} Update 1: If we could only create smart bindings ourselves.. maybe ask Apple to provide their mechanism to signal to I assume they use .transaction, Crazy idea: Use Update2: We can create a custom binding property wrapper using Update 3:
|
Beta Was this translation helpful? Give feedback.
Own opinion:
When adding
Text("Page: \(page)
to the content view, the whole view gets redrawn. I assume@State
is smart in that it knows that it should not re-render the view body, just because a binding is used. It seems to have a mechanism inside the$bindings
it emits that allows certain Apple views to listen to changes, be it through a private publisher or through using@Binding
inside a sub-view ofTabView
, that then emits the state change to change the page. When the state variablepage
is accessed directly the@State
knows that it cannot track what was accessed, and so rerenders the body completely.I think this tracking of "accessed directly at all" is a step between tracking what…