1

I am experimenting with SwiftUI and having a hard time figuring out the proper architecture for my app.

What I am trying to achieve is simple. I want to have an initial screen that either show a sign up screen or the home screen for a given user depending on current authentication state. I cannot figure out how to make the initial screen pick up changes from the sign up screen once authentication has taken place. Here's some relevant code:

struct InitialView: View {
    @EnvironmentObject var viewModel: InitialViewModel

    var body: some View {
        VStack {
            if viewModel.auth.identity != nil {
                NewHomeView()
            } else {
                SignInView()
            }
        }
    }
}

In the sign in view, I have a usual sign in form and when the user presses the sign in button, I want to send a login request to the backend and remember the resulting token.


class SignInViewModel: ObservableObject {
    private let auth: AuthStore
    private let signInApi: SignInApi
    private var cancellableSet: Set<AnyCancellable>

    // Input
    @Published var name: String = ""
    @Published var password: String = ""
    @Published var showSignUp: Bool = false

    // Output
    @Published var authSuccess: Bool = false

    init(auth: AuthStore, signInApi: SignInApi) {
        self.auth = auth
        self.signInApi = signInApi
        self.cancellableSet = Set<AnyCancellable>()
    }

    func signIn() {
        signInApi.signIn(email: name, password: password).sink(receiveCompletion: { _ in }) { response in
            self.auth.identity = Identity(
                person: Person(id: 1, name: "user", sex: nil, birthday: nil),
                token: response.token
            )
            self.authSuccess = true
        }.store(in: &cancellableSet)
    }
}

HOWEVER, this does not work. Even after clicking the sign in button, the initial view is not updated. Note that I am passing the same AuthStore to both view models.

  let auth = AuthStore()

        // Create the SwiftUI view that provides the window contents.
        let contentView = InitialView()
            .environmentObject(InitialViewModel(auth: auth))
            .environmentObject(SignInViewModel(auth: auth, signInApi: SignInApi()))

where AuthStore is defined as


class AuthStore: ObservableObject {
    @Published var identity: Identity? = nil
}

Ideally, I'd love to be able to 1) have each view be paired with its own VM 2) have each VM access global state via @EnvironmentObject. However, it seems that @EnvironmentObject is restricted to views only? If so, how do I access the global auth state from within each VM that requires it?

simplyblago
  • 11
  • 1
  • 3
  • Have you tried a little project with this tutorial ? https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-environmentobject-to-share-data-between-views There is two views by the way... exactly what you want to learn – LetsGoBrandon Feb 08 '20 at 18:55
  • Hey, I looked at that link before asking. I didn't go with it because it basically forces me to stop using MVVM and do all state management inside the view. In the language of the link you posted, I'd love to have two View Models with access to the same UserSettings (ideally injected once from within the SceneDelegate). Is that possible? – simplyblago Feb 08 '20 at 19:02
  • see the discussion below the answer there, you have to check what @Published wrapper is for, and how it works https://stackoverflow.com/questions/60119057/swiftui-propagating-change-notifications-through-nested-reference-types/60126962#60126962 – user3441734 Feb 08 '20 at 19:26
  • another question, which environment object will be accessible in your contentView ??? – user3441734 Feb 08 '20 at 19:55
  • I answered something similar here: https://stackoverflow.com/questions/60134061/how-to-push-a-new-root-view-using-swiftui-without-navigationlink/60424968#60424968 – zgluis Feb 27 '20 at 18:00
  • From my understand this topic is very complicated. It has been solved with architectures like Redux, VIPER, and dependency injection. Having a global state mutated from different view models is dangerous. But if you are looking for something quick and dirty you can just have the authentication state be a singleton. – Marwan Roushdy Apr 28 '20 at 02:55

2 Answers2

0

As on provided code I would change it as following

struct InitialView: View {
    @EnvironmentObject var viewModel: InitialViewModel
    @EnvironmentObject var signIn: SignInViewModel

    var body: some View {
        VStack {
            if signIn.authSuccess {
                NewHomeView()
            } else {
                SignInView()
            }
        }
    }
}

as well redirect publisher to main thread in

    signInApi.signIn(email: name, password: password)
        .receive(on: RunLoop.main) // << following update should be on main thread
        .sink(receiveCompletion: { _ in }) { response in
        self.auth.identity = Identity(
            person: Person(id: 1, name: "user", sex: nil, birthday: nil),
            token: response.token
        )
        self.authSuccess = true
    }.store(in: &cancellableSet)
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Hey, thanks for that. I'll try it. A small note of clarification: the view model for the initial view only has auth and no authSuccess. – simplyblago Feb 08 '20 at 19:12
  • the missing part in his example is some link between SignInViewModel and InitialViewModel. At least i don't see that ... – user3441734 Feb 08 '20 at 19:13
  • Update: nope, for some reason when I update auth.identity even though it's @Published, etc. it still doesn't register. I have a feeling this might be because changes in nested observableobjects are not propagated up. I tried doing something like self.isAuthenticated = self.auth.$identity.map { $0 != nil } in the InitialViewModel but I can't get the types right. In Rx this would have been just an observable but Combine encodes the computation in the type system and it quickly becomes a mess.. :( \ – simplyblago Feb 08 '20 at 19:15
  • @user3441734 I want the link to be the shared AuthStore that I pass to both ViewModels (InitialViewModel and SignInViewModel). – simplyblago Feb 08 '20 at 19:16
  • he wrote "Note that I am passing the same AuthStore to both view models" ... ???? It is hard to understand his business logic from his example – user3441734 Feb 08 '20 at 19:16
  • @user3441734 I'll update the question to demonstrate what I mean. – simplyblago Feb 08 '20 at 19:17
  • OK, I've probably got your point ... see https://stackoverflow.com/questions/60119057/swiftui-propagating-change-notifications-through-nested-reference-types/60126962#60126962 – user3441734 Feb 08 '20 at 19:23
  • setting value to auth.identity will NOT force to update UI, jst because the auth is reference type (and correctly defined as private let auth: .....) – user3441734 Feb 08 '20 at 19:43
  • did you ever try to have two @EnvironmentObject wrappers in one View? – user3441734 Feb 08 '20 at 20:00
0

Venture a guess here since you didn't provide InitialViewModel details.

You didn't observe auth in SignInViewModel, and didn't publish it to tigger view update.

I'm guessing it's the same problem with InitialViewModel.

class SignInViewModel: ObservableObject {
    private let auth: AuthStore
    private let signInApi: SignInApi

To question 2): you can't. (as far as I know)

To question 1): you can. But at what cost? Are there better alternatives?

I can elaborate more if needed.

Jim lai
  • 1,224
  • 8
  • 12