View not reacting to changes of Published property when its chained from another ObservedObject - swift

Below is the SwiftUI view which owns a ViewModel and the logic is if the viewModel.authenticationService.user contains a user object then it will show the HomeView, else case will be asked for Login. So initially the viewModel.authenticationService.user is nil and user logins successful the user object in no more nil.
View
struct WelcomeView: View {
#ObservedObject private var viewModel: WelcomeView.Model
#State private var signInActive: Bool = false
init(viewModel: WelcomeView.Model) {
self.viewModel = viewModel
}
var body: some View {
if viewModel.authenticationService.user != nil {
HomeView()
} else {
LoginView()
}
}
ViewModel
extension WelcomeView {
final class Model: ObservableObject {
#ObservedObject var authenticationService: AuthenticationService
init(authenticationService: AuthenticationService) {
self.authenticationService = authenticationService
}
}
}
AuthenticationService
final class AuthenticationService: ObservableObject {
#Published var user: User?
private var authenticationStateHandle: AuthStateDidChangeListenerHandle?
init() {
addListeners()
}
private func addListeners() {
if let handle = authenticationStateHandle {
Auth.auth().removeStateDidChangeListener(handle)
}
authenticationStateHandle = Auth.auth()
.addStateDidChangeListener { _, user in
self.user = user
}
}
static func signIn(email: String, password: String, completion: #escaping AuthDataResultCallback) {
if Auth.auth().currentUser != nil {
Self.signOut()
}
Auth.auth().signIn(withEmail: email, password: password, completion: completion)
}
}
However, when the user object is updated with some value it does not update the View. I am not sure as I am new to reactive way of programming. There is a chain of View -> ViewModel -> Service and the published user property is in the Service class which gets updated successfully once user login.
Do I need to add a listener in the ViewModel which reacts to Service published property? Or is there any direct way for this scenario to work and get the UI Updated?

In case of scenario where there is a chain from View -> ViewModel -> Services:
#ObservedObject does not work on classes. Only works in case of SwiftUI View(structs). So if you want to observe changes on your view model from a service you need to manually listen/subscribe to it.
self.element.$value.sink(
receiveValue: { [weak self] _ in
self?.objectWillChange.send()
}
)
You can also use the .assign. Find more details here

Related

How to Reuse Same Function in Parent and Child View in SwiftUI with NSManagedObject

Suppose we have a main view MainView and a child view LoginView which perform login function for user. I have chosen CoreData to store some persisted data like username and password and #State in MainView to store non-persisted data like logging-in status.
I also want to support automatic login when the user opens the app. So the login function in LoginView is also used in MainView. My demo code is pasted below.
import SwiftUI
import CoreData
struct MainView: View {
#State private var loggingIn: Bool = false
#State private var token: String? = nil
// `UserInfo` here is a `NSManagedObject` fetched from CoreData
#ObservedObject private var info: UserInfo
init() {
// fetch info from CoreData
}
func logIn() {
await MainActor.run {
self.loggingIn = true
}
let token = await performLoginHttpRequest(cred.username!, cred.password!)
await MainActor.run {
self.loggingIn = false
self.token = token
}
}
var body: some View {
NavigationView {
VStack {
if logging {
ProgressView()
} else if token == nil {
Text("Logged Out")
} else {
Text("Token: \(token!)")
}
NavigationLink("Login") {
LoginView(info: info, logging: $loggingIn)
}
}
}
.task {
await logIn()
}
}
}
struct LoginView: View {
#ObservedObject var info: UserInfo
#Binding var loggingIn: Bool
#Binding var token: String?
func logIn() async {
// basically same as in MainView, but
// `loggingIn` and `token` here is wrapped
// with `#Binding`
}
var body: some View {
// UI to login
}
}
As you can see, the problem here is that I have to write the same logic in MainView and LoginView since I do not know how to share the logic in such configuration.
I have tried some ugly workaround like wrapping the UserInfo in another ObservableObject and sharing it between views. But nested ObservableObject seems not recommended in SwiftUI. Is there any elegant way to implement this?

How to pass a parent ViewModel #Published property to a child ViewModel #Binding using MVVM

I'm using an approach similar to the one described on mockacoding - Dependency Injection in SwiftUI where my main ViewModel has the responsibility to create child viewModels.
In the code below I am not including the Factory, as it's very similar to the contents of the post above: it creates the ParentViewModel, passes to it dependencies and closures that construct the child view models.
struct Book { ... } // It's a struct, not a class
struct ParentView: View {
#StateObject var viewModel: ParentViewModel
var body: some View {
VStack {
if viewModel.book.bookmarked {
BookmarkedView(viewModel: viewModel.makeBookMarkedViewModel())
} else {
RegularView(viewModel: viewModel.makeBookMarkedViewModel())
}
}
}
}
class ParentViewModel: ObservableObject {
#Published var book: Book
// THIS HERE - This is how I am passing the #Published to #Binding
// Problem is I don't know if this is correct.
//
// Before, I was not using #Binding at all. All where #Published
// and I just pass the reference. But doing that would cause for
// the UI to NEVER update. That's why I changed it to use #Binding
private var boundBook: Binding<Book> {
Binding(get: { self.book }, set: { self.book = $0 })
}
// The Factory object passes down these closures
private let createBookmarkedVM: (_ book: Binding<Book>) -> BookmarkedViewModel
private let createRegularVM: (_ book: Binding<Book>) -> RegularViewModel
init(...) {...}
func makeBookmarkedViewModel() {
createBookmarkedVM(boundBook)
}
}
class BookmarkedView: View {
#StateObject var viewModel: BookmarkedViewModel
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Text(book.title) // <---- THIS IS THE PROBLEM. Not being updated
Button("Remove bookmark") {
viewModel.removeBookmark()
}
}
.onReceive(timer) { _ in
print("adding letter") // <-- this gets called
withAnimation {
viewModel.addLetterToBookTitle()
}
}
}
}
class BookmarkedViewModel: ObservableObject {
#Binding var book: Book
// ... some other dependencies passed by the Factory object
init(...) { ... }
public func removeBookmark() {
// I know a class would be better than a struct, bear with me
book = Book(title: book.title, bookmarked: false)
}
/// Adds an "a" to the title
public func addLetterToBookTitle() {
book = Book(title: book.title + "a", bookmarked: book.bookmarked)
print("letter added") // <-- this gets called as well
}
}
From the code above, let's take a look at BookmarkedView. If I click the button and viewModel.removeBookmark() gets called, the struct is re-assigned and ParentView now renders RegularView.
This tells me that I successfully bound #Published book: Book from ParentViewModel to #Binding book: Book from BookmarkedViewModel, through its boundBook computed property. This felt like the most weird thing I had to make.
However, the problem is that even though addLetterToBookTitle() is also re-assigning the book with a new title, and it should update the Text(book.title), it's not happening. The same title is being displayed.
I can guarantee that the book title has change (because of some other components of the app I'm omitting for simplicity), but the title's visual is not being updated.
This is the first time I'm trying out these pattern of having a view model build child view models, so I appreciate I may be missing something fundamental. What am I missing?
EDIT:
I made an MVP example here: https://github.com/christopher-francisco/TestMVVM/tree/main/MVVMTest.xcodeproj
I'm looking for whether:
My take at child viewmodels is fundamentally wrong and I should start from scratch, or
I have misunderstood #Binding and #Published attributes, or
Anything really
Like I said initially #Binding does not work in a class you have to use .sink to see the changes to an ObservableObject.
See below...
class MainViewModel: ObservableObject {
#Published var timer = YourTimer()
let store: Store
let nManager: NotificationManager
let wManager: WatchConnectionManager
private let makeNotYetStartedViewModelClosure: (_ parentVM: MainViewModel) -> NotYetStartedViewModel
private let makeStartedViewModelClosure: (_ parentVM: MainViewModel) -> StartedViewModel
init(
store: Store,
nManager: NotificationManager,
wManager: WatchConnectionManager,
makeNotYetStartedViewModel: #escaping (_ patentVM: MainViewModel) -> NotYetStartedViewModel,
makeStartedViewModel: #escaping (_ patentVM: MainViewModel) -> StartedViewModel
) {
self.store = store
self.nManager = nManager
self.wManager = wManager
self.makeNotYetStartedViewModelClosure = makeNotYetStartedViewModel
self.makeStartedViewModelClosure = makeStartedViewModel
}
}
// MARK: - child View Models
extension MainViewModel {
func makeNotYetStartedViewModel() -> NotYetStartedViewModel {
self.makeNotYetStartedViewModelClosure(self)
}
func makeStartedViewModel() -> StartedViewModel {
self.makeStartedViewModelClosure(self)
}
}
class NotYetStartedViewModel: ObservableObject {
var parentVM: MainViewModel
var timer: YourTimer{
get{
parentVM.timer
}
set{
parentVM.timer = newValue
}
}
var cancellable: AnyCancellable? = nil
init(parentVM: MainViewModel) {
self.parentVM = parentVM
//Subscribe to the parent
cancellable = parentVM.objectWillChange.sink(receiveValue: { [self] _ in
//Trigger reload
objectWillChange.send()
})
}
func start() {
// I'll make this into a class later on
timer = YourTimer(remainingSeconds: timer.remainingSeconds, started: true)
}
}
class StartedViewModel: ObservableObject {
var parentVM: MainViewModel
var timer: YourTimer{
get{
parentVM.timer
}
set{
parentVM.timer = newValue
}
}
var cancellable: AnyCancellable? = nil
init(parentVM: MainViewModel) {
self.parentVM = parentVM
cancellable = parentVM.objectWillChange.sink(receiveValue: { [self] _ in
//trigger reload
objectWillChange.send()
})
}
func tick() {
// I'll make this into a class later on
timer = YourTimer(remainingSeconds: timer.remainingSeconds - 1, started: timer.started)
}
func cancel() {
timer = YourTimer()
}
}
But this is an overcomplicated setup, stick to class or struct. Also, maintain a single source of truth. That is basically the center of how SwiftUI works everything should be getting its value from a single source.

SwiftUI Firebase Authentication dismiss view after successfully login

I'm a beginner iOS developer and I have a problem with my first application. I'm using Firebase as a backend for my app and I have already sign in and sing up methods implemented. My problem is with dismissing LoginView after Auth.auth().signIn method finishing. I've managed to do this when I'm using NavigationLink by setting ObservableObject in isActive:
NavigationLink(destination: DashboardView(), isActive: $isUserLogin) { EmptyView() }
It's working as expected: when app ending login process screen is going to next view - Dashboard.
But I don't want to use NavigationLink and creating additional step, I want just go back to Dashboard using:
self.presentationMode.wrappedValue.dismiss()
In this case I don't know how to force app to wait till method loginUser() ends. This is how my code looks now:
if loginVM.loginUser() {
appSession.isUserLogin = true
self.presentationMode.wrappedValue.dismiss()
}
I've tried to use closures but it doesn't work or I'm doing something wrong.
Many thanks!
You want to use a AuthStateDidChangeListenerHandle and #EnvrionmentObject, like so:
class SessionStore: ObservableObject {
var handle: AuthStateDidChangeListenerHandle?
#Published var isLoggedIn = false
#Published var userSession: UserModel? { didSet { self.willChange.send(self) }}
var willChange = PassthroughSubject<SessionStore, Never>()
func listenAuthenticationState() {
handle = Auth.auth().addStateDidChangeListener({ [weak self] (auth, user) in
if let user = user {
let firestoreUserID = API.FIRESTORE_DOCUMENT_USER_ID(userID: user.uid)
firestoreUserID.getDocument { (document, error) in
if let dict = document?.data() {
//Decoding the user, you can do this however you see fit
guard let decoderUser = try? UserModel.init(fromDictionary: dict) else {return}
self!.userSession = decoderUser
}
}
self!.isLoggedIn = true
} else {
self!.isLoggedIn = false
self!.userSession = nil
}
})
}
func logOut() {
do {
try Auth.auth().signOut()
print("Logged out")
} catch let error {
debugPrint(error.localizedDescription)
}
}
func unbind() {
if let handle = handle {
Auth.auth().removeStateDidChangeListener(handle)
}
}
deinit {
print("deinit - seession store")
}
}
Then simply do something along these lines:
struct InitialView: View {
#EnvironmentObject var session: SessionStore
func listen() {
session.listenAuthenticationState()
}
var body: some View {
ZStack {
Color(SYSTEM_BACKGROUND_COLOUR)
.edgesIgnoringSafeArea(.all)
Group {
if session.isLoggedIn {
DashboardView()
} else if !session.isLoggedIn {
SignInView()
}
}
}.onAppear(perform: listen)
}
}
Then in your app file, you'd have this:
InitialView()
.environmentObject(SessionStore())
By using an #EnvironmentObject you can now access the user from any view, furthermore, this also allows to track the Auth status of the user meaning if they are logged in, then the application will remember.

Combine link UIViewController to #Published

I've set up a nice little view model with an #Published state:
class ViewModel {
#Published private(set) var state = State.loading
private var cancellable: Set<AnyCancellable> = []
enum State {
case data([Users.UserData])
case failure(Error)
case loading
}
}
I've then linked to this in my UIViewController's viewDidLoad function:
private var cancellable: AnyCancellable?
override func viewDidLoad() {
super.viewDidLoad()
cancellable = viewModel.$state.sink{ [weak self] _ in
DispatchQueue.main.async {
self?.render()
}
}
}
however, when I link directly to the state it doesn't change when the state is changed from the view model (not shown in the code).
private func render() {
switch viewModel.state {
case .loading:
// Show loading spinner
print("loading render")
case .failure(let error):
// Show error view
print("failing render")
case .data(let userData):
// Show user's profile
self.applySnapshot(userData: userData)
print("loaded \(userData) render")
}
}
Now I can pass the state from my viewDidLoad, but how can I link directly to the viewModel state using Combine and UIKit?
Make sure your ViewModel conforms to ObservableObject like so:
class ViewModel: ObservableObject. This way it knows to react to changes, despite being a UIViewController.

Subscribing to a user variable from my authentication class in an unrelated ViewModel - Swift/Combine

I'm trying to instantiate a user profile based on the logged in user from my AuthenticationState class (using Firebase Auth). This user profile is part of my UserProfileViewModel, which should power a view for editing the user's profile.
But it appears that the loggedInUser is still seen as nil when the UserProfileViewModel is instantiated, and I'm not sure if there's a way I can use a Combine subscription from another class like this to make sure I'm subscribed to the loggedInUser published variable of that specific instance of Authentication state.
Authentication state is called by my main app file, as an environment object - once the user logs in, the loggedInUser is set to that Firebase Auth user:
class AuthenticationState: NSObject, ObservableObject {
// The firebase logged in user, and the userProfile associated with the users collection
#Published var loggedInUser: User?
#Published var isAuthenticating = false
#Published var error: NSError?
static let shared = AuthenticationState()
private let auth = Auth.auth()
fileprivate var currentNonce: String?
I initialize AuthenticationState in my main app file:
#main
struct GoalTogetherApp: App {
let authState: AuthenticationState
init() {
FirebaseApp.configure()
self.authState = AuthenticationState.shared
setupFirebase()
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(authState)
}
}
}
And I have this other class that I want to grab the loggedInUser, and then use that user's uid to create or find a userProfile from Cloud Firestore:
class UserProfileViewModel: ObservableObject {
#Published var loggedInUser: User?
#Published var userProfile: UserProfile?
private let auth = Auth.auth()
private let db = Firestore.firestore()
init() {
self.loggedInUser = AuthenticationState.shared.loggedInUser
if self.loggedInUser != nil {
self.userProfile = self.loadUser()
}
}
And the Profile page is supposed to grab that and pull the email from the UserProfile, but it keeps coming up as blank:
struct ProfilePage: View {
#ObservedObject var userProfileVM = UserProfileViewModel()
#State var email: String = ""
init() {
print("User Profile VM equals: \(String(describing: userProfileVM.userProfile))")
if userProfileVM.userProfile?.email != nil {
_email = State(initialValue: userProfileVM.userProfile!.email!)
} else {
_email = State(initialValue: "")
}
}
You could use the instance method addStateDidChangeListener(_:) of Firebase's Auth class and assign the User instance passed in via the completion handler to your own property of loggedInUser in AuthenticationState. This way, you'll get notified whenever the user logs in or out - staying in sync. Like so:
class AuthenticationState: NSObject, ObservableObject {
#Published var loggedInUser: User?
//...other properties...
static let shared = AuthenticationState()
private let auth = Auth.auth()
override init() {
super.init()
auth.addStateDidChangeListener { [weak self] (_, user) in
self?.loggedInUser = user
}
}
}
Then, you're correct in that you can use Combine to form a data pipeline between your AuthenticationState instance and UserProfileViewModel instance. Instead of a one-time assignment during init() (as you have currently), you can use Combine's sink(receiveValue:) method to bind UserProfileViewModel's loggedInUser property to AuthenticationState's:
class UserProfileViewModel: ObservableObject {
#Published var loggedInUser: User?
init() {
AuthenticationState.shared.$loggedInUser
.sink { [weak self] user in
self?.loggedInUser = user
}
.store(in: &subs)
}
private var subs: Set<AnyCancellable> = .init()
}
Using $loggedInUser accesses the built-in publisher provided by #Published. And here you can sink and create a subscription. Note, also, the storage of the AnyCancellable returned by sink(receiveValue:). Keep a strong reference to this for however long UserProfileViewModel needs to be around for.