#ObservedObject not triggering redraw if in conditional - swift

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

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

Enum in viewmodel is not triggering refresh in SwiftUI

I've got a ViewModel which conforms the ObservableObject protocol.
This ViewModel holds a enum variable.
class DeviceViewModel:ObservableObject {
enum ConnectionState: Equatable, CaseIterable {
case NoConnected
case Connected
}
#Published var connectionState: ConnectionState = .NoConnected
}
I also got a simple view that it will change the text depending of that enum:
struct ContentView: View {
let viewModel: DeviceViewModel
var body: some View {
if viewModel.connectionState != .NoConnected {
Text("connected")
} else {
Text("No connected")
}
}
}
I've noticed that if the enum connectionState changes it won't trigger the view to refresh.
To test this I've added a init method in the ViewModel with the following asyncAfter:
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
guard let self = self else {
return
}
self.connectionState = .Connected
print("self.connectionState: \(self.connectionState)")
}
}
Any idea what I'm missing?
Thanks
The view needs to observe the changes in order to refresh:
struct ContentView: View {
#ObservedObject let viewModel: DeviceViewModel
...
Use #StateObject to declare your viewModel.
struct ContentView: View {
#StateObject var viewModel = DeviceViewModel()
var body: some View {
if viewModel.connectionState != .NoConnected {
Text("connected")
} else {
Text("No connected")
}
}
}

How to implement multi window with menu commands in SwiftUI for macOS?

Situation
Implement a multi window application, where each window has its own state.
Example
Here is an example (on github) to showcase the question:
import SwiftUI
#main
struct multi_window_menuApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}.commands {
MenuCommands()
}
}
}
struct ContentView: View {
#StateObject var viewModel: ViewModel = ViewModel()
var body: some View {
TextField("", text: $viewModel.inputText)
.disabled(true)
.padding()
}
}
public class ViewModel: ObservableObject {
#Published var inputText: String = "" {
didSet {
print("content was updated...")
}
}
}
Question
How should we programmatically figure out what is the currently selected view so we can update the state when the menu command is about to finish and update the state in the view model?
import Foundation
import SwiftUI
import Combine
struct MenuCommands: Commands {
var body: some Commands {
CommandGroup(after: CommandGroupPlacement.newItem, addition: {
Divider()
Button(action: {
let dialog = NSOpenPanel();
dialog.title = "Choose a file";
dialog.showsResizeIndicator = true;
dialog.showsHiddenFiles = false;
dialog.allowsMultipleSelection = false;
dialog.canChooseDirectories = false;
if (dialog.runModal() == NSApplication.ModalResponse.OK) {
let result = dialog.url
if (result != nil) {
let path: String = result!.path
do {
let string = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
print(string)
// how to get access to the currently active view model to update the inputText variable?
// viewModel.inputText = string
}
catch {
print("Error \(error)")
}
}
} else {
return
}
}, label: {
Text("Open File")
})
.keyboardShortcut("O", modifiers: .command)
})
}
}
Links that might be useful to figure this out:
http://www.gfrigerio.com/build-a-macos-app-with-swiftui/
https://troz.net/post/2021/swiftui_mac_menus/
https://onmyway133.com/posts/how-to-manage-windowgroup-in-swiftui-for-macos/
Useful links:
How to access NSWindow from #main App using only SwiftUI?
How to access own window within SwiftUI view?
https://lostmoa.com/blog/ReadingTheCurrentWindowInANewSwiftUILifecycleApp/
(this is what I was able to come up with, if anyone has a better idea/approach, please share)
The idea is to create a shared "global" view model that keeps track of opened windows and view models. Each NSWindow has an attribute with a unique windowNumber. When a window becomes active (key), it looks up the view model by the windowNumber and sets it as the activeViewModel.
import SwiftUI
class GlobalViewModel : NSObject, ObservableObject {
// all currently opened windows
#Published var windows = Set<NSWindow>()
// all view models that belong to currently opened windows
#Published var viewModels : [Int:ViewModel] = [:]
// currently active aka selected aka key window
#Published var activeWindow: NSWindow?
// currently active view model for the active window
#Published var activeViewModel: ViewModel?
func addWindow(window: NSWindow) {
window.delegate = self
windows.insert(window)
}
// associates a window number with a view model
func addViewModel(_ viewModel: ViewModel, forWindowNumber windowNumber: Int) {
viewModels[windowNumber] = viewModel
}
}
Then, react on every change on window (when it is being closed and when it becomes an active aka key window):
import SwiftUI
extension GlobalViewModel : NSWindowDelegate {
func windowWillClose(_ notification: Notification) {
if let window = notification.object as? NSWindow {
windows.remove(window)
viewModels.removeValue(forKey: window.windowNumber)
print("Open Windows", windows)
print("Open Models", viewModels)
}
}
func windowDidBecomeKey(_ notification: Notification) {
if let window = notification.object as? NSWindow {
print("Activating Window", window.windowNumber)
activeWindow = window
activeViewModel = viewModels[window.windowNumber]
}
}
}
Provide a way to lookup window that is associated to the current view:
import SwiftUI
struct HostingWindowFinder: NSViewRepresentable {
var callback: (NSWindow?) -> ()
func makeNSView(context: Self.Context) -> NSView {
let view = NSView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
Here is the view that is updating the global view model with the current window and viewModel:
import SwiftUI
struct ContentView: View {
#EnvironmentObject var globalViewModel : GlobalViewModel
#StateObject var viewModel: ViewModel = ViewModel()
var body: some View {
HostingWindowFinder { window in
if let window = window {
self.globalViewModel.addWindow(window: window)
print("New Window", window.windowNumber)
self.globalViewModel.addViewModel(self.viewModel, forWindowNumber: window.windowNumber)
}
}
TextField("", text: $viewModel.inputText)
.disabled(true)
.padding()
}
}
Then we need to create the global view model and send it to the views and commands:
import SwiftUI
#main
struct multi_window_menuApp: App {
#State var globalViewModel = GlobalViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(self.globalViewModel)
}
.commands {
MenuCommands(globalViewModel: self.globalViewModel)
}
Settings {
VStack {
Text("My Settingsview")
}
}
}
}
Here is how the commands look like, so they can access the currently selected/active viewModel:
import Foundation
import SwiftUI
import Combine
struct MenuCommands: Commands {
var globalViewModel: GlobalViewModel
var body: some Commands {
CommandGroup(after: CommandGroupPlacement.newItem, addition: {
Divider()
Button(action: {
let dialog = NSOpenPanel();
dialog.title = "Choose a file";
dialog.showsResizeIndicator = true;
dialog.showsHiddenFiles = false;
dialog.allowsMultipleSelection = false;
dialog.canChooseDirectories = false;
if (dialog.runModal() == NSApplication.ModalResponse.OK) {
let result = dialog.url
if (result != nil) {
let path: String = result!.path
do {
let string = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
print("Active Window", self.globalViewModel.activeWindow?.windowNumber)
self.globalViewModel.activeViewModel?.inputText = string
}
catch {
print("Error \(error)")
}
}
} else {
return
}
}, label: {
Text("Open File")
})
.keyboardShortcut("O", modifiers: [.command])
})
}
}
All is updated and runnable under this github project: https://github.com/ondrej-kvasnovsky/swiftui-multi-window-menu
I came across this question when I was solving a similar problem. I believe the SwiftUI way is to use FocusedValue:
// create an active viewmodel key
struct ActiveViewModelKey: FocusedValueKey {
typealias Value = ViewModel
}
extension FocusedValues {
var activeViewModel: ViewModel? {
get { self[ActiveViewModelKey.self] }
set { self[ActiveViewModelKey.self] = newValue }
}
}
struct ContentView: View {
#StateObject var viewModel: ViewModel = ViewModel()
var body: some View {
TextField("", text: $viewModel.inputText)
...
.focusedValue(\.activeViewModel, viewModel) // inject the focused value
}
}
struct MenuCommands: Commands {
#FocusedValue(\.activeViewModel) var activeViewModel // inject the active viewmodel
var body: some Commands {
CommandGroup(after: CommandGroupPlacement.newItem, addition: {
Divider()
Button(action: {
...
activeViewModel?.inputText = string
}, label: {
Text("Open File")
})
.keyboardShortcut("O", modifiers: [.command])
})
}
}

Share Data Binding Between Class and Struct View

For example, when I get data from API success or unsuccess I would like to show Alert but Alert there need isPresented type is Binding that's why I want to use concept Share Data as Binding between class and View is possible or have another way to do please help thanks advance.
#Published properties work well for sharing data between ObservableObjects and Views.
For example:
class ViewModel : ObservableObject {
struct APIError : Identifiable {
var id = UUID()
var message : String
}
#Published var error : APIError?
func apiCall() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.error = APIError(message: "Error message")
}
}
}
struct ContentView : View {
#StateObject var viewModel = ViewModel()
var body: some View {
VStack {
Text("Hello, world!")
.alert(item: $viewModel.error) { item in
Alert(title: Text(item.message))
}
}
.onAppear {
viewModel.apiCall()
}
}
}
You could also do this with a custom Binding, but it's a little messier. The above example would definitely be my go-to.
class ViewModel : ObservableObject {
#Published var errorMessage : String?
var alertBinding : Binding<Bool> {
.init {
self.errorMessage != nil
} set: { newValue in
if !newValue { self.errorMessage = nil }
}
}
func apiCall() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.errorMessage = "Error!"
}
}
}
struct ContentView : View {
#StateObject var viewModel = ViewModel()
var body: some View {
VStack {
Text("Hello, world!")
.alert(isPresented: viewModel.alertBinding) {
Alert(title: Text(viewModel.errorMessage ?? "(unknown)"))
}
}
.onAppear {
viewModel.apiCall()
}
}
}

SwiftUI authentication view

In swift UI I want the content view to be my root view for my app with a conditional setup to check if the users is logged in or not. If the user is logged in a list view shows other wise show the login view so the user can log in. Based on my research i could not find a best way to do this.
In my case I can not get the solution I found to work and do not know if it is the best solution.
import SwiftUI
struct ContentView: View {
#ObservedObject var userAuth: UserAuth = UserAuth()
// MARK: - View
#ViewBuilder
var body: some View {
if !userAuth.isLoggedin {
return LoginView().environmentObject(userAuth)
}
return BookList()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import Combine
class UserAuth: ObservableObject {
let didChange = PassthroughSubject<UserAuth,Never>()
// required to conform to protocol 'ObservableObject'
let willChange = PassthroughSubject<UserAuth,Never>()
func login() {
// login request... on success:
self.isLoggedin = true
}
func logout() {
// login request... on success:
self.isLoggedin = false
}
var isLoggedin = false {
didSet {
didChange.send(self)
}
// willSet {
// willChange.send(self)
// }
}
}
When running this all i get is a white screen. It seems that the view builder might be the problem but removing that i get a opaque error on content view
There two problems with provided code snapshot: 1) incorrect view builder content, and 2) incorrect model.
See below both fixed. Tested with Xcode 11.4 / iOS 13.4
struct ContentView: View {
#ObservedObject var userAuth: UserAuth = UserAuth()
// MARK: - View
#ViewBuilder // no need return inside
var body: some View {
if !userAuth.isLoggedin {
LoginView().environmentObject(userAuth)
}
else {
BookList()
}
}
}
import Combine
class UserAuth: ObservableObject {
#Published var isLoggedin = false // published property to update view
func login() {
// login request... on success:
self.isLoggedin = true
}
func logout() {
// login request... on success:
self.isLoggedin = false
}
}
how about just this:
var body: some View {
if !userAuth.isLoggedin {
LoginView().environmentObject(userAuth)
} else {
BookList()
}
}