skip to Main Content

I’m working on the creation of an iPhone application and am having a few issues with the nature of @Binding, @ObservedObject, and the @Published property. I was pretty sure I have this all right but apparently not since it’s still not working.

Here is a "minimal" example of what’s happening — if I reduce the layers of nesting views, then it starts working correctly.

import SwiftUI

//character
class TopLevelParent: ObservableObject  {
    var id: UUID = UUID()
    @Published var prop: [Child1] = [Child1(), Child1(), Child1()]
}

class TestState: ObservableObject {
    @Published var tlp: TopLevelParent = TopLevelParent()
}

//level
class Child1: ObservableObject, Identifiable {
    var id: UUID = UUID()
    @Published var prop: [Child2] = [Child2(), Child2()]
}

//levelskill
class Child2: ObservableObject, Identifiable {
    var id: UUID = UUID()
    @Published var clicked: Int = 0
    @Published var maxClick: Int = 3
    @Published var prop: [YoungestChild] = [YoungestChild(), YoungestChild(), YoungestChild(), YoungestChild(), YoungestChild()]
}

//skill
class YoungestChild: ObservableObject, Identifiable {
    var id: UUID = UUID()
    @Published var name: String = "name"
    @Published var hasIncreased: Bool = false
}

struct TestView: View {
    @EnvironmentObject var state: TestState
    
    var body: some View {
        VStack {
            NavigationStack {
                List {
                    ForEach(state.tlp.prop, id:.id) { prop in
                        NavigationLink(destination: TestView2(testclass2: prop)) {
                            VStack {
                                TestView5(testclass2: prop)
                            }
                        }
                    }
                }
            }
        }
    }
}

struct TestView2: View {
    @ObservedObject var testclass2: Child1
    @State private var activeSkillBonus: UUID?
    
    var body: some View {
        VStack (alignment: .leading) {
            let testclass3 = testclass2.prop
            VStack (alignment: .leading) {
                ForEach(testclass3) { index in
                    TestView3(testclass3: index, activeSkillBonuses: $activeSkillBonus)
                }
            }
        }
    }
}



struct TestView3: View {
    @ObservedObject var testclass3: Child2
    @Binding var activeSkillBonuses: UUID?
    var body: some View {
        VStack (alignment: .leading)
        {
            VStack {
                Text("Skill Boosts - (testclass3.clicked)/(testclass3.maxClick)")
                Text("(testclass3.id)")
            }
            .foregroundStyle(Color.white)
            .background(RoundedRectangle(cornerRadius: 20)
                .foregroundStyle(Color.oxblood))
            .onTapGesture{
                withAnimation {
                    if activeSkillBonuses == testclass3.id {
                        activeSkillBonuses = nil
                    } else {
                        activeSkillBonuses = testclass3.id
                    }
                }
            }
            if activeSkillBonuses == testclass3.id  {
                VStack {
                    ForEach(testclass3.prop) { index in
                        TestView4(testclass4: index, testclass3: testclass3)
                    }
                }
            }
        }
    }
}

struct TestView4: View {
    @ObservedObject var testclass4: YoungestChild
    @ObservedObject var testclass3: Child2
    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(testclass4.name)
            }
        }
        .foregroundStyle(testclass4.hasIncreased ? Color.red : Color.primary)
        .background(testclass4.hasIncreased ? Color.green : Color.brown)
        .onTapGesture {
            testclass3.objectWillChange.send()
            if testclass4.hasIncreased {
                testclass3.clicked -= 1
            } else
            {
                testclass3.clicked += 1
            }
            testclass4.hasIncreased.toggle()
        }
    }
}

struct TestView5: View {
    @ObservedObject var testclass2: Child1
    var body: some View {
        VStack {
            ForEach(testclass2.prop) {testclass3 in
                Text("Skill Bonus - (testclass3.clicked)/(testclass3.maxClick)")
            }
        }
    }
}
    
struct TestView_Previews: PreviewProvider {
    static var previews: some View {
        TestView()
            .environmentObject(TestState())
    }
}

Any idea why the TopLevelParent.Child1[x].Child2[y].YoungestChild value isn’t propagating from TestView4 to TestView5?

Essentially, TestView1 is the "Top level" view, and has a set of TestView5’s, one for each set of TopLevelParent.prop, set in a NagivationStack. When one of the TestView5’s in the NavigationStack is pressed, it opens a view that has TestView2, which shows a TestView3 for each Child1.Prop, and in each TestView3, it has a TestView4 for each Child2.Prop (or Child2.YoungestChild) that can be pressed, which updates a value for Child2, which value is being displayed in TestView3, as well as in TestView5. TestView3 updates with the value correctly, but TestView5 remains at the starting value (0/3 pressed).

2

Answers


  1. Chosen as BEST ANSWER

    If I update Child1 and Child2 with these changes, the code starts to work, and the value gets propagated to TestView5:

    //level
    class Child1: ObservableObject, Identifiable {
        var id: UUID = UUID()
        @Published var prop: [Child2] = [] {
            didSet {
                for child in prop {
                    child.parent = self
                }
            }
        }
        
        init() {
            self.prop = [Child2(), Child2()]
            for child in prop {
                child.parent = self
            }
        }
    }
    
    
    //levelskill
    class Child2: ObservableObject, Identifiable {
        weak var parent: Child1?
        var id: UUID = UUID()
        @Published var clicked: Int = 0 {
            didSet {
                parent?.objectWillChange.send()
            }
        }
        @Published var maxClick: Int = 3
        @Published var prop: [YoungestChild] = [YoungestChild(), YoungestChild(), YoungestChild(), YoungestChild(), YoungestChild()]
    }
    

    ChatGPT explains this:

    The issue is that TestView5 is not aware of changes made to Child2.clicked within TestView4 because TestView5 is observing Child1, which in turn contains an array of Child2. The @Published property wrapper only handles changes made directly to its wrapped value. When you modify a property within one of the Child2 objects, the Child1 object doesn't know about this change because the array reference itself hasn't changed, just one of the items within it.

    Which makes sense, but I'm not sure if that's the real reason because we all know that ChatGPT is not entirely reliable all the time. In any case, I have it working for now (but am absolutely open to improvements on the code)


  2. Use one source of truth:

    Make sure there’s one source of truth for your data. This often means having your data owned by a top level view or a shared EnvironmentObject.

    Here’s an example how you can demonstrate this:

    • first create a CharacterViewModel that holds the Character and applies different actions on it or the levels in it:
    @MainActor class CharacterViewModel: ObservableObject {
        @Published var character: Character
    
        init(character: Character) {
            self.character = character
        }
    
        // Add methods to modify character and levels here
    }
    
    • initialize the view model on a top level view, and pass it to child views using .environmentObject():
    struct HomeView: View {
        @StateObject private var charVM = CharacterViewModel(character: Character())
        
        var body: some View {
            VStack {
                NavigationStack {
                    CharacterSummaryView(character: charVM)
                    List($charVM.character.levels, id: .levelNum) { level in
                        NavigationLink(
                            destination: LevelUpView(level: level)
                                .environmentObject(charVM)
                        ) {
                            VStack {
                                LevelView(level: level.wrappedValue)
                            }
                        }
                    }
                }
            }
        }
    }
    
    • use the environment object in a child view:
    struct LevelUpView: View {
        @EnvironmentObject private var charVM: CharacterViewModel
    
        // ...
    }
    
    • but, as long as you do not use the Character directly in LevelUpView and not in SkillBoostsView either, then you wouldn’t even declare it there. Instead, you’d use it directly in SkillPillboxView:
    struct SkillPillboxView: View {
        @EnvironmentObject private var charVM: CharacterViewModel
    
        // ...
        
        // Computed property to calculate the skill level
        private var skillLevelCalc: Int {
            return skillLevel(for: skill.name.rawValue,
                              in: charVM.character,
                              level: level)
        }
    
        // ...
        
    }
    

    And so on…
    You’d probably want to store a property named currentLevel in CharacterViewModel to get rid of the rest level parameters you’re passing to children (and also declare charVM in LevelUpView and SkillBoostsView for this, or use another view model, or find another solution that fits your needs).

    This separation also ensures your code is clean and easy to debug by removing a lot of boilerplate code.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search