Search code examples
swiftswiftuiequatable

Property change from Int to String provokes SwiftUI view not updating


I have a strange behavior in my code. It's a simple app that shows four views (CardView) and each time one of them is tapped, the view will reflect it by changing its border (using "selected" property). The problem is that when "value" property is a String it doesn't work. However when "value" property is of type Int it does work. What is this behavior due to?

import SwiftUI

struct Deck {
    var cards = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map {
//        Card(value: $0)
        Card(value: "\($0)")
    }
}

struct Card: Identifiable {
    let id = UUID()
//    let value: Int // Using int value solves the problem...
    let value: String // Using string value provokes card view not updated
    var selected = false
}

extension Card: Equatable {
    static func ==(lhs: Card, rhs: Card) -> Bool {
        return lhs.id == rhs.id
    }
}

final class TestVM: ObservableObject {
    @Published var deck = Deck()
    
    func toggleSelection(card: Card) {
        let idx = deck.cards.firstIndex(of: card)!
        deck.cards[idx].selected.toggle()
    }
}

struct CardView: View {
    @ObservedObject var testVM: TestVM
    let card: Card
    
    var body: some View {
        Text("\(card.value)")
            .frame(width: 40, height: 80)
            .background {
                Rectangle()
                    .stroke(card.selected ? .red : .black, lineWidth: 2)
            }
            .background(card.selected ? .red.opacity(0.2) : .gray.opacity(0.2))
            .onTapGesture {
                testVM.toggleSelection(card: card)
            }
    }
}

struct TestZStackView: View {
    
    @StateObject var testVM = TestVM()
    
    var body: some View {
        HStack {
            ForEach(testVM.deck.cards.prefix(4)) { card in
                CardView(testVM: testVM, card: card)
            }
        }
    }
}

struct TestZStackView_Previews: PreviewProvider {
    static var previews: some View {
        TestZStackView()
    }
}

Solution

  • Inside CardView you display card that is declared as let card: Card, meaning it will not change. When testVM.toggleSelection(card: card) is called the array of Card in Deck has not really changed, that is, no cards has been added or removed from it. So the view is not updated.

    Try this approach, works for me:

    struct Deck {
        var cards = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map {Card(value: "\($0)")}
    }
    
    struct Card: Identifiable {
        let id = UUID()
        let value: String
        var selected = false
    }
    
    extension Card: Equatable {
        static func ==(lhs: Card, rhs: Card) -> Bool {
            return lhs.id == rhs.id
        }
    }
    
    final class TestVM: ObservableObject {
        @Published var deck = Deck()
    }
    
    struct CardView: View {
        @Binding var card: Card // <-- here
    
        var body: some View {
            Text("\(card.value)")
                .frame(width: 40, height: 80)
                .background {Rectangle().stroke(card.selected ? .red : .black, lineWidth: 2)}
                .background(card.selected ? .red.opacity(0.2) : .gray.opacity(0.2))
                .onTapGesture {
                    card.selected.toggle()  // <-- here
                }
        }
    }
    
    struct ContentView: View {
        @StateObject var testVM = TestVM()
        
        var body: some View {
            HStack {
                ForEach($testVM.deck.cards) { $card in  // <-- here
                    if testVM.deck.cards.prefix(4).contains(card) { 
                        CardView(card: $card)  // <-- here
                    }
                }
            }
        }
    }