SwiftUI + Combine, using Models and ViewModels together

1.3k views Asked by At

I'm very new to Swift and I am currently trying to learn by building a rent splitting app with SwiftUI + Combine. I want to follow the MVVM pattern and am trying to implement this. At the moment I have the following Model, ViewModel and View files:

Model:

import Foundation
import Combine

struct InputAmounts {
    var myMonthlyIncome : Double
    var housemateMonthlyIncome : Double
    var totalRent : Double
}

ViewModel (where I have attempted to use the data from the Model to conform to the MVVM pattern, but I am not sure I have done this in the cleanest way/correct way so please correct me if wrong)

import Foundation
import Combine

class FairRentViewModel : ObservableObject {
    
    private var inputAmounts: InputAmounts

    init(inputAmounts: InputAmounts) {
        self.inputAmounts = inputAmounts
    }
   
    var yourShare: Double {
        inputAmounts.totalRent = Double(inputAmounts.totalRent)
        inputAmounts.myMonthlyIncome = Double(inputAmounts.myMonthlyIncome)
        inputAmounts.housemateMonthlyIncome = Double(inputAmounts.housemateMonthlyIncome)
        let totalIncome = Double(inputAmounts.myMonthlyIncome + inputAmounts.housemateMonthlyIncome)
        let percentage = Double(inputAmounts.myMonthlyIncome / totalIncome)
        let value = Double(inputAmounts.totalRent * percentage)

        return Double(round(100*value)/100)
    }
}

And then am trying to pass this all to the View:

import SwiftUI
import Combine

struct FairRentView: View {
    
    @ObservedObject private var viewModel: FairRentViewModel
    
    init(viewModel: FairRentViewModel){
        self.viewModel = viewModel
    }
    
    var body: some View {
        NavigationView {
            Form {
                Section(header: Text("Enter the total monthly rent:")) {
                    TextField("Total rent", text: $viewModel.totalRent)
                        .keyboardType(.decimalPad)
                }
                
                Section(header: Text("Enter your monthly income:")) {
                    TextField("Your monthly wage", text: $viewModel.myMonthlyIncome)
                        .keyboardType(.decimalPad)
                }
                
                Section(header: Text("Enter your housemate's monthly income:")) {
                    TextField("Housemate's monthly income", text: $viewModel.housemateMonthlyIncome)
                        .keyboardType(.decimalPad)
                }
                Section {
                    Text("Your share: £\(viewModel.yourShare, specifier: "%.2f")")
                }
            }
            .navigationBarTitle("FairRent")
        }
    }
}

struct FairRentView_Previews: PreviewProvider {
    static var previews: some View {
        let viewModel = FairRentViewModel(inputAmounts: <#InputAmounts#>)
        FairRentView(viewModel: viewModel)
    }
}

I am getting the build errors with the View: "Value of type 'ObservedObject.Wrapper' has no dynamic member 'totalRent' using key path from root type 'FairRentViewModel'"

"Value of type 'ObservedObject.Wrapper' has no dynamic member 'myMonthlyIncome' using key path from root type 'FairRentViewModel'"

"Value of type 'ObservedObject.Wrapper' has no dynamic member 'housemateMonthlyIncome' using key path from root type 'FairRentViewModel'"

My questions are:

  • What does this error mean and please point me in the right direction to solve?
  • Have I gone completely the wrong way at trying to implement the MVVM pattern here?

As I said I am a Swift beginner just trying to learn so any advice would be appreciated.

UPDATE IN RESPONSE TO ANSWER

  var yourShare: String {
        inputAmounts.totalRent = (inputAmounts.totalRent)
        inputAmounts.myMonthlyIncome = (inputAmounts.myMonthlyIncome)
        inputAmounts.housemateMonthlyIncome = (inputAmounts.housemateMonthlyIncome)
        var totalIncome = Double(inputAmounts.myMonthlyIncome) 0.00 + Double(inputAmounts.housemateMonthlyIncome) ?? 0.00
        var percentage = Double(myMonthlyIncome) ?? 0.0 / Double(totalIncome) ?? 0.0
        var value = (totalRent * percentage)
        
        return FairRentViewModel.formatter.string(for: value) ?? ""
    }

I am getting errors here that "Value of optional type 'Double?' must be unwrapped to a value of type 'Double'" which I thought I was achieving with the ?? operands?

1

There are 1 answers

9
Joakim Danielson On BEST ANSWER

Your view model should have properties for each of the model properties you want to work with in the view and the view model should also be responsible for converting them to a format suitable for the view. The properties should be marked as @Published to so that the view gets updated if they are changed.

For example

@Published var myMonthlyIncome: String

and this is as you see a String and we can convert it in the init

myMonthlyIncome = FairRentViewModel.formatter.string(for: inputAmounts.myMonthlyIncome) ?? ""

Here is my complete version of the view model

final class FairRentViewModel : ObservableObject {
    private static let formatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        return formatter
    }()

    private var inputAmounts: InputAmounts

    @Published var myMonthlyIncome: String
    @Published var housemateMonthlyIncome: String
    @Published var totalRent: String

    init(inputAmounts: InputAmounts) {
        self.inputAmounts = inputAmounts
        myMonthlyIncome = FairRentViewModel.formatter.string(for: inputAmounts.myMonthlyIncome) ?? ""
        housemateMonthlyIncome = FairRentViewModel.formatter.string(for: inputAmounts.housemateMonthlyIncome) ?? ""
        totalRent = FairRentViewModel.formatter.string(for: inputAmounts.totalRent) ?? ""
    }

    var yourShare: String {
        let value = inputAmounts.totalRent * inputAmounts.myMonthlyIncome / (inputAmounts.myMonthlyIncome + inputAmounts.housemateMonthlyIncome)

        return FairRentViewModel.formatter.string(for: Double(round(100*value)/100)) ?? ""
    }

    func save() {
        inputAmounts.myMonthlyIncome = FairRentViewModel.formatter.number(from: myMonthlyIncome)?.doubleValue ?? 0
        //...
    }
}

You should do something similar for yourShare and I the save method is just a simple example of how to update the model from the view if you tie the function to a Button action or similar.

Also note that the number style I used for the formatter is just a guess, you might need to change that. And it is recommended to work with Decimal instead of Double when dealing with money.