swiftswiftuiobservable

Why does SwiftUI re-render all ForEach items in view


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

Solution

  • 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:


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