Search code examples
iosswiftswiftui

How to achieve that only selected item in LazyHStack is redrawn


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?

This is from Instruments...Instruments image

I know this maybe can't be an issue for 20-30 items with some simple data, but it seems inefficient.


Solution

  • 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 bodys 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
            }
        }
    }