Re-rendering sibling items when modifying a child state #826
Replies: 4 comments 20 replies
-
Interesting... These re-renders could probably be avoided by making // Store.swift
extension Store: Equatable where State: Equatable {
public static func == (lhs: Store, rhs: Store) -> Bool {
lhs.state.value == rhs.state.value
}
}
// ViewStore.swift
extension ViewStore: Equatable where State: Equatable {
public static func == (lhs: ViewStore, rhs: ViewStore) -> Bool {
lhs._state.value == rhs._state.value
}
} With @josephktcheung I'm not too sure about the difference between conforming a extension Store: Equatable where State: Equatable {
public static func == (lhs: Store, rhs: Store) -> Bool {
ViewStore(lhs).state == ViewStore(rhs).state
}
} |
Beta Was this translation helpful? Give feedback.
-
As I understand it, I'm not sure about what happens on SwiftUI's side with |
Beta Was this translation helpful? Give feedback.
-
@josephktcheung Thanks for the discussion here! That's definitely a bummer, though ideally it doesn't noticeably impact most apps, and it's good to see that you have a solution for it. Hopefully we can find a fix at the library level, though! |
Beta Was this translation helpful? Give feedback.
-
Hi @stephencelis, @iampatbrown, I continue to look into the performance issue of I modified the 01-GettingStarted-Animations case study to replace the read me text with a simple todo list with 10,000 todos, when I move the cursor in the simulator, the frame rate is terrible. Here's the sample code: import Combine
import ComposableArchitecture
import SwiftUI
private let readMe = """
This screen demonstrates how changes to application state can drive animations. Because the \
`Store` processes actions sent to it synchronously you can typically perform animations \
in the Composable Architecture just as you would in regular SwiftUI.
To animate the changes made to state when an action is sent to the store you can pass along an \
explicit animation, as well, or you can call `viewStore.send` in a `withAnimation` block.
To animate changes made to state through a binding, use the `.animation` method on `Binding`.
To animate asynchronous changes made to state via effects, use the `.animation` method provided \
by the CombineSchedulers library to receive asynchronous actions in an animated fashion.
Try it out by tapping or dragging anywhere on the screen to move the dot, and by flipping the \
toggle at the bottom of the screen.
"""
struct Todo: Equatable, Identifiable {
var description = ""
let id: UUID
var isComplete = false
}
enum TodoAction: Equatable {
case checkBoxToggled
case textFieldChanged(String)
}
struct TodoEnvironment {}
let todoReducer = Reducer<Todo, TodoAction, TodoEnvironment> { todo, action, _ in
switch action {
case .checkBoxToggled:
todo.isComplete.toggle()
return .none
case let .textFieldChanged(description):
todo.description = description
return .none
}
}
struct TodoView: View {
let store: Store<Todo, TodoAction>
var body: some View {
WithViewStore(self.store) { viewStore in
HStack {
Button(action: { viewStore.send(.checkBoxToggled) }) {
Image(systemName: viewStore.isComplete ? "checkmark.square" : "square")
}
.buttonStyle(PlainButtonStyle())
TextField(
"Untitled Todo",
text: viewStore.binding(get: \.description, send: TodoAction.textFieldChanged)
)
}
.foregroundColor(viewStore.isComplete ? .gray : nil)
}
}
}
extension IdentifiedArray where ID == Todo.ID, Element == Todo {
static let mock: Self = IdentifiedArray(uniqueElements: (1...10_000).map {
Todo(description: "TODO \($0)", id: UUID(), isComplete: false)
})
}
extension Effect where Failure == Never {
public static func keyFrames<S>(
values: [(output: Output, duration: S.SchedulerTimeType.Stride)],
scheduler: S
) -> Effect where S: Scheduler {
.concatenate(
values
.enumerated()
.map { index, animationState in
index == 0
? Effect(value: animationState.output)
: Just(animationState.output)
.delay(for: values[index - 1].duration, scheduler: scheduler)
.eraseToEffect()
}
)
}
}
struct AnimationsState: Equatable {
var alert: AlertState<AnimationsAction>? = nil
var circleCenter = CGPoint(x: 50, y: 50)
var circleColor = Color.white
var isCircleScaled = false
var todos: IdentifiedArrayOf<Todo> = .mock
}
enum AnimationsAction: Equatable {
case circleScaleToggleChanged(Bool)
case dismissAlert
case rainbowButtonTapped
case resetButtonTapped
case resetConfirmationButtonTapped
case setColor(Color)
case tapped(CGPoint)
case todo(id: Todo.ID, action: TodoAction)
}
struct AnimationsEnvironment {
var mainQueue: AnySchedulerOf<DispatchQueue>
}
let animationsReducerCore = Reducer<AnimationsState, AnimationsAction, AnimationsEnvironment> {
state, action, environment in
switch action {
case let .circleScaleToggleChanged(isScaled):
state.isCircleScaled = isScaled
return .none
case .dismissAlert:
state.alert = nil
return .none
case .rainbowButtonTapped:
return .keyFrames(
values: [Color.red, .blue, .green, .orange, .pink, .purple, .yellow, .white]
.map { (output: .setColor($0), duration: 1) },
scheduler: environment.mainQueue.animation(.linear)
)
case .resetButtonTapped:
state.alert = .init(
title: .init("Reset state?"),
primaryButton: .destructive(
.init("Reset"),
action: .send(.resetConfirmationButtonTapped, animation: .default)
),
secondaryButton: .cancel(.init("Cancel"))
)
return .none
case .resetConfirmationButtonTapped:
state = .init()
return .none
case let .setColor(color):
state.circleColor = color
return .none
case let .tapped(point):
state.circleCenter = point
return .none
case .todo:
return .none
}
}
let animationsReducer = Reducer<AnimationsState, AnimationsAction, AnimationsEnvironment>.combine(
todoReducer.forEach(
state: \.todos,
action: /AnimationsAction.todo(id:action:),
environment: { _ in TodoEnvironment() }
),
animationsReducerCore
)
struct AnimationsView: View {
@Environment(\.colorScheme) var colorScheme
let store: Store<AnimationsState, AnimationsAction>
var body: some View {
GeometryReader { proxy in
WithViewStore(self.store) { viewStore in
VStack(alignment: .leading) {
ZStack(alignment: .center) {
List {
ForEachStore(
self.store.scope(state: \.todos, action: AnimationsAction.todo(id:action:)),
content: TodoView.init(store:)
)
}
Circle()
.fill(viewStore.circleColor)
.blendMode(.difference)
.frame(width: 50, height: 50)
.scaleEffect(viewStore.isCircleScaled ? 2 : 1)
.offset(
x: viewStore.circleCenter.x - proxy.size.width / 2,
y: viewStore.circleCenter.y - proxy.size.height / 2
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(self.colorScheme == .dark ? Color.black : .white)
.simultaneousGesture(
DragGesture(minimumDistance: 0).onChanged { gesture in
viewStore.send(
.tapped(gesture.location),
animation: .interactiveSpring(response: 0.25, dampingFraction: 0.1)
)
}
)
Toggle(
"Big mode",
isOn:
viewStore
.binding(get: \.isCircleScaled, send: AnimationsAction.circleScaleToggleChanged)
.animation(.interactiveSpring(response: 0.25, dampingFraction: 0.1))
)
.padding()
Button("Rainbow") { viewStore.send(.rainbowButtonTapped, animation: .linear) }
.padding([.leading, .trailing, .bottom])
Button("Reset") { viewStore.send(.resetButtonTapped) }
.padding([.leading, .trailing, .bottom])
}
.alert(self.store.scope(state: \.alert), dismiss: .dismissAlert)
}
}
}
}
struct AnimationsView_Previews: PreviewProvider {
static var previews: some View {
Group {
NavigationView {
AnimationsView(
store: Store(
initialState: .init(),
reducer: animationsReducer,
environment: AnimationsEnvironment(
mainQueue: .main
)
)
)
}
NavigationView {
AnimationsView(
store: Store(
initialState: .init(),
reducer: animationsReducer,
environment: AnimationsEnvironment(
mainQueue: .main
)
)
)
}
.environment(\.colorScheme, .dark)
}
}
} Animation with Screen.Recording.2021-10-07.at.6.30.00.PM.movAnimation without Screen.Recording.2021-10-07.at.6.33.04.PM.movI wonder if anyone has faced similar issue or I've made any mistake in my above code, thanks! |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Hi,
I'm a new learner of TCA and SwiftUI, and I'm looking into the performance of
ForEachStore
with theTodos
case study. I added.debug()
toWithViewStore
inTodoView
like this:Run the case study in simulator and add a bunch of todos. Whenever I add a new todo / edit a todo / filter todos, it triggers other sibling
TodoView
s to re-render (callingWithViewStore
again) while state of those siblings aren't changed at all.I believe it's because adding / editing a
Todo
invalidatestodos
state that makes the list and its items to re-render. I then start exploring ways to reduce needless rendering of unchanged siblings.What I found is that if I make
TodoView
equatable then the siblingTodoView
s won't re-render anymore (without the need of wrapping aEquatableView
over theTodoView
).Screen.Recording.2021-09-24.at.6.53.15.PM.mp4
I'd like to know if this is the appropriate approach to solve this problem.
Beta Was this translation helpful? Give feedback.
All reactions