Search code examples
swiftmvvmviewmodelswiftuixcode11

SwiftUI ViewModel doesn't update computed variables


I have a SwiftUI view class that was able to update its own Text view as TextFields with bindable values were updated by the user. The problem was that all of the variables were contained within the View class itself. As soon as I extracted the variables to a view model class however the calculated fields no longer update as the bindable values are updated. Here's the (non-updating) code:

struct KeView: View {
    var vm = KeViewModel()

    var body: some View {
        return VStack {
            Image("ke")
            InputFieldView(category: Localizable.weaponAp(), input: vm.$ap)
            InputFieldView(category: Localizable.targetArmor(), input: vm.$targetArmor)
            InputFieldView(category: Localizable.weaponRange(), input: vm.$weaponRange)
            InputFieldView(category: Localizable.targetRange(), input: vm.$targetRange)
            Text(vm.damageString)
            .foregroundColor(Color.white)
            .padding()
                .background(vm.damageColor)
            .frame(maxHeight: .infinity)
        }
    }
}


struct KeView_Previews: PreviewProvider {
    static var previews: some View {
        KeView()
    }
}

struct KeViewModel {
    @State var ap = ""
    @State var targetArmor = ""
    @State var targetRange = ""
    @State var weaponRange = ""

    var damageColor: Color {
        if damageString.contains(Localizable.outOfRange()) { return Color.red }
        if damageString.contains(Localizable.inefficient()) { return Color.black }
        let d = damageString.split(separator: " ").last ?? ""
        if (Double(d) ?? 0) < 10 { return Color.blue }
        return Color.red
    }

    var damageString : String {
            guard let ap = Double(ap),
                let weaponRange = Double(weaponRange),
                let targetRange = Double(targetRange),
                let targetArmor = Double(targetArmor) else {
                    return Localizable.damagePrefix() + " 0"
        }
            if (weaponRange < targetRange){
                return Localizable.outOfRange()
            } else {
                let difference = (weaponRange - targetRange) / 175
                //print("Difference is equal to",difference)
                let actualAp = ap + difference
                //print("actual AP is equal to",actualAp)
                if (actualAp < targetArmor){
                    return Localizable.inefficient()
                } else if (targetArmor == 0){
                    return Localizable.damagePrefix()
                        + "\(round(actualAp * 2))"
                } else {
                    return Localizable.damagePrefix()
                        + " \(round((actualAp - Double(targetArmor)) / 2 + 1.0))"
                }
            }
    }
}

And here's the code that is able to update as the user inputs the values:

struct KeView: View {
    @State var ap = ""
    @State var targetArmor = ""
    @State var targetRange = ""
    @State var weaponRange = ""

    var damageColor: Color {
        if damageString.contains(Localizable.outOfRange()) { return Color.red }
        if damageString.contains(Localizable.inefficient()) { return Color.black }
        let d = damageString.split(separator: " ").last ?? ""
        if (Double(d) ?? 0) < 10 { return Color.blue }
        return Color.red
    }

    var damageString : String {
            guard let ap = Double(ap),
                let weaponRange = Double(weaponRange),
                let targetRange = Double(targetRange),
                let targetArmor = Double(targetArmor) else {
                    return Localizable.damagePrefix() + " 0"
        }
            if (weaponRange < targetRange){
                return Localizable.outOfRange()
            } else {
                let difference = (weaponRange - targetRange) / 175
                //print("Difference is equal to",difference)
                let actualAp = ap + difference
                //print("actual AP is equal to",actualAp)
                if (actualAp < targetArmor){
                    return Localizable.inefficient()
                } else if (targetArmor == 0){
                    return Localizable.damagePrefix()
                        + "\(round(actualAp * 2))"
                } else {
                    return Localizable.damagePrefix()
                        + " \(round((actualAp - Double(targetArmor)) / 2 + 1.0))"
                }
            }
    }

    var body: some View {
        return VStack {
            Image("ke")
            InputFieldView(category: Localizable.weaponAp(), input: $ap)
            InputFieldView(category: Localizable.targetArmor(), input: $targetArmor)
            InputFieldView(category: Localizable.weaponRange(), input: $weaponRange)
            InputFieldView(category: Localizable.targetRange(), input: $targetRange)
            Text(String(self.damageString))
            .foregroundColor(Color.white)
            .padding()
            .background(damageColor)
            .frame(maxHeight: .infinity)
        }
    }
}


struct KeView_Previews: PreviewProvider {
    static var previews: some View {
        KeView()
    }
}

This seems silly that I can't extract that variables to an external struct and would prefer to have a clean separation between my data and my view. Any assistance is appreciated. Finally, if you'd like to build and run the project yourself, it's available in it's entirety at https://github.com/jamesjmtaylor/wrd-ios


Solution

  • Here is the full codes you may interest :

      window.rootViewController = UIHostingController(rootView: KeView().environmentObject(KeViewModel())
    
    class KeViewModel : ObservableObject {
    @Published var ap = ""
    @Published var targetArmor = ""
    @Published var targetRange = ""
    @Published var weaponRange = ""
    
    var damageColor: Color {
        if damageString.contains(Localizable.outOfRange()) { return Color.red }
        if damageString.contains(Localizable.inefficient()) { return Color.black }
        let d = damageString.split(separator: " ").last ?? ""
        if (Double(d) ?? 0) < 10 { return Color.blue }
        return Color.red
    }
    
    var damageString : String {
            guard let ap = Double(ap),
                let weaponRange = Double(weaponRange),
                let targetRange = Double(targetRange),
                let targetArmor = Double(targetArmor) else {
                    return Localizable.damagePrefix() + " 0"
        }
            if (weaponRange < targetRange){
                return Localizable.outOfRange()
            } else {
                let difference = (weaponRange - targetRange) / 175
                //print("Difference is equal to",difference)
                let actualAp = ap + difference
                //print("actual AP is equal to",actualAp)
                if (actualAp < targetArmor){
                    return Localizable.inefficient()
                } else if (targetArmor == 0){
                    return Localizable.damagePrefix()
                        + "\(round(actualAp * 2))"
                } else {
                    return Localizable.damagePrefix()
                        + " \(round((actualAp - Double(targetArmor)) / 2 + 1.0))"
                }
            }
    }
    }
    
    
    struct KeView: View {
    @EnvironmentObject var model: KeViewModel
    var body: some View {
        return VStack {
            Image("ke")
            InputFieldView(category: Localizable.weaponAp(), input: $model.ap)
            InputFieldView(category: Localizable.targetArmor(), input: $model.targetArmor)
            InputFieldView(category: Localizable.weaponRange(), input: $model.weaponRange)
            InputFieldView(category: Localizable.targetRange(), input: $model.targetRange)
            Text(String(model.damageString))
            .foregroundColor(Color.white)
            .padding()
            .background(model.damageColor)
            .frame(maxHeight: .infinity)
        }
    }
    
    }
    

    The model has to be a class because it needs to conform observable. All variables needs to be @published which make things easier.