Keeping data in SwiftUI MVVM hierarchy without persistence

Viewed 279

I am new to SwiftUI, coming from Java. I know MVC pattern and try to understand MVVM in SwiftUI. It kindly works, but I don't know how to keep data between View hierarchy... also the View should not know anything about models, so I tried using the ViewModels passing around the views. Each ViewModel than should manage their Model.

First Question: Is this a good way to implement MVVM in SwiftUI like I did? Second: Anyone here who could help me out to divide strictly Model from View and getting this code working?

For now I can add a Garage, go into GarageView and add certain Car models. After switching back to GarageListView and adding another Garage model all Car models from first Garage model are gone... what am I doing wrong? ;(

import SwiftUI

// Models
struct Car: Identifiable, Codable {
    var id = UUID()
    var name: String
}

struct Garage: Identifiable, Codable {
    var id = UUID()
    var name: String
    var cars: [Car] = []
}

// ViewModels
class GarageListViewModel: ObservableObject {
    @Published var garages: [Garage] = []
}

class GarageViewModel: ObservableObject {
    @Published var garage: Garage

    init(garage: Garage) {
        self.garage = garage
    }
}

class CarViewModel: ObservableObject {
    @Published var car: Car

    init(car: Car) {
        self.car = car
    }
}

// Views
struct GarageListView: View {
    @ObservedObject var viewModel: GarageListViewModel

    var body: some View {
        List {
            ForEach(viewModel.garages) { garage in
                NavigationLink {
                    GarageView(viewModel: GarageViewModel(garage: garage))
                } label: {
                    Text(garage.name)
                }
            }
        }
        .toolbar {
            Button {
                viewModel.garages.append(Garage(name: "My Garage"))
            } label: {
                Label("Add", systemImage: "plus")
            }
        }
    }
}

struct GarageView: View {
    @ObservedObject var viewModel: GarageViewModel

    var body: some View {
        List {
            ForEach(viewModel.garage.cars) { car in
                NavigationLink {
                    CarDetailView(viewModel: CarViewModel(car: car))
                } label: {
                    Text(car.name)
                }
            }
        }
        .toolbar {
            Button {
                viewModel.garage.cars.append(Car(name: "My Car"))
            } label: {
                Label("Add", systemImage: "plus")
            }
        }
    }
}

struct CarDetailView: View {
    @ObservedObject var viewModel: CarViewModel

    var body: some View {
        Text(viewModel.car.name)
    }

}

struct ContentView: View {
    @StateObject var viewModel = GarageListViewModel()

    var body: some View {
        NavigationView {
            GarageListView(viewModel: viewModel)
        }
    }
}
2 Answers

I would switch from using struct to class. Structs are passed by value, so you'd never get the exact same struct but a copy of it. By adding a new GarageViewModel, SwiftUI will rerender the List, that's why you lose your garages' cars when you add a new garage.

On a side note, you also forgot to add a NavigationView around your GarageListView and the List in the GarageView, rendering the NavigationLinks useless.

It can be a bit overwhelming to get into MVVC. I would definitely go through Apple's SwiftUI Tutorials as they subtly go through how to set up an MVVC.

As @loremipsum said, you should never init your ViewModel in a view struct because structs are immutable value types, and SwiftUI simply discards the view and recreates it, recreating EVERYTHING inside of it. Also, as you said yourself, your views shouldn't know about the inner workings of the model, so the model should change itself. Therefore, adding garages and cars should be handled in the ViewModel.

Another thing with the ViewModel; you do not need one for EACH view. In this case, one GaragesViewModel can handle all your data. And it should. Apple talks about having a "single source of truth'. That means that there is ONE place your views can go and get the data. That is your ViewModel. Unless you have a wholly unrelated set of data, keep it in one model.

Lastly, before some example code, not every view needs to have the model, or even a mutable parameter. Remember, views are disposable, and with them their parameters are disposed of. If you mutate the model, you will get a new view, so let constants are fine to use. I left your models alone and removed the CarViewModel. It is not needed in this example.

// ViewModel
class GaragesViewModel: ObservableObject {
    
    // This initializes the model IN the model and provides a Singleton.
    // You can refer to it anywhere you need to in code.
    static var shared = GaragesViewModel()
    
    @Published var garages: [Garage] = []
    
    // Data manipulation in the model
    public func addNewGarage() {
        garages.append(Garage(name: "My Garage"))
    }
    
    public func add(car: Car, to garage:Garage) {
        // The guard will stop the func if garage is not in garages.
        guard let index = garages.firstIndex(where: { $0.id == garage.id }) else { return }
        garages[index].cars.append(car)
    }
}

// Views
struct ContentView: View {
    var body: some View {
        NavigationView {
            GarageListView()
        }
    }
}

struct GarageListView: View {
    // Since this is the only view that needs the model, it is called here.
    // There is no need to pass it in.
    @ObservedObject var viewModel = GaragesViewModel.shared

    var body: some View {
        List {
            ForEach(viewModel.garages) { garage in
                NavigationLink {
                    GarageView(garage: garage)
                } label: {
                    Text(garage.name)
                }
            }
        }
        .toolbar {
            Button {
                GaragesViewModel.shared.addNewGarage()
            } label: {
                Label("Add", systemImage: "plus")
            }
        }
    }
}

struct GarageView: View {

    // GarageView doesn't mutate garage, so it can be a let constant. KISS principal.
    let garage: Garage
    
    var body: some View {
        List {
            ForEach(garage.cars) { car in
                NavigationLink {
                    CarDetailView(car: car)
                } label: {
                    Text(car.name)
                }
            }
        }
        .toolbar {
            Button {
                // if you wanted, you could add a func to car to return a new "My Car"
                // and further separate the model.
                GaragesViewModel.shared.add(car: Car(name: "My Car"), to: garage)
            } label: {
                Label("Add", systemImage: "plus")
            }
        }
    }
}

struct CarDetailView: View {
    
    // CarDetailView doesn't mutate car, so it can be a let constant.
    let car: Car
    
    var body: some View {
        Text(car.name)
    }
}

Welcome to StackOverflow! You just got yourself too far into the weeds trying to implement this, but it was a good first shot.

edit:

You can add something like:

public func changeName(of car: Car, in garage: Garage, to name: String) {
    guard let garageIndex = garages.firstIndex(where: { $0.id == garage.id }),
          let carIndex = garage.cars.firstIndex(where: { $0.id == car.id }) else { return }
    garages[garageIndex].cars[carIndex].name = name
}

to your model, and then use it like this:

struct CarDetailView: View {
    
    // Make car an @State variable so you can change it, and then pass the change to the func in the model.
    @State var car: Car
    let garage: Garage
    
    var body: some View {
        VStack {
            TextField("", text: $car.name)
                .padding()
            Button {
                GaragesViewModel.shared.changeName(of: car, in: garage)
            } label: {
                Text("Save")
            }
        }
    }
}

Now, this is for learning purposes only. I would not use this in a shipping app as you have no persistence of your data. If you need to create a database of cars and garages, etc. I would use CoreData to track it, and it works a little differently than just using the structs and class we have here.

Also, if you have any more questions, you really need to make a new question. The purpose of StackOverflow is to get discrete answers to discrete questions, and so follow on questions are discouraged.

Related