Home > OS >  Property change from Int to String provokes SwiftUI view not updating
Property change from Int to String provokes SwiftUI view not updating

Time:09-28

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

CodePudding user response:

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

CodePudding user response:

I can't tell you why Int work but String doesn't, maybe Int for some strange reason changes so that even the outer view, TestZStackView, gets redrawn.

What I have found though in your code is an inconsistency where you pass the Card object to CardView but what you update is not that instance but the Card in your view models deck.cards array.

So here is a solution where I pass the id of each Card to the detail view and then get the correct Card from the view model instead.

Change in main view

ForEach(testVM.deck.cards.prefix(4)) { card in
    CardView(testVM: testVM, id: card.id)
}

Change in detail view

@ObservedObject var testVM: TestVM
var id: Card.ID

var body: some View {
    if let card = testVM.deck.cards.first(where: { $0.id ==  id}) {
        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)
            }
    } else {
        EmptyView()
    }
}

There are probably other ways to either pass the actual card, like working with @Binding but the main point is that you need to keep track of your source of truth and update and display the same Card objects.

  • Related