SwiftUI published variable not updating view - swift

Context: I am trying to use firebase authentication and firestore to get the user's data. The problem I am running into is that the views are presented before the data is completely fetched and that obviously causes the app to crash. That being said, I am utilizing the firebase authentication listener in my app delegate to ensure the user is authenticated before fetching the users' data (which is also done in the app delegate as shown below)
App delegate snippet
class AppDelegate: NSObject, UIApplicationDelegate {
var handle: AuthStateDidChangeListenerHandle?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
self.handle = Auth.auth().addStateDidChangeListener { (auth, user) in
if (user != nil){
print("UserAuthentication User authenticated in delegate")
DatabaseDelegate().getUserInfo(UID: user!.uid, withCompletionHandler: {
print("got user data")
})
} else {
print(" UserAuthentication User not authenticated in delegate")
try! Auth.auth().signOut()
}
}
return true
}
This is the database code I am querying and want to listen for when the data is finished loading:
class DatabaseDelegate: ObservableObject {
#Published var userDataLoaded = Bool()
func getUserInfo(UID: String, withCompletionHandler completionHandler: #escaping () -> Void) {
database.collection("Users").document(UID).getDocument { (document, error) in
if let document = document, document.exists {
let data = document.data()!
guard let UID = data["UUID"] as? String else { return }
guard let Name = data["Name"] as? String else { return }
guard let PhoneNumber = data["PhoneNumber"] as? String else { return }
guard let StripeID = data["StripeID"] as? String else { return }
self.userDataLoaded = true
UserData.append(User(UID: UID, Name: Name, PhoneNumber: PhoneNumber, StripeID: StripeID, PurchasedContent: ["TEMP": true]))
completionHandler()
}
}
}
}
And this is the SwiftUI view I want to update based on the userDataLoaded above:
struct MainViewDelegate: View {
//MARK: VARIABLES
#State var showAnimation = true
#State var locationHandler = LocationHandler()
#ObservedObject var databaseDelegate = DatabaseDelegate()
init(){
//Check and enable user location
locationHandler.requestAuthorisation()
}
var body: some View {
VStack {
//Check if data has finished loading, if not, show loading. Listen for changes when the data is finished loading and then present the tab view when it is.
switch databaseDelegate.userDataLoaded {
case true:
TabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
CheckoutView()
.tabItem {
Label("Services", systemImage: "bag")
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gearshape")
}
}
case false:
Text("Loading data")
}
}
}
}
Thank you in advanced. I am new to swiftui (transitioning from uikit) and I've spent too much time trying to solve this silly issue

You're using two different instances of DatabaseDelegate, one in the AppDelegate and one in the MainViewDelegate. The boolean is only updated in app delegate's instance.
Move your auth listener into your DatabaseDelegate.
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
return true
}
}
class DatabaseDelegate: ObservableObject {
#Published var userDataLoaded = false
private var handle: AuthStateDidChangeListenerHandle?
init() {
self.handle = Auth.auth().addStateDidChangeListener { (auth, user) in
// .. etc
self.getUserInfo(...)
}
}
private func getUserInfo(UID: String, withCompletionHandler completionHandler: #escaping () -> Void) {
database.collection("Users").document(UID).getDocument { (document, error) in
// .. etc
self.userDataLoaded = true
}
}
}
You need to use StateObject instead of ObservedObject since you are initializing it internally on the view, instead of injecting it from an external source.
struct MainViewDelegate: View {
#StateObject private var databaseDelegate = DatabaseDelegate()
}
If you want to use ObservedObject, you can create it externally and inject into the view like so:
var databaseDelegate = DatabaseDelegate()
MainViewDelegate(databaseDelegate: databaseDelegate)
struct MainViewDelegate: View {
#ObservedObject var databaseDelegate: DatabaseDelegate
}

Related

Trying show another view but can only dismiss existing views - SwiftUI

I am working on a sign up page. I want to show the main view after the sign up process is finished, but I have a problem like this;
OnBoardingView presents the SignUp view and when the signup process is finished, I update the currentUser. When I debug, the if statement works and the line where the main view is executed, but I see the first step of the signUp view presented by the onboardingView on the screen. As if only opened sheets are closed.
I draw the situation https://i.imgur.com/bUJF1Rv.png
ContentView:
struct ContentView: View {
#EnvironmentObject var appEnvironment: AppEnvironment
var body: some View {
if appEnvironment.currentUser == nil {
OnboardingView()
} else {
MainView()
}
}
}
SignUpService
final class SignUpService {
static let shared = SignUpService()
func completeSignUp() async {
let progress = SignUpProgress.shared
let auth = FirebaseManager.shared.auth
guard
let email = progress.email,
let password = progress.password,
let photoData = progress.profilePhotoData
else { return }
do {
let result = try await auth.createUser(withEmail: email, password: password)
let photoUrl = try await result.user.saveProfilePhoto(data: photoData)
progress.profileImageUrl = photoUrl
if let userDetail = progress.createSaveUserDetailModel() {
try await result.user.saveUserDetail(userDetail)
DispatchQueue.main.async {
AppEnvironment.default.currentUser = result.user
}
}
} catch {
print(error)
}
}
}
Last Page of the SignUp Steps where I call the SignUpService:
//Other things
func didContinueTapped() {
SignUpProgress.shared.disinterests = Array(selecteds)
showIndicator = true
Task(priority: .background) {
await SignUpService.shared.completeSignUp()
DispatchQueue.main.async {
self.showIndicator = false
}
}
}
//Other things
AppEnvironment:
final class AppEnvironment: ObservableObject {
static let `default` = AppEnvironment()
#Published var currentUser: User?
}
Main:
#main
struct AnApp: App {
#StateObject var appEnvironment: AppEnvironment = .default
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appEnvironment)
}
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
return true
}
}

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.

Using UIApplicationDelegateAdaptor to get callbacks from userDidAcceptCloudKitShareWith not working

I'm trying to get notified when userDidAcceptCloudKitShareWith gets called. Traditionally this was called in the App Delegate but since I am building an iOS 14+ using App as my root object. I couldn't find any documentation out yet as far as how to add userDidAcceptCloudKitShareWith to my App class, so I am using UIApplicationDelegateAdaptor to use an App Delegate class, however it doesn't seem like userDidAcceptCloudKitShareWith is ever getting called?
import SwiftUI
import CloudKit
// Our observable object class
class ShareDataStore: ObservableObject {
static let shared = ShareDataStore()
#Published var didRecieveShare = false
#Published var shareInfo = ""
}
#main
struct SocialTestAppApp: App {
#StateObject var shareDataStore = ShareDataStore.shared
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView().environmentObject(shareDataStore)
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
let container = CKContainer(identifier: "iCloud.com.TestApp")
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
print("did finish launching called")
return true
}
func application(_ application: UIApplication, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
print("delegate callback called!! ")
acceptShare(metadata: cloudKitShareMetadata) { result in
switch result {
case .success(let recordID):
print("successful share!")
ShareDataStore.shared.didRecieveShare = true
ShareDataStore.shared.shareInfo = recordID.recordName
case .failure(let error):
print("failure in share = \(error)")
}
} }
func acceptShare(metadata: CKShare.Metadata,
completion: #escaping (Result<CKRecord.ID, Error>) -> Void) {
// Create a reference to the share's container so the operation
// executes in the correct context.
let container = CKContainer(identifier: metadata.containerIdentifier)
// Create the operation using the metadata the caller provides.
let operation = CKAcceptSharesOperation(shareMetadatas: [metadata])
var rootRecordID: CKRecord.ID!
// If CloudKit accepts the share, cache the root record's ID.
// The completion closure handles any errors.
operation.perShareCompletionBlock = { metadata, share, error in
if let _ = share, error == nil {
rootRecordID = metadata.rootRecordID
}
}
// If the operation fails, return the error to the caller.
// Otherwise, return the record ID of the share's root record.
operation.acceptSharesCompletionBlock = { error in
if let error = error {
completion(.failure(error))
} else {
completion(.success(rootRecordID))
}
}
// Set an appropriate QoS and add the operation to the
// container's queue to execute it.
operation.qualityOfService = .utility
container.add(operation)
}
}
Updated based on Asperi's Answer:
import SwiftUI
import CloudKit
class ShareDataStore: ObservableObject {
static let shared = ShareDataStore()
#Published var didRecieveShare = false
#Published var shareInfo = ""
}
#main
struct athlyticSocialTestAppApp: App {
#StateObject var shareDataStore = ShareDataStore.shared
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
let sceneDelegate = MySceneDelegate()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(shareDataStore)
.withHostingWindow { window in
sceneDelegate.originalDelegate = window.windowScene.delegate
window.windowScene.delegate = sceneDelegate
}
}
}
}
class MySceneDelegate: NSObject, UIWindowSceneDelegate {
let container = CKContainer(identifier: "iCloud.com...")
var originalDelegate: UIWindowSceneDelegate?
var window: UIWindow?
func sceneWillEnterForeground(_ scene: UIScene) {
print("scene is active")
}
func sceneWillResignActive(_ scene: UIScene) {
print("scene will resign active")
}
// forward all other UIWindowSceneDelegate/UISceneDelegate callbacks to original, like
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
originalDelegate?.scene!(scene, willConnectTo: session, options: connectionOptions)
}
func windowScene(_ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
print("delegate callback called!! ")
acceptShare(metadata: cloudKitShareMetadata) { result in
switch result {
case .success(let recordID):
print("successful share!")
ShareDataStore.shared.didRecieveShare = true
ShareDataStore.shared.shareInfo = recordID.recordName
case .failure(let error):
print("failure in share = \(error)")
}
}
}
}
extension View {
func withHostingWindow(_ callback: #escaping (UIWindow?) -> Void) -> some View {
self.background(HostingWindowFinder(callback: callback))
}
}
struct HostingWindowFinder: UIViewRepresentable {
var callback: (UIWindow?) -> ()
func makeUIView(context: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
In Scene-based application the userDidAcceptCloudKitShareWith callback is posted to Scene delegate, but in SwiftUI 2.0 App-based application the scene delegate is used by SwiftUI itself to provide scenePhase events, but does not provide native way to handle topic callback.
The possible approach to solve this is to find a window and inject own scene delegate wrapper, which will handle userDidAcceptCloudKitShareWith and forward others to original SwiftUI delegate (to keep standard SwiftUI events working).
Here is a couple of demo snapshots based on https://stackoverflow.com/a/63276688/12299030 window access (however you can use any other preferable way to get window)
#main
struct athlyticSocialTestAppApp: App {
#StateObject var shareDataStore = ShareDataStore.shared
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
let sceneDelegate = MySceneDelegate()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(shareDataStore)
.withHostingWindow { window in
sceneDelegate.originalDelegate = window?.windowScene.delegate
window?.windowScene.delegate = sceneDelegate
}
}
}
}
class MySceneDelegate : NSObject, UIWindowSceneDelegate {
var originalDelegate: UISceneDelegate?
func windowScene(_ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShareMetadata) {
// your code here
}
// forward all other UIWindowSceneDelegate/UISceneDelegate callbacks to original, like
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
originalDelegate?.scene(scene, willConnectTo: session, options: connectionOptions)
}
}
Check out this question that has a lot of useful things to check across several possible answers:
CloudKit CKShare userDidAcceptCloudKitShareWith Never Fires on Mac App
Be sure to add the CKSharingSupported key to your info.plist, and then try putting the userDidAcceptCloudKitShareWith in different places using the answers in the above link (where you put it will depend on what kind of app you're building).

How to implement handleUserActivity in WatchOS in SwiftUI?

I've passed to my complicationDescriptors a userInfo dictionary that includes the names of my complications so I can be notified of which complication the user tapped on and launch them to that View. I'm using the new #App and #WKExtensionDelegateAdaptor for other reasons, so I have access to handleUserActivity in my extensionDelegate and can unpack the source of the complication tap, but how do I launch them to a specific view in SwiftUI? handleUserActivity seems to be set up to work with WKInterfaceControllers?
class ExtensionDelegate: NSObject, WKExtensionDelegate {
let defaults = UserDefaults.standard
func applicationDidFinishLaunching() {
//print("ApplicationDidFinishLanching called")
scheduleNextReload()
}
func applicationWillResignActive() {
//print("applicationWillResignActive called")
}
func handleUserActivity(_ userInfo: [AnyHashable : Any]?) {
if let complication = userInfo?[TrackerConstants.complicationUserTappedKey] as? String {
if complication == TrackerConstants.recoveryDescriptorKey {
//What now?
} else if complication == TrackerConstants.exertionDescriptorKey {
//What now?
}
}
}
I managed to update my view according to the tapped complication userInfo by using notifications, inspired by this answer.
First declare a notification name:
extension Notification.Name {
static let complicationTapped = Notification.Name("complicationTapped")
}
In your ExtensionDelegate:
func handleUserActivity(_ userInfo: [AnyHashable : Any]?) {
if let complication = userInfo?[TrackerConstants.complicationUserTappedKey] as? String {
NotificationCenter.default.post(
name: Notification.Name.complicationTapped,
object: complication
)
}
}
Finally in your view:
struct ContentView: View {
#State private var activeComplication: String? = nil
var body: some View {
NavigationView { // or TabView, etc
// Your content with NavigationLinks
}
.onReceive(NotificationCenter.default.publisher(
for: Notification.Name.complicationTapped
)) { output in
self.activeComplication = output.object as? String
}
}
}
For more information on how to activate a view from here, see Programmatic navigation in SwiftUI

Going crazy with UserDefaults in Swift[UI]

Launching myself into Swift and SwiftUI, I find the process of migrating from UIKit quite hard.
Presently stomped by UserDefaults, even after trying to make sense of the many tutorials I found on the web.
Please tell me what I'm doing wrong here :
VERY simple code to :
register a bool value to a UserDefault,
display that bool in a text !
Doesn't get any simpler than that.
But I can't get it to work, as the call to UserDefaults throws this error message :
Instance method 'appendInterpolation' requires that 'Bool' conform to '_FormatSpecifiable'
My "app" is the default single view app with the 2 following changes :
1- In AppDelegate, I register my bool :
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
UserDefaults.standard.register(defaults: [
"MyBool 1": true
])
return true
}
2- in ContentView, I try to display it (inside struct ContentView: View) :
let defaults = UserDefaults.standard
var body: some View {
Text("The BOOL 1 value is : Bool 1 = \(defaults.bool(forKey: "MyBool 1"))")
}
Any ideas ?
Thanks
Your issue is that the Text(...) initializer takes a LocalizedStringKey rather than a String which supports different types in its string interpolation than plain strings do (which does not include Bool apparently).
There's a couple ways you can work around this.
You could use the Text initializer that takes a String and just displays it verbatim without attempting to do any localization:
var body: some View {
Text(verbatim: "The BOOL 1 value is : Bool 1 = \(defaults.bool(forKey: "MyBool 1"))")
}
Alternatively, you could extend LocalizedStringKey.StringInterpolation to support bools and then your original code should work:
extension LocalizedStringKey.StringInterpolation {
mutating func appendInterpolation(_ value: Bool) {
appendInterpolation(String(value))
}
}
To solve your problem, just add description variable, like:
var body: some View {
Text("The BOOL 1 value is : Bool 1 = \(defaults.bool(forKey: "MyBool 1").description)")
}
To answer your questions:
1- register a bool value to a UserDefault,
2- display that bool in a text !
I tested the following code and confirm that it works on ios 13.4 and macos using catalyst. Note the String(...) wrapping.
in class AppDelegate
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
// UserDefaults.standard.register(defaults: ["MyBool 1": true])
UserDefaults.standard.set(true, forKey: "MyBool 1")
return true
}
in ContentView
import SwiftUI
struct ContentView: View {
#State var defaultValue = false // for testing
let defaults = UserDefaults.standard
var body: some View {
VStack {
Text("bull = \(String(UserDefaults.standard.bool(forKey: "MyBool 1")))")
Text(" A The BOOL 1 value is Bool 1 = \(String(defaultValue))")
Text(" B The BOOL 1 value is : Bool 1 = \(String(defaults.bool(forKey: "MyBool 1")))")
}
.onAppear(perform: loadData)
}
func loadData() {
defaultValue = defaults.bool(forKey: "MyBool 1")
print("----> defaultValue: \(defaultValue) ")
}
}
Not sure why you use register, but you can just set the bool value like this:
UserDefaults.standard.set(true, forKey: "MyBool1")
in SwiftUI I use these:
UserDefaults.standard.set(true, forKey: "MyBool 1")
let bull = UserDefaults.standard.bool(forKey: "MyBool 1")
I figured that the best way to use UserDefaults is inside a class. It helps us subscribe to that class from any model using #ObservedObject property wrapper.
Boolean method can be used for rest of the types
//
// ContentView.swift
//
import SwiftUI
struct ContentView: View {
#ObservedObject var data = UserData()
var body: some View {
VStack {
Toggle(isOn: $data.isLocked){ Text("Locked") }
List(data.users) { user in
Text(user.name)
if data.isLocked {
Text("User is Locked")
} else {
Text("User is Unlocked")
}
}
}
}
}
//
// Model.swift
//
import SwiftUI
import Combine
let defaults = UserDefaults.standard
let usersData: [User] = loadJSON("Users.json")
// This is custom JSON loader and User struct is to be defined
final class UserData: ObservableObject {
// Saving a Boolean
#Published var isLocked = defaults.bool(forKey: "Locked") {
didSet {
defaults.set(self.isLocked, forKey: "Locked")
}
}
// Saving Object after encoding
#Published var users: [User] {
// didSet will only work if used as Binding variable. Else need to create a save method, which same as the following didSet code.
didSet {
// Encoding Data to UserDefault if value of user data change
if let encoded = try? JSONEncoder().encode(users) {
defaults.set(encoded, forKey: "Users")
}
}
}
init() {
// Decoding Data from UserDefault
if let users = defaults.data(forKey: "Users") {
if let decoded = try? JSONDecoder().decode([User].self, from: users) {
self.users = decoded
return
}
}
// Fallback value if key "Users" is not found
self.users = usersData
}
// resetting UserDefaults to initial values
func resetData() {
defaults.removeObject(forKey: "Users")
self.isLocked = false
self.users = usersData
}
}
Note: This code is not tested. It is directly typed here.
Try this:
struct MyView {
private let userDefaults: UserDefaults
// Allow for dependency injection, should probably be some protocol instead of `UserDefaults` right away
public init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
}
}
// MARK: - View
extension MyView: View {
var body: some View {
Text("The BOOL 1 value is: \(self.descriptionOfMyBool1)")
}
}
private extension MyView {
var descriptionOfMyBool1: String {
let key = "MyBool 1"
return "\(boolFromDefaults(key: key))"
}
// should probably not be here... move to some KeyValue protocol type, that you use instead of `UserDefaults`...
func boolFromDefaults(key: String) -> Bool {
userDefaults.bool(forKey: key)
}
}