I have this LazyHStack
and a few items in it. Now what I have noticed, is that every time I tap on an item (I draw a border around it) the whole hstack is redrawn along with all items in it (I added rainbow debug so I see it visually it re-renders).
Here is a minimal reproducible example:
import SwiftUI
private let rainbowDebugColors = [Color.purple, Color.blue, Color.green, Color.yellow, Color.orange, Color.red]
extension View {
func rainbowDebug() -> some View {
self.background(rainbowDebugColors.randomElement()!)
}
}
enum MyEnum: String, CaseIterable {
case first = "first"
case second = "second"
case third = "third"
case fourth = "fourth"
case fifth = "fifth"
}
struct MyModel: Identifiable {
let id: String
let title: String
let type: MyEnum
init(type: MyEnum) {
self.id = type.rawValue
self.type = type
self.title = type.rawValue.uppercased()
}
}
final class MyViewModel: ObservableObject {
@Published var selection: MyEnum?
let types: [MyModel] = MyEnum.allCases.map { MyModel(type: $0) }
}
struct MyItemView: View {
let model: MyModel
@Binding var isSelected: Bool
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color.white)
.frame(width: 100, height: 130)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(isSelected ? .purple : .clear, lineWidth: 3)
)
VStack {
Text(model.title)
.font(.headline)
.foregroundColor(.black)
.rainbowDebug()
}
}.onTapGesture {
isSelected.toggle()
}
}
}
struct MySelectorView: View {
@StateObject private var viewModel = MyViewModel()
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 20) {
ForEach(viewModel.types, id: \.type) { model in
MyItemView (
model: model,
isSelected: Binding(get: {
viewModel.selection == model.type
}, set: { value in
viewModel.selection = model.type
})
)
}
}
.frame(height: 120)
.padding()
}.rainbowDebug()
}
}
struct ContentView: View {
var body: some View {
MySelectorView()
}
}
What is the correct way to affect only one selected item in my hstack
?
I know this maybe can't be an issue for 20-30 items with some simple data, but it seems inefficient.
rainbowDebug
and the Instruments screenshot both show that body
is called. This could be caused by the view being recreated (when the view's identity changes), but it could also be caused by changes in one or more of the view's dependencies.
SwiftUI records what dependencies each of your views have. When the dependencies change, the body
s of the views that depend on those dependencies get evaluated. The new body
is compared with the previous value of body
, and SwiftUI figures out the differences, and updates the views accordingly.
Note that this doesn't recreate the whole view. In UIKit terms, the change in background color (from rainbowDebug
) would be similar to just setting the backgroundColor
property of an existing UIView
instance. The view is only recreated when its identity changes, which would be similar to calling UIView(frame: ...)
in UIKit terms. To learn more about identities, see Demystifying SwiftUI. In your code, the identity of MySelectorView
doesn't change.
From rainbowDebug
, we can see that MySelectorView
updates when viewModel.selection
changes. This is because viewModel
is a dependency of MySelectorView
. It is a @StateObject
in MySelectorView
after all. This is totally reasonable. Using @Observable
instead of Observable
allows SwiftUI to build a much finer-grained dependency graph, and MySelectorView.body
will not be called.
If you don't want a part of the body
to run because it doesn't actually depend on anything, you can extract that part into a separate View
/ViewModifier
. That View
/ViewModifier
would then have no dependencies, and so its body
will not be called. See here for an example.
When you select an item, every MyItemView
is updated. This is unexpected because only the isSelected
of the selected item and the previously selected item has changed. This can be fixed by not passing a Binding
to MyItemView
- just pass a Bool
, and handle the tap gesture outside of MyItemView
:
struct MyItemView: View {
let model: MyModel
let isSelected: Bool
// the rest is the same as before
}
ForEach(viewModel.types) { model in
MyItemView (
model: model,
isSelected: viewModel.selection == model.type
)
.onTapGesture {
if viewModel.selection == model.type {
viewModel.selection = nil
} else {
viewModel.selection = model.type
}
}
}