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
}
}
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."
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()
}