Search code examples
swiftswiftuipublisherproperty-wrapper

SwiftUI: calling objectWillChange.send() not updating child view


I have a rather complicated set of views nested in views. When I trigger a button action, I pass along an optional block through my viewModel class which calls objectWillChange.send() on that viewModel and I know that it's being triggered because the other parts of my view are updating. One of the child views (which is observing that viewModel changes) doesn't update until I click on part of it (which changes viewModel.selectedIndex and triggers redraw so I know it's listening for published changes).

Why isn't the update triggering the child view (in this case PurchaseItemGrid) to redraw itself?

Here's where I setup the call to update...

struct RightSideView: View {
    @ObservedObject var viewModel: TrenchesPurchases
            
    var body: some View {
        VStack {
            ...
            PurchaseItemGrid(viewModel: viewModel)      // <-- View not updating
            Button {
                viewModel.purchaseAction() {
                    viewModel.objectWillChange.send()   // <-- Triggers redraw, reaches breakpoint here
                }
            } label: {
                ...
            }
            ...
        }
    }
}

Here's where the optional is called (and I've not only visually confirmed this is happening as other parts of the view redraw, it also hits breakpoint here)...

class TrenchesPurchases: ObservableObject, CanPushCurrency {

    // MARK: - Properties

    @Published private var model = Purchases()

    // MARK: - Properties: Computed

    var selectedIndex: Int {
        get { return model.selectedIndex }
        set { model.selectedIndex = newValue }
    }

    var purchaseAction: BlockWithBlock {
        { complete in                    
            ...
            
            complete?()
        }
    }
    ...
}

And here's the view that's not updating as expected...

struct PurchaseItemGrid: View {

   @ObservedObject var viewModel: TrenchesPurchases
    
   var body: some View {
        VStack {
            itemRow(indices:  0...3)
            ...
        }
        ...
    }
    
    @ViewBuilder
    func itemRow(indices range: ClosedRange<Int>) -> some View {
        HStack {
            ForEach(viewModel.purchaseItems[range], id: \.id) { item in
                PurchaseItemView(item: item,
                                 borderColor: viewModel.selectedIndex == item.id ? .green : Color(Colors.oliveGreen))
                .onTapGesture { viewModel.selectedIndex = item.id }
            }
        }
    }
}

Here's the code workingdog asked for...

struct Purchases {

    // MARK: - Properties

    var selectedIndex = 15

    let items: [PurchaseItem] = buildCollectionOfItems()

    // MARK: - Functions

    // MARK: - Functions: Static

    // TODO: Define Comments
    static func buildCollectionOfItems() -> [PurchaseItem] {
        return row0() + row1() + row2() + row3()
}

static func row0() -> [PurchaseItem] {
    var items = [PurchaseItem]()
    
    let grenade = Ammo(ammo: .grenade)
    items.append(grenade)
    
        let bullets = Ammo(ammo: .bullets)
        items.append(bullets)
    
        let infiniteBullets = Unlock(mode: .defense)
        items.append(infiniteBullets)
    
        let unlimitedInfantry = Unlock(mode: .offense)
        items.append(unlimitedInfantry)
    
        return items
    }

    static func row1() -> [PurchaseItem] {
        var items = [PurchaseItem]()
    
        for unit in UnitType.allCases {
            let item = Unit(unit: unit)
            items.append(item)
        }
        
        return items
    }

    static func row2() -> [PurchaseItem] {
        var items = [PurchaseItem]()

        let brits = NationItem(nation: .brits)
        items.append(brits)
    
        let turks = NationItem(nation: .turks)
        items.append(turks)
    
        let usa = NationItem(nation: .usa)
        items.append(usa)
    
        let insane = DifficultyItem(difficulty: .insane)
        items.append(insane)
    
        return items
    }

    static func row3() -> [PurchaseItem] {
        var items = [PurchaseItem]()

        let offenseLootBox = Random(mode: .offense)
        items.append(offenseLootBox)
    
        let defenseLootBox = Random(mode: .defense)
        items.append(defenseLootBox)
    
        let currency = Currency(isCheckin: false)
        items.append(currency)
    
        let checkIn = Currency(isCheckin: true)
        items.append(checkIn)
    
        return items
    }
}

Solution

  • The issue I had was that the PurchaseItemGrid was noticing the observed item being published, but the change I was trying to trigger was in the PurchaseItemView which did not have an observed object.

    I assumed that when the PurchaseItemGrid observed the change and was redrawn, the itemRow method would redraw a new collection of PurchaseItemView's that would then have their image updated to match the new state.

    This was further compounded because the onTapGesture was triggering a redraw of the PurchaseItemView, and to be honest I'm still not sure how the PurchaseItemGrid could redraw itself while still using the same PurchaseItemView's in it's body; but it may have to do with how @ViewBuilder works and because the views were created in an entirely separate method.

    So, long story short: make sure each view you want to update has some form of observer, don't rely on the parent's redraw to create new child views.