swiftmacosswiftui

Cannot get view to update after state change in SwiftUI no matter what I do


So I have racked my brain out with this app for almost a week now, trying to do this is in a way where I can have reusable components and have state variables that I can call from those reusable components..

Finally I gave up and said fine.. I will just hardcode views for every icon on the screen.

I have a bunch of icons that when you click them, they go dark and lose color, click again and they come back.

No matter how I implement this, the behavior is the same: Clicking the icons appears to work visually. Resetting with cmd-R menu item, doesn't work.

Now I've actually printed out the values of each boss's isDead state variables in appState and they're false before I do the reset, regardless of what their local isDead state variable is in the View. I click on a boss icon, and it goes gray, and the isDead property that's supposed to be bound to the property of the @Observable AppState class toggles correctly, but doesn't connect to the AppState Observable class.

The state vars are also false after the reset, which is what they should be .. but I don't know if it actually sets them if that actually works.. or if it's just because they were false before, and clicking the icons doesn't change the global state.

Also keep in mind this code produces no errors. I know some things like the Views in ContentView being called without parenthesis is a bit weird.. Not really sure why that works but it works, and if it ain't broke .... one thing at a time :)

Heres all the relevant code:

Bosses.swift

import SwiftUI

struct BossBody: View {
    let iconName: String
    @Binding var isDead: Bool
    
    var appState = AppState()
    
    var body: some View {
        Image(isDead ? "dead" + iconName : iconName)
            .resizable()
            .frame(width: 65, height: 65)
            .gesture(
                TapGesture()
                    .onEnded {
                        $isDead.wrappedValue.toggle()
                        print(isDead)
                    }
            )
            .modifier(AppearanceModifier(type: .boss, isActive: isDead))
    }
}

struct BossRidley: View {
    let name: String = "ridley"
    @State var appState = AppState()

    var body: some View {
        BossBody(iconName: name, isDead: $appState.ridleyDead)
    }
}

struct BossPhantoon: View {
    let name: String = "phantoon"
    @State private var appState = AppState()

    var body: some View {
        BossBody(iconName: name, isDead: $appState.phantoonDead)
    }
}

struct BossKraid: View {
    let name: String = "kraid"
    @State private var appState = AppState()

    var body: some View {
        BossBody(iconName: name, isDead: $appState.kraidDead)
    }
}

struct BossDraygon: View {
    let name: String = "draygon"
    @State private var appState = AppState()

    var body: some View {
        BossBody(iconName: name, isDead: $appState.draygonDead)
    }
}

So I did make it a LITTLE reusable.. I made the BossBody View so that I just didn't have that code repeating coz that just seemed not right.

Here's the relevant portion of State.swift, the rest of the file is just all the other icon states.. I haven't done them yet other than type the states in because I wanted to try with the bosses first since there's only 4.

import SwiftUI

@Observable
class AppState {
    // Bosses
    var ridleyDead: Bool = false
    var phantoonDead: Bool = false
    var kraidDead: Bool = false
    var draygonDead: Bool = false

Relevant portion of ContentView.swift:

import SwiftUI

struct ContentView: View {
    // Application State
    
    var body: some View {
        ZStack {
            Color.black.edgesIgnoringSafeArea(.all)
            HStack(spacing: 20) {
                bosses
                itemGrid
                gameOptions
            }
        }
        .padding(0)
    }
}

private var bosses: some View {
    VStack(spacing: 30) {
        BossRidley()
        BossPhantoon()
        BossKraid()
        BossDraygon()
    }
    .padding(0)
}

And finally, the main App swift file where the reset function is written:

import SwiftUI

@main
struct SimpleTrackerApp: App {
    @State var appState = AppState()
    
    func resetTracker() {
        print(appState.ridleyDead)
        print(appState.phantoonDead)
        print(appState.kraidDead)
        print(appState.draygonDead)
        appState.ridleyDead = false
        appState.phantoonDead = false
        appState.kraidDead = false
        appState.draygonDead = false
        print(appState.ridleyDead)
        print(appState.phantoonDead)
        print(appState.kraidDead)
        print(appState.draygonDead)
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandGroup(replacing: .newItem) {
                Button("Reset Tracker") {
                    resetTracker()
                }
                .keyboardShortcut("R", modifiers: [.command])
            }
        }
    }
}

Solution

  • Every time you call AppState() you create a new instance, one does not know anything about the other.

    You can pass a shared state into the environment in SimpleTrackerApp

    ContentView()
        .environment(appState)
    

    Then replace all the other

    @State var appState = AppState()
    

    and

    var appState = AppState()
    

    With

    @Environment(AppState.self) private var appState 
    

    Something to know that when you need a Binding for a value type property you can add

    var body: some View {
    
        @Bindable var appState = appState
    

    to the body and then access the property with

    $appState.someProperty
    

    https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro

    https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app