ios – How to properly use Observables, @Binding, and @Published


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.

I’ll try and provide all the relevant information in the views that I’ve got working:

This is my HomeView. It contains CharacterSummaryView (which is working properly), a set of LevelViews (from the Character.Levels), and then when you press the LevelView it displays LevelUpView for that Level.

struct HomeView: View {
    @ObservedObject var character: Character
    
    var body: some View {
        VStack {
            NavigationStack {
                CharacterSummaryView(character: character)
                List($character.levels, id: \.levelNum) {level in
                    NavigationLink(destination: LevelUpView(level: level, character: character)) {
                        VStack {
                            LevelView(level:  level.wrappedValue)
                        }
                    }
                }
            }
        }
    }
}

Here is LevelView, which is where the issues lie. When I update the skillCount[index].skillsUsed value in the LevelUpView, it does not get correctly propagated to this view:

import SwiftUI
import OrderedCollections

struct LevelView: View {
    @ObservedObject var level: Level
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("LEVEL \(level.levelNum)")
                .font(.caption)
            if let skillcount = level.appliedSkill, skillcount.count > 0 {
                VStack {
                    ForEach(skillcount.indices, id : \.self) {index in
                        Text("Skill Bonus - \(skillcount[index].skillsUsed)/\(skillcount[index].skillsAllowed)")
                    }
                }
            }
        }
    }
}

Here is LevelUpView (ignore the double Vstacks — I removed some code that is not relevant to the problem at hand). Essentially, it’s looking through the level being passed from HomeView to it, and then making a section for each of the appliedSkills that expands on being pressed:

import SwiftUI
import OrderedCollections
import WrappingHStack

struct LevelUpView: View {
    @Binding var level: Level
    @ObservedObject var character: Character
    @State private var activeSkillBonus: UUID?
    
    
    var body: some View {
        GeometryReader { geometry in
            let wid = geometry.size.width * 0.50
            
            ScrollView {
                VStack (alignment: .leading) {
                    VStack (alignment: .leading) {
                        if let appliedSkill = level.appliedSkill, appliedSkill.count > 0 {
                            VStack (alignment: .leading) {
                                ForEach(appliedSkill.indices, id: \.self) { index in
                                    SkillBoostsView(character: character, skillBoost: appliedSkill[index], level: level.levelNum, wid: wid, showSkillBonuses: $activeSkillBonus)
                                        .padding(.bottom, 10)
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

Here is the SkillBoostsView. It has a box/button that when pressed expands to show a SkillPillboxView for each skill in the array of skills. Of note, the skillBoost.skillsUsed value is updating here correctly, but is just not propagating up to the HomeView > LevelView:

import SwiftUI
import WrappingHStack

struct SkillBoostsView: View {
    
    @ObservedObject var character: Character
    @ObservedObject var skillBoost: LevelSkills
    var level: Int
    var wid: CGFloat
    @Binding var showSkillBonuses: UUID?
    
    var body: some View {
        VStack (alignment: .leading)
        {
            HStack {
                VStack {
                    Text("Skill Boosts - \(skillBoost.skillsUsed)/\(skillBoost.skillsAllowed)")
                    Text(skillBoost.skillType.rawValue)
                    Text(skillBoost.source.rawValue)
                }
                .frame(width: wid)
                .foregroundStyle(Color.white)
                .padding()
                .background(RoundedRectangle(cornerRadius: 20)
                    .foregroundStyle( skillBoost.skillsUsed < skillBoost.skillsAllowed ? Color.oxblood : Color.green))
                .onTapGesture{
                    withAnimation {
                        if showSkillBonuses == skillBoost.id {
                            showSkillBonuses = nil
                        } else {
                            showSkillBonuses = skillBoost.id
                        }
                    }
                }
            }
            HStack {
                if showSkillBonuses == skillBoost.id  {
                    WrappingHStack {
                        ForEach(Array(skillBoost.skills.indices), id: \.self) { index in
                            SkillPillboxView(skill: skillBoost.skills[index], character: character, skillBoosts: skillBoost, level: level)
                                .frame(width: wid * 0.8)
                        }
                    }
                    .transition(.asymmetric(insertion: .push(from: .top), removal: .push(from: .bottom)))
                    
                }
            }
            .animation(.easeInOut, value: showSkillBonuses)
        }
    }
}

And here is the SkillPillboxView, which is also working more or less as intended:

import SwiftUI
struct SkillPillboxView: View {
    @ObservedObject var skill: Skill
    @ObservedObject var character: Character
    @ObservedObject var skillBoosts: LevelSkills
    var level: Int

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(skill.displayName)
                    .font(.headline)
                HStack {
                    Text("\(skill.associatedAbility)")
                        .font(.caption)
                    Text("|")
                    Text(rankString(skillLevelCalc))
                        .font(.caption)
                }
            }
            Text("+\(skillBonusCalc)")
        }
        .foregroundStyle(skill.hasIncreased ? Color.red : Color.primary)
        .padding(10)
        .background(backgroundColor(for: skillLevelCalc))
        .cornerRadius(10)
        .onTapGesture {
            skillToggle()
        }
        .transition(.scale)
        .animation(.easeInOut, value: skill.hasIncreased)
    }
    
    // Computed property to calculate the skill level
    private var skillLevelCalc: Int {
        return skillLevel(for: skill.name.rawValue, in: character, level: level)
    }
    
    private var skillBonusCalc: Int {
        var bonus = 0
        if skillLevelCalc > 0 {
            bonus += level
            bonus += 2*skillLevelCalc
        }
        bonus += calculateAbilityScoreAndModifier(character.abilityScores[skill.associatedAbility] ?? 0).modifier
        return bonus
    }
    
    private func skillToggle() {
        var maxTrain: Int = 0
        
        if skillBoosts.skillType == .training {
            maxTrain = 1
        } else {
            switch level {
            case 0...6: maxTrain = 2
            case 7...14: maxTrain = 3
            case 15...20: maxTrain = 4
            default: maxTrain = 1
            }
        }
        
        if skill.hasIncreased {
            skill.hasIncreased.toggle()
            skillBoosts.skillsUsed -= 1
        }
        else if !skill.hasIncreased && skillBoosts.skillsUsed < skillBoosts.skillsAllowed && skillLevel(for: skill.name.rawValue, in: character, level: level) < maxTrain {
            skill.hasIncreased.toggle()
            skillBoosts.skillsUsed += 1
        }
    }
    
    private func rankString(_ trainingLevel: Int) -> String {
            switch trainingLevel {
            case 0: return "Untrained"
            case 1: return "Trained"
            case 2: return "Expert"
            case 3: return "Master"
            case 4: return "Legendary"
            default: return "Unknown"
            }
        }
    }

    private func backgroundColor(for level: Int) -> Color {
        switch level {
            case 0: return .gray
            case 1: return .green
            case 2: return .blue
            case 3: return .purple
            case 4: return .orange 
            default: return .black
        }
    }
}

What am I missing? If necessary, I can provide information about the Character, Level, LevelSkills, or Skill class that are used in each of these as well — I will head off and state in advance that they are all ObservableObjects and that they all have the @Published for all the values that are mentioned in these views.

Any idea why the Character.levels[x].appliedSkill[y].skillsUsed value isn’t propagating back up from the pillbox to the top level?

Latest articles

spot_imgspot_img

Related articles

Leave a reply

Please enter your comment!
Please enter your name here

spot_imgspot_img