0

I'm building a small SwiftUI view with a list of CardView items that use a shared @Binding from an @Observable model. When I tap one button, all the items in the ForEach are re-rendered. I'm trying to understand why and how to prevent that.

This is my simple code:

import SwiftUI

struct GameObservableView: View {
    @Observable final class Game {
        let data = ["jxrfkzqp", "duwlemci", "hbpokxra", "tcyqdsne", "lvgurxzt"]
        
        var isSelected: String?
    }
    
    @State private var game = Game()
    
    var body: some View {
        ForEach(game.data, id: \.self) { row in
            VStack {
                CardView(title: row, isSelected: $game. isSelected)
                    .padding()
            }
        }
    }
}

struct CardView: View {
    let title: String
    @Binding var isSelected: String?
    
    var body: some View {
        let _ = print("update view \(title)")
        Button {
            withAnimation(.easeInOut(duration: 0.2)) {
                if isSelected == title {
                    isSelected = nil
                } else {
                    isSelected = title
                }
            }
        } label: {
            Text(title)
                .font(.headline)
                .frame(maxWidth: .infinity)
        }
    }
}
import SwiftUI

@main
struct CardsGameApp: App {
    var body: some Scene {
        WindowGroup {
            GameObservableView()
        }
    }
}

Unfortunately, even after wrapping the logic inside a class (and the same happens without it), clicking the button still triggers the 'update view' print five times.

UPDATE: I noticed is that after making CardView conform to Equatable and removing the binding—moving the logic into the onTap closure—I managed to reduce the print output to one call when a single button is tapped, and two calls when two buttons need to be refreshed.

struct GameObservableView: View {
    @State private var data = ["jxrfkzqp", "duwlemci", "hbpokxra", "tcyqdsne", "lvgurxzt"]
    @State private var selectedTitle: String?

    var body: some View {
        VStack(spacing: 12) {
            ForEach(data, id: \.self) { title in
                CardView(
                    title: title,
                    isSelected: selectedTitle == title
                ) {
                    withAnimation(.easeInOut(duration: 0.2)) {
                        selectedTitle = (selectedTitle == title) ? nil : title
                    }
                }
            }
        }
        .padding()
    }
}

struct CardView: View, Equatable {
    let title: String
    var isSelected: Bool
    let onTap: () -> Void
    
    var body: some View {
        let _ = print("update view \(title)")
        Button(action: onTap) {
            Text(title)
                .font(.headline)
                .frame(maxWidth: .infinity)
        }
    }
    
    static func == (lhs: CardView, rhs: CardView) -> Bool {
        return lhs.title == rhs.title &&
        lhs.isSelected == rhs.isSelected
    }
}
12
  • SwiftUI doesn’t render anything, the Views are just simple values. The values get diffed and only if different are actual view objects created. By the way, State is just for value types and self is not a valid ForEach id for values. Commented Nov 12 at 21:32
  • Look at the docs: Store observable objects as the Apple recommended approach to using @State with @Observable class. See also Managing model data in your app. You can see that you are looping over multiple data, but you only access one and the same game in the CardView calls. You change one, and you change them all in the loop, hence your View updates. Look also at @Bindable when you need to use a binding. Commented Nov 12 at 23:35
  • Note, declaring @Observable final class Game inside the View makes it local to that type. You can’t reuse it elsewhere, test it, or share it between views. And yet this is the main purpose of using a @Observable class. Commented Nov 12 at 23:46
  • It’s not local to that view @workingdogsupportUkraine. You can totally say GameObservableView.Game elsewhere. Sure, it’s unwieldy, but it’s not private. Commented Nov 12 at 23:52
  • yes, I stand corrected, it can be used elsewhere using the name qualifier. However, I'm guessing the macro is probably expanded every time the View is changed. Commented Nov 13 at 0:56

1 Answer 1

2

Why does SwiftUI re-render all ForEach items in view?

In your original code, CardView has a dependency on isSelected, which is a @Binding , so basically a state (in the sense that it will trigger an update if the value changes).

Every button tap updates the isSelected binding. Since all CardView reference the same binding, when that binding's value changes, all views holding that binding update.

With this approach, the only solution is the conformance to Equatable, just like in your updated code, which tells SwiftUI: "Two GameCardView instances are equal if their title AND isSelected values are the same."

The alternative: Class-based approach with @Observable

If you want to avoid conforming to Equatable, the only way I can think of is by using @Observable class models for items/titles (or Swiftdata @Model classes if you need persistence, which are also @Observable).

So if instead of a simple string, you could have a GameCard model:

@Observable
class GameCard: Identifiable {
    let id = UUID()
    let title: String
    var isSelected: Bool = false

    init(title: String) {
        self.title = title
    }
}

Then your Game class would have a var cards: [GameCard] property, with a toggleSelection() function:

@Observable
class Game {
    var cards: [GameCard]

    init(titles: [String]) {
        self.cards = titles.map { GameCard(title: $0) }
    }

    func toggleSelection(for card: GameCard) {
        if card.isSelected {
            // Deselect the item
            card.isSelected = false
        } else {
            // Deselect any currently selected item
            cards.first(where: { $0.isSelected })?.isSelected = false
            // Select this item
            card.isSelected = true
        }
    }
}

Your call to GameCardView would become:

 GameCardView(card: card, game: game)

And the GameCardView:

struct GameCardView: View {
    var card: GameCard
    var game: Game

    var body: some View {
        let _ = print("update view \(card.title)")
        Button {
            withAnimation(.easeInOut(duration: 0.2)) {
                game.toggleSelection(for: card)
            }
        } label: {
            Text(card.title)
                .font(.headline)
                .frame(maxWidth: .infinity)
                .foregroundStyle(card.isSelected ? .green : .orange)
        }
    }
}

This would work, because:

  • Each GameCard is independently observable and mutation happens inside the card object (rather than on the parent when you have centralized selection state)

  • When card.isSelected changes, only views reading that specific card's isSelected property update

  • Only 2 views print (the one being selected and the one being deselected)


From a memory footprint standpoint, the string array + Equatable approach is far lighter, but as far as update performance, both methods would probably be pretty equal. However, from a scalability standpoint, the class approach is certainly preferred.

Which you use is up to you.

Here's the complete working code:

import SwiftUI

// Class-based approach with @Observable
@Observable
class GameCard: Identifiable {
    let id = UUID()
    let title: String
    var isSelected: Bool = false

    init(title: String) {
        self.title = title
    }
}

@Observable
class Game {
    var cards: [GameCard]

    init(titles: [String]) {
        self.cards = titles.map { GameCard(title: $0) }
    }

    func toggleSelection(for card: GameCard) {
        if card.isSelected {
            // Deselect the item
            card.isSelected = false
        } else {
            // Deselect any currently selected item
            cards.first(where: { $0.isSelected })?.isSelected = false
            // Select this item
            card.isSelected = true
        }
    }
}

struct GameObservableView: View {
    @State private var game = Game(titles: [
        "jxrfkzqp", "duwlemci", "hbpokxra", "tcyqdsne", "lvgurxzt"
    ])

    var body: some View {
        VStack(spacing: 12) {
            ForEach(game.cards) { card in
                GameCardView(card: card, game: game)
            }
        }
        .padding()
    }
}

struct GameCardView: View {
    var card: GameCard
    var game: Game

    var body: some View {
        let _ = print("update view \(card.title)")
        Button {
            withAnimation(.easeInOut(duration: 0.2)) {
                game.toggleSelection(for: card)
            }
        } label: {
            Text(card.title)
                .font(.headline)
                .frame(maxWidth: .infinity)
                .foregroundStyle(card.isSelected ? .green : .orange)
        }
    }
}

#Preview {
    GameObservableView()
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.