Correct way to use navigationDestination after async call - SwiftUI - swift

So, i'm creating a simple login page where an async call is made passing the credentials and with a successful response, it should navigate to another view. The call is working just fine, i've managed to receive the correct response, but i can't get the navigationDestination(isPresented:destination:) to work. Any clues on what i'm missing here?
LoginView:
struct LoginView: View {
#StateObject private var loginViewModel = LoginViewModel()
#State var success: Bool = false
var body: some View {
VStack {
if loginViewModel.isBusy {
ProgressView()
} else {
NavigationStack {
TextField("Username", text: $loginViewModel.username)
SecureField("Password", text: $loginViewModel.password)
Button("Login") {
Task {
success = await loginViewModel.login()
}
}
.navigationDestination(isPresented: $success) {
HomeView()
}
}
.buttonStyle(PlainButtonStyle())
}
}
}
}
LoginViewModel:
#MainActor
class LoginViewModel: ObservableObject {
#Published var response: LoginResponse?
#Published var isBusy: Bool = false
var username = ""
var password = ""
func login() async -> Bool {
isBusy = true
do {
response = try await Service().login(username: username, password: password)
isBusy = false
return true
} catch {
isBusy = false
print(error.localizedDescription)
return false
}
}
}

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

SwiftUI Modifying state during view update, this will cause undefined behavior - Proper use explanation

Im learning swift and this error/warning is driving me crazy because I cant see what call Im making that causing it... The Xcode warning only shows up in my #main struct
Modifying state during view update, this will cause undefined behavior.
I thought it might be in the ListView, but I realized the warning only shows after the "Submit Post" button is it.
Im looking for a fix, but more importantly and explanation as to why this is happening and the proper usage moving forward.
import SwiftUI
import Firebase
#main
struct SocialcademyApp: App {
init() {
FirebaseApp.configure()
}
var body: some Scene {
WindowGroup {
PostsList()
}
}
}
struct PostsList: View {
#StateObject var viewModel = PostsViewModel()
#State private var searchText = ""
#State private var showNewPostForm = false
var body: some View {
NavigationView {
List(viewModel.posts) { post in
if searchText.isEmpty || post.contains(searchText) {
PostRow(post: post)
}
}
.searchable(text: $searchText)
.navigationTitle("Posts")
.toolbar {
Button {
showNewPostForm = true
} label: {
Label("New Post", systemImage: "square.and.pencil")
}
}
.sheet(isPresented: $showNewPostForm) {
NewPostView(creationAction: viewModel.makeCreationAction())
}
}
}
}
struct NewPostView: View {
typealias CreationAction = (Post) async throws -> Void
let creationAction: CreationAction
#State private var post = Post(title: "", content: "", authorName: "")
#State private var state = FormState.idle
#Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
Form {
Section {
TextField("Title", text: $post.title)
TextField("Author Name", text: $post.authorName)
}
Section {
TextField("Content", text: $post.content)
.multilineTextAlignment(.leading)
}
Button(action: createPost, label: {
if state == .working {
ProgressView() } else {
Text("Submit Post")
}
})
.font(.headline)
.frame(maxWidth: .infinity)
.foregroundColor(.white)
.padding()
.listRowBackground(Color.accentColor)
}
}
.navigationTitle("New Post")
.disabled(state == .working)
.alert("Cannot Create Post", isPresented: $state.isError, actions: {}) {
Text("Sorry, something went wrong")
}
.onSubmit {
createPost()
}
}
private func createPost() {
print("[NewPostForm] creating a new post")
Task {
state = .working
do {
try await creationAction(post)
dismiss()
} catch {
state = .error
print("[NewPostForm] Cannot create post: \(error)")
}
}
}
}
private extension NewPostView {
enum FormState {
case idle, working, error
var isError: Bool {
get {
self == .error
}
set {
guard !newValue else { return }
self = .idle
}
}
}
}
#MainActor
class PostsViewModel: ObservableObject {
#Published var posts = [Post.testPost]
func makeCreationAction() -> NewPostView.CreationAction {
return { [weak self] post in
try await PostsRepository.create(post)
self?.posts.insert(post, at: 0)
}
}
}

How to pass user id to view with firebase

I am working on a social media app and I am having trouble displaying a Profile of a specific user. I am able to do it with currentUser?.uid but I don't know how to pass a different id/user to a profile view. Sorry if this explanation is confusing, I'm also having a hard time putting it into words.
This is my view that fetches the currently logged in user and displays their username:
import SwiftUI
class TestProfileViewModel: ObservableObject {
#Published var qUser: User?
init() {
fetchCurrentUser()
}
func fetchCurrentUser() {
guard let uid = FirebaseManager.shared.auth.currentUser?.uid else { return }
FirebaseManager.shared.firestore.collection("users").document(uid).getDocument { snapshot, err in
if let err = err {
print("\(err)")
return
}
guard let data = snapshot?.data() else { return }
print(data)
self.qUser = .init(data: data)
}
}
}
struct TestProfileView: View {
#ObservedObject var vm = TestProfileViewModel()
var body: some View {
Text(vm.qUser?.username ?? "No User")
}
}
struct TestProfileView_Previews: PreviewProvider {
static var previews: some View {
TestProfileView()
}
}
This is the view, where I want to use the ID to fetch a user from my database and use it for a Profile View, ike how I did with the currentUser:
import SwiftUI
struct TestSongVIew: View {
let testUsername = "John"
let testUserID = "123123"
#State var showingUserProfile = false
var body: some View {
VStack {
Button("Open \(testUsername)'s Profile") {
}
}
.fullScreenCover(isPresented: $showingUserProfile, onDismiss: nil) {
TestProfileView()
}
}
}
struct TestSongVIew_Previews: PreviewProvider {
static var previews: some View {
TestSongVIew()
}
}
Here is my FirebaseManager code
import Foundation
import Firebase
import FirebaseFirestore
class FirebaseManager: NSObject {
let auth: Auth
let storage: Storage
let firestore: Firestore
static let shared = FirebaseManager()
override init() {
FirebaseApp.configure()
self.auth = Auth.auth()
self.storage = Storage.storage()
self.firestore = Firestore.firestore()
super .init()
}
}
There are a couple of ways to achieve this, and they all depend on how you set up the navigation for your app.
I'm currently working on a blog post / video to demonstrate how to monitor authentication state in a SwiftUI app. To demonstrate how to implement your use case, I added a profile screen that you can use in two ways:
You can navigate to the profile screen from the app's settings screen. This will show the user profile of the currently signed in user.
You can navigate to the profile screen from a List view showing all user profiles in your user profile collection in Firestore. This might be useful if you want to implement a high score screen that allows the user to navigate to the profile screen for each of the top 10 players in a game.
Ok, here goes:
Profile
The profile model
import Foundation
import FirebaseFirestore
import FirebaseFirestoreSwift
struct Profile: Identifiable, Codable {
#DocumentID var id: String? = ""
var nickname: String
}
extension Profile {
static let empty = Profile(nickname: "")
}
The profile view model
import Foundation
import Combine
import FirebaseFirestore
import FirebaseFirestoreSwift
class ProfileViewModel: ObservableObject {
// MARK: - Output
#Published var profile: Profile
init(profile: Profile) {
self.profile = profile
}
init(uid: String) {
self.profile = Profile.empty
fetchProfile(uid)
}
// MARK: - Private attributes
private var db = Firestore.firestore()
func fetchProfile(_ uid: String) {
db.collection("profiles")
.whereField("uid", isEqualTo: uid)
.getDocuments { querySnapshot, error in
if let error = error {
print("Error getting documents: \(error)")
}
else {
if let querySnapshot = querySnapshot {
if let document = querySnapshot.documents.first {
do {
self.profile = try document.data(as: Profile.self)
}
catch {
}
}
}
}
}
}
}
The profile view
struct ProfileView: View {
#ObservedObject var viewModel: ProfileViewModel
init(profile: Profile) {
self.viewModel = ProfileViewModel(profile: profile)
}
init(uid: String) {
self.viewModel = ProfileViewModel(uid: uid)
}
var body: some View {
Form {
Text(viewModel.profile.nickname)
}
.navigationTitle("Details")
}
}
struct ProfileView_Previews: PreviewProvider {
static var previews: some View {
ProfileView(profile: Profile(nickname: "freter"))
}
}
Settings
The settings view model
import Foundation
import Combine
import FirebaseAuth
class SettingsViewModel: ObservableObject {
// MARK: - Output
#Published var email: String = ""
#Published var idToken: String = ""
#Published var user: User?
#Published var authenticationState: AuthenticationState = .unauthenticated
// MARK: - Dependencies
private var authenticationService: AuthenticationService?
func connect(authenticationService: AuthenticationService) {
if self.authenticationService == nil {
self.authenticationService = authenticationService
self.authenticationService?
.$authenticationState
.assign(to: &$authenticationState)
self.authenticationService?
.$user
.assign(to: &$user)
$user
.map { $0?.email }
.replaceNil(with: "(no email address)")
.assign(to: &$email)
}
}
#MainActor
func refreshIDToken() {
Task {
do {
idToken = try await user?.idTokenForcingRefresh(true) ?? ""
}
catch {
idToken = error.localizedDescription
print(error)
}
}
}
}
The settings view
import SwiftUI
struct SettingsView: View {
#StateObject var viewModel = SettingsViewModel()
#Environment(\.dismiss) var dismiss
#EnvironmentObject var authenticationService: AuthenticationService
#State private var presentingLoginScreen = false
var loginButton: some View {
Button(authenticationService.authenticationState == .unauthenticated ? "Login" : "Logout") {
if authenticationService.authenticationState == .unauthenticated {
presentingLoginScreen.toggle()
}
else {
authenticationService.signOut()
}
}
.frame(maxWidth: .infinity)
}
var body: some View {
Form {
Section {
Label("Help & Feedback", systemImage: "questionmark.circle")
Label("About", systemImage: "info.circle")
}
Section {
Label(viewModel.email, systemImage: "at")
Label(viewModel.idToken, systemImage: "person")
Button(action: viewModel.refreshIDToken) {
Text("Refresh ID token")
}
NavigationLink(destination: ProfileView(uid: viewModel.user?.uid ?? "unknown")) {
Label("Show user profile", systemImage: "person")
}
} header: {
Text("User Details")
}
Section {
loginButton
}
}
.sheet(isPresented: $presentingLoginScreen) {
LoginView()
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
.onAppear {
viewModel.connect(authenticationService: authenticationService)
}
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
SettingsView()
.environmentObject(AuthenticationService())
}
}
}
Authentication
The authentication service
import Foundation
import FirebaseAuth
enum AuthenticationState {
case unauthenticated
case authenticating
case authenticated
}
class AuthenticationService: ObservableObject {
// MARK: - Output
#Published var authenticationState: AuthenticationState = .unauthenticated
#Published var errorMessage: String = ""
#Published var user: User?
init() {
registerAuthStateListener()
}
#MainActor
func signIn(withEmail email: String, password: String) async -> Bool {
authenticationState = .authenticating
do {
try await Auth.auth().signIn(withEmail: email, password: password)
return true
}
catch {
await MainActor.run {
errorMessage = error.localizedDescription
authenticationState = .unauthenticated
}
print(error)
return false
}
}
func signOut() {
do {
try Auth.auth().signOut()
}
catch {
print(error)
}
}
private var handle: AuthStateDidChangeListenerHandle?
private func registerAuthStateListener() {
if handle == nil {
handle = Auth.auth().addStateDidChangeListener { auth, user in
Task {
await MainActor.run {
self.user = user
if let user = user {
self.authenticationState = .authenticated
print("User \(user.uid) signed in. Email: \(user.email ?? "(no email address set)"), anonymous: \(user.isAnonymous)")
}
else {
self.authenticationState = .unauthenticated
print("User signed out.")
}
}
}
}
}
}
}
The login view model
import Foundation
import Combine
import FirebaseAuth
class LoginViewModel: ObservableObject {
// MARK: - Input
#Published var email: String = ""
#Published var password: String = ""
// MARK: - Output
#Published var isValid: Bool = false
#Published var authenticationState: AuthenticationState = .unauthenticated
#Published var errorMessage: String = ""
#Published var user: User?
// MARK: - Dependencies
private var authenticationService: AuthenticationService?
func connect(authenticationService: AuthenticationService) {
if self.authenticationService == nil {
self.authenticationService = authenticationService
self.authenticationService?
.$authenticationState
.assign(to: &$authenticationState)
self.authenticationService?
.$errorMessage
.assign(to: &$errorMessage)
self.authenticationService?
.$user
.assign(to: &$user)
Publishers.CombineLatest($email, $password)
.map { !($0.isEmpty && $1.isEmpty) }
.print()
.assign(to: &$isValid)
}
}
func signInWithEmailPassword() async -> Bool {
if let authenticationService = authenticationService {
return await authenticationService.signIn(withEmail: email, password: password)
}
else {
return false
}
}
}
The login view
import SwiftUI
enum FocusableField: Hashable {
case email
case password
}
struct LoginView: View {
#StateObject var viewModel = LoginViewModel()
#EnvironmentObject var authenticationService: AuthenticationService
#Environment(\.dismiss) var dismiss
#FocusState private var focus: FocusableField?
private func signInWithEmailPassword() {
Task {
if await viewModel.signInWithEmailPassword() == true {
dismiss()
}
}
}
var body: some View {
VStack {
Image("Login")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(minHeight: 0)
Text("Login")
.font(.largeTitle)
.fontWeight(.bold)
.frame(maxWidth: .infinity, alignment: .leading)
HStack {
Image(systemName: "at")
TextField("Email", text: $viewModel.email)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.focused($focus, equals: .email)
.submitLabel(.next)
.onSubmit {
self.focus = .password
}
}
.padding(.vertical, 6)
.background(Divider(), alignment: .bottom)
.padding(.bottom, 4)
HStack {
Image(systemName: "lock")
SecureField("Password", text: $viewModel.password)
.focused($focus, equals: .password)
.submitLabel(.go)
.onSubmit {
signInWithEmailPassword()
}
}
.padding(.vertical, 6)
.background(Divider(), alignment: .bottom)
.padding(.bottom, 8)
if !viewModel.errorMessage.isEmpty {
VStack {
Text(viewModel.errorMessage)
.foregroundColor(Color(UIColor.systemRed))
}
}
Button(action: signInWithEmailPassword) {
if viewModel.authenticationState != .authenticating {
Text("Login")
.frame(maxWidth: .infinity)
}
else {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity)
}
}
.disabled(!viewModel.isValid)
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
.controlSize(.large)
HStack {
VStack { Divider() }
Text("or")
VStack { Divider() }
}
Button(action: { }) {
Image(systemName: "applelogo")
.frame(maxWidth: .infinity)
}
.foregroundColor(.black)
.buttonStyle(.bordered)
.controlSize(.large)
HStack {
Text("Don't have an account yet?")
Button(action: {}) {
Text("Sign up")
.fontWeight(.semibold)
.foregroundColor(.blue)
}
}
.padding([.top, .bottom], 50)
}
.onAppear {
viewModel.connect(authenticationService: authenticationService)
}
.listStyle(.plain)
.padding()
}
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
Group {
LoginView()
.environmentObject(AuthenticationService())
LoginView()
.preferredColorScheme(.dark)
.environmentObject(AuthenticationService())
}
}
}

how use swift await func in onSubmit block

I have a list with data from the search.
to get data I want to call to await func (swift 5.5) but I get this error:
"Cannot pass function of type '() async -> ()' to parameter expecting
synchronous function type"
this is my code:
struct ContentView: View {
#ObservedObject var twitterAPI: TwitterAPI = TwitterAPI()
#State private var searchText = "TheEllenShow" // TheEllenShow
var body: some View {
NavigationView {
VStack{
if twitterAPI.twitterSearchResults?.resultDataVM != nil{
List {
ForEach((twitterAPI.twitterSearchResults?.resultDataVM)!) { item in
Text(item.text)
}
}
.refreshable {
await twitterAPI.executeQuery(userName: searchText)
}
}else{
Text("Loading")
}
Spacer()
}
.searchable(text: $searchText)
.onSubmit(of: .search) {
await twitterAPI.executeQuery(userName: searchText)
}
.navigationTitle("Twitter")
}
.task {
await twitterAPI.executeQuery(userName: searchText)
}
} }
To call asynchronous code from a synchronous code block, you can create a Task object:
.onSubmit(of: .search) {
Task {
await twitterAPI.executeQuery(userName: searchText)
}
}
You could bounce it over to .task like this:
#State var submittedSearch = ""
#State var results = []
.onSubmit(of: .search) {
submittedSearch = searchText
}
.task(id: submittedSearch) {
if submittedSearch.isEmpty {
return
}
results = await twitterAPI.executeQuery(userName: submittedSearch)
}
Has the advantage it will be cancelled and restarted if the search changes and also when the underlying UIView disappears.

#ObservedObject not triggering redraw if in conditional

I have the following code:
import SwiftUI
struct RootView: View {
#ObservedObject var authentication: AuthenticationModel
var body: some View {
ZStack {
if self.authentication.loading {
Text("Loading")
} else if self.authentication.userId == nil {
SignInView()
} else {
ContentView()
}
}
}
}
However, the #ObservedObject's changes doesn't seem to trigger the switch to the other views. I can "fix" this by rendering
var body: some View {
VStack {
Text("\(self.authentication.loading ? "true" : "false") \(self.authentication.userId ?? "0")")
}.font(.largeTitle)
ZStack {
if self.authentication.loading {
Text("Loading")
} else if self.authentication.userId == nil {
SignInView()
} else {
ContentView()
}
}
}
and suddenly it starts working. Why does #ObservedObject not seem to trigger a rerender if the watched properties are only used in conditionals?
The code for AuthenticationModel is:
import SwiftUI
import Combine
import Firebase
import FirebaseAuth
class AuthenticationModel: ObservableObject {
#Published var userId: String?
#Published var loading = true
init() {
// TODO: Properly clean up this handle.
Auth.auth().addStateDidChangeListener { [unowned self] (auth, user) in
self.userId = user?.uid
self.loading = false
}
}
}
I think the problem could be that you aren't creating an instance of AuthenticationModel.
Can you try the following in RootView?:
#ObservedObject var authentication = AuthenticationModel()