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

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
}
}

Related

Redirecting after task w/ Await completes

In a view, I want to wait for a series of async calls to finish loading, then redirect to another screen. Unfortunately, I see the code running in the back (The JSON data gets loaded) but once it completes it does not redirect to the new view.
Here is my view:
struct loadingView: View {
#ObservedObject var dataLoader: DataLoader = DataLoader()
#State var isLoaded: Bool = false
var body: some View {
VStack {
Text("Loading \(isLoaded)")
}
}
.task {
await self.dataloader.loadJSONData(isLoaded: $isLoaded)
MainScreen()
}
}
...and the DataLoader class:
#MainActor DataLoader: NSObject, ObservableObject {
func loadJSONData(isLoaded: Binding<Bool>) {
await doLoadData()
isLoaded.wrappedValue = True
}
func doLoadData() async {
/* do data load */
/* This code works */
}
}
"Redirecting" here doesn't really make sense. Do you really want the user to be able to navigate back to the loading screen? Perhaps you're thinking of this like a web page, but SwiftUI is nothing like that. What you really want to do is display one thing when loading, and a different thing when loaded. That's just if, not "redirection."
Instead, consider the following pattern. Create this kind of LoadingView (extracted from some personal code of mine):
struct LoadingView<Content: View, Model>: View {
enum LoadState {
case loading
case loaded(Model)
case error(Error)
}
#ViewBuilder let content: (Model) -> Content
let loader: () async throws -> Model
#State var loadState = LoadState.loading
var body: some View {
ZStack {
Color.white
switch loadState {
case .loading: Text("Loading")
case .loaded(let model): content(model)
case .error(let error): Text(verbatim: "Error: \(error)")
}
}
.task {
do {
loadState = .loaded(try await loader())
} catch {
loadState = .error(error)
}
}
}
}
It require no redirection. It just displays different things when in different states (obviously the Text view can be replaced by something more interesting).
Then to use this, embed it in another View. In my personal code, that includes a view like this:
struct DailyView: View {
var body: some View {
LoadingView() { model in
LoadedDailyView(model: model)
} loader: {
try await DailyModel()
}
}
}
Then LoadedDailyView is the "real" view. It is handled a fully populated model that is created by DailyModel.init (a throwing, async init).
You could try this approach, using NavigationStack and NavigationPath to Redirecting after task w/ Await completes.
Here is the code I use to test my answer:
struct ContentView: View {
var body: some View {
loadingView()
}
}
#MainActor
class DataLoader: NSObject, ObservableObject {
func loadJSONData() async {
await doLoadData()
// for testing, wait for 1 second
try? await Task.sleep(nanoseconds: 1 * 1_000_000_000)
}
func doLoadData() async {
/* do data load */
/* This code works */
}
}
struct loadingView: View {
#StateObject var dataLoader = DataLoader()
#State private var navPath = NavigationPath()
var body: some View {
NavigationStack(path: $navPath) {
VStack (spacing: 44) {
Text("Loading....")
}
.navigationDestination(for: Bool.self) { _ in
MainScreen()
}
}
.task {
await dataLoader.loadJSONData()
navPath.append(true)
}
}
}
struct MainScreen: View {
var body: some View {
Text("---> MainScreen here <---")
}
}
If you need ios 15 or earlier, then use NavigationView:
struct loadingView: View {
#StateObject var dataLoader = DataLoader()
#State var isLoaded: Bool?
var body: some View {
NavigationView {
VStack {
Text(isLoaded == nil ? "Loading..." : "Finished loading")
NavigationLink("", destination: MainScreen(), tag: true, selection: $isLoaded)
}
}.navigationViewStyle(.stack)
.task {
await dataLoader.loadJSONData()
isLoaded = true
}
}
}
If your loadingView has the only purpose of showing the "loading" message, then
display the MainScreen after the data is loaded, you could use the following approach using a simple swicth:
struct loadingView: View {
#StateObject var dataLoader = DataLoader()
#State private var isLoaded = false
var body: some View {
VStack {
if isLoaded {
MainScreen()
} else {
ProgressView("Loading")
}
}
.task {
await dataLoader.loadJSONData()
isLoaded = true
}
}
}
Use #StateObject instead of #ObservedObject. Use #Published instead of trying to pass a binding to the object (that is a mistake because a binding is just a pair of get and set closures that will expire if LoadingView is re-init), use Group with an if to conditionally show a View e.g.
struct LoadingView: View {
#StateObject var dataLoader: DataLoader = DataLoader()
var body: some View {
Group {
if dataLoader.isLoaded {
LoadedView(data: dataLoader.data)
} else {
Text("Loading...")
}
}
.task {
await dataloader.loadJSONData()
}
}
The DataLoader should not be #MainActor because you want it to run on a background thread. Use #MainActor instead on a sub-task once the async work has finished e.g.
class DataLoader: ObservableObject {
#Published var isLoaded = false
#Published var data: [Data] = []
func loadJSONData async {
let d = await doLoadData()
Task { #MainActor in
isLoaded = true
data = d
}
}
func doLoadData() async {
/* do data load */
/* This code works */
}
}
This pattern is shown in Apple's tutorial here, PandaCollectionFetcher.swift copied below:
import SwiftUI
class PandaCollectionFetcher: ObservableObject {
#Published var imageData = PandaCollection(sample: [Panda.defaultPanda])
#Published var currentPanda = Panda.defaultPanda
let urlString = "http://playgrounds-cdn.apple.com/assets/pandaData.json"
enum FetchError: Error {
case badRequest
case badJSON
}
func fetchData() async
throws {
guard let url = URL(string: urlString) else { return }
let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badRequest }
Task { #MainActor in
imageData = try JSONDecoder().decode(PandaCollection.self, from: data)
}
}
}

Swift: Error converting type 'Binding<Subject>' when passing Observed object's property to child view

I want to load data from an API, then pass that data to several child views.
Here's a minimal example with one child view (DetailsView). I am getting this error:
Cannot convert value of type 'Binding<Subject>' to expected argument type 'BusinessDetails'
import Foundation
import SwiftUI
import Alamofire
struct BusinessView: View {
var shop: Business
class Observer : ObservableObject{
#Published public var shop = BusinessDetails()
#Published public var loading = false
init(){ shop = await getDetails(id: shop.id) }
func getDetails(id: String) async -> (BusinessDetails) {
let params = [
id: id
]
self.loading = true
self.shop = try await AF.request("https://api.com/details", parameters: params).serializingDecodable(BusinessDetails.self).value
self.loading = false
return self.shop
}
}
#StateObject var observed = Observer()
var body: some View {
if !observed.loading {
TabView {
DetailsView(shop: $observed.shop)
.tabItem {
Label("Details", systemImage: "")
}
}
}
}
}
This has worked before when the Observed object's property wasn't an object itself (like how the loading property doesn't cause an error).
When using async/await you should use the .task modifier and remove the object. The task will be started when the view appears, cancelled when it disappears and restarted when the id changes. This saves you a lot of effort trying to link async task lifecycle to object lifecycle. e.g.
struct BusinessView: View {
let shop: Business
#State var shopDetails = BusinessDetails()
#State var loading = false
var body: some View {
if loading {
Text("Loading")
}
else {
TabView {
DetailsView(shop: shopDetails)
.tabItem {
Label("Details", systemImage: "")
}
}
}
.task(id: shop.id) {
loading = true
shopDetails = await Self.getDetails(id: shop.id) // usually we have a try catch around this so we can show an error message
loading = false
}
}
// you can move this func somewhere else if you like
static func getDetails(id: String) async -> BusinessDetails{
let params = [
id: id
]
let result = try await AF.request("https://api.com/details", parameters: params).serializingDecodable(BusinessDetails.self).value
return result
}
}
}

SwiftUI published variable not updating view

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
}

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.

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