Home > Software engineering >  SwiftUI MVVM With ObservedObject
SwiftUI MVVM With ObservedObject

Time:02-11

UPDATE (2/10/22 @ 11:30am CST):

I rewrote the workflow so that my viewModel conforms to the ObservableObject protocol and no longer conforms to the CreateUser class. Now my variables are updating properly (tested via print("\(viewModel.loadingStatus)") both before and after the loadingState value is set.

However, my view that uses a switch statement to show the current state of the view (i.e. switches on loadingState) does not update, even though the value of loadingState in the viewModel is being set correctly and I'm able to use that print statement to show that it's being set, in the view itself.

I even tried setting changing the viewModel's @Published var loadingState to read as:

@Published var loadingState: LoadingState = .loading {
    willSet {
        objectwillchange.send()
    }
}

Of course, according to Apple's docs, that's exactly the same behavior as @Published by itself, and it did nothing to solve the issue.

It seems as though everything else is working properly, except for the view switch case not updating when loadingState changes in the viewModel.


Original Post:

I have a multi-page form where all of the views of the form share the same ObservableObject(s). What I would like to do is split up each individual view into an MVVM workflow so that the UI stuff is in the view and the data stuff is in the viewModel.

The issue is that since all of these views share the same ObservableObject(s), and the functions in each of the views make heavy use of these ObservableObjects (storing variables, using other functions, etc.), the viewModel can't just be an ObservableObject of its own, because I would need to write a custom intializer for everything (and there are a lot of variables). This also means that in my NavigationLink as well as the view's preview, I would have to call to every single one of those individually initialized vars.

CreateUser (the shared ObservableObject that has all of the form variables as @Published vars) is where I am setting all of the form variables as the user moves through the pages. The best I came up with was to conform my viewModel to CreateUser instead of simply ObservableObject. That seemed to work and after quite a bit of recoding, there were no more errors and my app built. That being said, as it's not working, I have a feeling that this is not the correct way to do it.

Unfortunately, it seems that the viewModel is not updating any of the CreateUser variables, nor is it accurately referencing the CreateUser variables that are being set. I tested this by using page 3 of the form (where the username is entered) as the test for the MVVM architecture, and asked it to print the variable from page 2. It returned the default value instead of what was actually set.

This means my data is not being passed through properly and my variables are not being referenced the way they should, so page 3 (the MVVM view) is stuck showing the loading state, even though it should show the loaded state once the json fetch is finished.

What is the proper procedure for instituting an MVVM workflow when you have multiple other ObservedObjects being utilized in each view? This is for the latest SwiftUI/XCode for iOS 15 .

Here's a bit of code to further explain (obviously very stripped down for this post, but you get the idea if you read my explanation above):

Main View:

import Combine
import SwiftUI

struct UsernameView: View {

@StateObject var viewModel: ViewModel

    var body: some View {
        VStack {
            List {
                switch viewModel.loadingState {
                    case .loading:
                        Section {
                            Text("I am loading")
                        }

                    case .loaded:
                        Section {
                            Text("I am loaded")
                        }

                    case .failed:
                        Section {
                            Text("Something went wrong")
                        }
                }
            }
        }
        .task {
            viewModel.userActivity = Date.now
            jsonFetch.sink (receiveCompletion: { completion in
                switch completion {
                    case .failure:
                        viewModel.loadingState = .failed
                    case .finished:
                        viewModel.loadingState = .loaded
                    }
                },
                receiveValue: { loadedData in
                    viewModel.userData = loadedData
                }).store(in: &viewModel.dataTransfer.requests)
        }
    }
}

View Model:

import Combine
import SwiftUI

extension UsernameView {
    class ViewModel: CreateUser {

        @ObservedObject var textBindingManager = TextBindingManager(limit: 15)
        
        @Published var loadingState: LoadingState = .loading
        @Published var userData: [Usernames] = []
        @Published var usernameExists: [UsernameExists] = []

        func editingChanged(_ value: String) {
            username = String(value.prefix(textBindingManager.characterLimit))
            
            let jsonFetchUserExistsURL = URL(string: "https://api.foo.com/userselect?user=\(user)")
            let jsonFetchUserExistsTask = dataTransfer.jsonFetch(jsonFetchUserExistsURL, defaultValue: [UsernameExists]())
            
            if username.count >= 8 {
                guard network.isNetworkActive else { loadingAlert = true; return }
                Task {
                    status = .loading
                    usernameExists.removeAll()
                    jsonFetchUserExistsTask.sink (receiveCompletion: { completion in
                        switch completion {
                        case .failure:
                            self.loadingState = .failed
                        case .finished:
                            return
                        }
                    },
                    receiveValue: { loadedUserExists in
                        self.usernameExists = loadedUserExists
                    }).store(in: &dataTransfer.requests)
                }
            }
        }

extension UsernameView.ViewModel {
    enum LoadingState {
        case loading, loaded, failed
    }
}

CreateUser:

import Combine
import SwiftUI

class CreateUser: ObservableObject {
    
    @ObservedObject var dataTransfer = NetworkTransfer()
    let network: NetworkMonitor
    
    init(network: NetworkMonitor) {
        self.network = network
    }
    
    @Published var userActivity = Date.now
    @Published var userActivityAlert = false
    @Published var birthday = Date()
    @Published var username = ""
}

CodePudding user response:

We don't use MVVM in SwiftUI, SwiftUI is managing our Views on screen like UILabels etc for us, we work with the view data in View structs, hence no need for extra view model objects. And we don't use sink in our ObservableObjects we instead assign to @Published which completes the pipleline and ties its life-cycle to the object (no need for cancellables).

Since you are using async/await in a task you don't even need Combine's ObservableObject anymore and can do it all in structs (which are SwiftUI's primary encapsulation mechanism).

Take a look at task(id:priority:) and you'll notice it can re-run the task when ever the id param changes and cancelling previous tasks. It's now as simple as setting result of your await to an @State which can either be the data array or a custom struct containing the data and other related vars.

We do however use an ObservableObject reference type to manage the life cycle of our value-type model data, that is a different story though.

CodePudding user response:

I think it would be important to take a look at your NavigationLinks and way you are passing down the view model to the multiple pages of the form from a parent view. I don't see how this is done in your post, however, so my answer will be a guess.

My assumption is that UsernameView is one of the multiple pages of your form, and all of the pages in your form all use @StateObject var viewModel: ViewModel. This would be a red flag, if you want to share the same view model across all of them. @StateObject signifies that the view owns that object, and will hold onto it for the lifetime of the view. @ObservedObject on the other hand is a reference to an ObservableObject that should be passed into the view. If the multiple pages of your form all use @StateObject, then it's possible that the way you've constructed things, you're getting distinct instantiations of your view model, which is why you're seeing default values in other pages for a previous page's input.

Here is a simple, high-level overview of how I might go about it.

struct ParentView: View {
    @StateObject var viewModel = ViewModel()
    @State var page = 1
    var body: some View {
        switch page {
        case 1:
            ChildView1(viewModel: self.viewModel)
        case 2:
            ChildView2(viewModel: self.viewModel)
        }
    }
}
struct ChildView1: View {
    @ObservedObject var viewModel: ViewModel
    var body: some View {
        Text(viewModel.username)
    }
}
struct ChildView2: View {
    @ObservedObject var viewModel: ViewModel
    var body: some View {
        Text(viewModel.username)
    }
}

The example would show the same username in both child views. The parent did a single instantiation of the view model via the @StateObject, and passed it down to the children without re-instantiating the model into the @ObservedObjects. This way, you will have the same object in memory correctly shared among the views.

CodePudding user response:

I ended up forgetting about trying to use an MVVM workflow. Unfortunately, though I recoded everything and got it pretty close to working, the nested ObservedObjects do not update the view. After quite a bit of research I found several articles stating the same.

Instead, I decided to follow malhal's suggestion of breaking everything up into separate views, structs, and classes. It's a serious pain the ass, but hopefully it'll make the code a bit easier to work with as things progress.

I appreciate everyone's input.

  • Related