How can I make a ViewModel instance alive in SwiftUI? - swiftui

In my app, there is a singleton instance, AppSetting, which is used in the entire views and models. AppSetting has a variable, userName.
class AppSetting: ObservableObject {
static let shared = AppSetting()
private init() { }
#Published var userName: String = ""
}
ParentView prints userName when it is not empty. At first, it is empty.
struct ParentView: View {
#State var isChildViewPresented = false
#ObservedObject var appSetting = AppSetting.shared
var body: some View {
ZStack {
Color.white.edgesIgnoringSafeArea(.all)
VStack {
Button(action: { self.isChildViewPresented = true }) {
Text("Show ChildView")
}
if !appSetting.userName.isEmpty { // <--- HERE!!!
Text("\(appSetting.userName)")
}
}
if isChildViewPresented {
ChildView(isPresented: $isChildViewPresented)
}
}
}
}
When a user taps the button, userName will be set.
struct ChildView: View {
#Binding var isPresented: Bool
#ObservedObject var childModel = ChildModel()
var body: some View {
ZStack {
Color.white.edgesIgnoringSafeArea(.all)
VStack {
Button(action: { self.childModel.setUserName() }) { // <--- TAP BUTTON HERE!!!
Text("setUserName")
}
Button(action: { self.isPresented = false }) {
Text("Close")
}
}
}
}
}
class ChildModel: ObservableObject {
init() { print("init") }
deinit { print("deinit") }
func setUserName() {
AppSetting.shared.userName = "StackOverflow" // <--- SET userName HERE!!!
}
}
The problem is when userName is set, the instance of ChildModel is invalidated. I think when ParentView adds Text("\(appSetting.userName)"), it changes its view hierarchy and then it makes SwiftUI delete the old instance of ChildModel and create a new one. Sadly, it gives me tons of bug. In my app, the ChildModel instance must be alive until a user explicitly closes ChildView.
How can I make the ChildModel instance alive?
Thanks in advance.

It is possible when to de-couple view & view model and inject dependency via constructor
struct ChildView: View {
#Binding var isPresented: Bool
#ObservedObject var childModel: ChildModel // don't initialize
// ... other your code here
store model somewhere externally and inject when show child view
if isChildViewPresented {
// inject ref to externally stored ChildModel()
ChildView(isPresented: $isChildViewPresented, viewModel: childModel)
}

Related

SwiftUI: Must an ObservableObject be passed into a View as an EnvironmentObject?

If I create an ObservableObject with a #Published property and inject it into a SwifUI view with .environmentObject(), the view responds to changes in the ObservableObject as expected.
class CounterStore: ObservableObject {
#Published private(set) var counter = 0
func increment() {
counter += 1
}
}
struct ContentView: View {
#EnvironmentObject var store: CounterStore
var body: some View {
VStack {
Text("Count: \(store.counter)")
Button(action: { store.increment() }) {
Text("Increment")
}
}
}
}
Tapping on "Increment" will increase the count.
However, if I don't use the EnvironmentObject and instead pass the store instance into the view, the compiler does not complain, the store method increment() is called when the button is tapped, but the count in the View does not update.
struct ContentViewWithStoreAsParameter: View {
var store: CounterStore
var body: some View {
VStack {
Text("Count: \(store.counter) (DOES NOT UPDATE)")
Button(action: { store.increment() }) {
Text("Increment")
}
}
}
}
Here's how I'm calling both Views:
#main
struct testApp: App {
var store = CounterStore()
var body: some Scene {
WindowGroup {
VStack {
ContentView().environmentObject(store) // works
ContentViewWithStoreAsParameter(store: store) // broken
}
}
}
}
Is there a way to pass an ObservableObject into a View as a parameter? (Or what magic is .environmentalObject() doing behind the scenes?)
It should be observed somehow, so next works
struct ContentViewWithStoreAsParameter: View {
#ObservedObject var store: CounterStore
//...
You can pass down your store easily as #StateObject:
#main
struct testApp: App {
#StateObject var store = CounterStore()
var body: some Scene {
WindowGroup {
VStack {
ContentView().environmentObject(store) // works
ContentViewWithStoreAsParameter(store: store) // also works
}
}
}
}
struct ContentViewWithStoreAsParameter: View {
#StateObject var store: CounterStore
var body: some View {
VStack {
Text("Count: \(store.counter)") // now it does update
Button(action: { store.increment() }) {
Text("Increment")
}
}
}
}
However, the store should normally only be available for the views that need it, why this solution would make the most sense in this context:
struct ContentView: View {
#StateObject var store = CounterStore()
var body: some View {
VStack {
Text("Count: \(store.counter)")
Button(action: { store.increment() }) {
Text("Increment")
}
}
}
}

Read value of #ObservedObject only once without listening to changes

Suppose I have some environmental object that stores the states of a user being signed in or not;
class Account: ObservableObject{
#Published var isSignedIn: Bool = false
}
I want to conditional display a view if the user is signed in. So,
app loads -> RootView(). If user is signed In -> go to Profile, otherwise go to LoginScreen
struct RootView: View {
#EnvironmentObject private var account: Account
var body: some View {
NavigationStackViews{
// This is just a custom class to help display certain views with a transition animation
if account.isSignedIn{ // I only want to read this ONCE. Otherwise, when this value changes, my view will abruptly change views
ProfileView()
} else { SignInView() }
}
}
}
Set a #State variable in onAppear(perform:) of the current isSignedIn value. Then use that in the view body instead.
Code:
struct RootView: View {
#EnvironmentObject private var account: Account
#State private var isSignedIn: Bool?
var body: some View {
NavigationStackViews {
if let isSignedIn = isSignedIn {
if isSignedIn {
ProfileView()
} else {
SignInView()
}
}
}
.onAppear {
isSignedIn = account.isSignedIn
}
}
}

Where do I handle the initial value before the first onChange(of:)?

I want to load some data when my app is first launched, and when the app is foregrounded I want to ensure I have the latest data.
The state is stored in a ViewModel class, which my view owns as a #StateObject. I read the ScenePhase from the Environment, and in onChange(of: scenePhase), I call a method on my ViewModel to start the reload if needed.
But when should I start the initial load?
ContentView.init is too early, because scenePhase is .background. And even if it were .active, I'm apparently not supposed to access StateObject from init — SwiftUI logs a runtime warning.
ViewModel.init is too early as well — theoretically, I think the view model could be created even if the app were never brought to the foreground.
The first time var body is accessed, the scenePhase is .active. onChange(of:) doesn't call the closure for its initial value, so it's never called until I background and re-foreground the app.
class ViewModel: ObservableObject {
init() {
// 1. reload() here? Could happen without the app entering the foreground.
}
func reload() { ... }
}
struct ContentView: View {
#Environment(\.scenePhase) var scenePhase
#StateObject var viewModel = ViewModel()
init() {
// 2. viewModel.reload() here?
// - problem 1: scenePhase == .background, not .active
// - problem 2: not supposed to access a #StateObject here anyway
}
var body: some View {
(...)
// The initial render happens when scenePhase == .active,
// so I don't get the onChange callback until it changes again.
.onChange(of: scenePhase) {
if $0 == .active {
viewModel.reload()
}
}
}
}
If your ContentView is the root View you can just use onAppear.
However, if your ContentView can disappear and then reappear the above solution will not work.
A possible solution may be to inject a variable into the Environment:
struct LaunchAppKey: EnvironmentKey {
static let defaultValue = Binding.constant(false)
}
extension EnvironmentValues {
var isAppLaunched: Binding<Bool> {
get { return self[LaunchAppKey] }
set { self[LaunchAppKey] = newValue }
}
}
#main
struct TestApp: App {
#State private var isAppLaunched = false
#State private var showContentView = true
var body: some Scene {
WindowGroup {
VStack {
if showContentView {
ContentView()
} else {
Text("Some other view")
}
}
.environment(\.isAppLaunched, $isAppLaunched)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
showContentView.toggle()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
showContentView.toggle()
}
}
}
}
}
struct ContentView: View {
#Environment(\.isAppLaunched) var isAppLaunched
var body: some View {
(...)
.onAppear {
guard !isAppLaunched.wrappedValue else { return }
isAppLaunched.wrappedValue = true
print("apppear")
}
}
}

Missing argument for parameter 'View Call' in call

I am struggle with understanding about why i have to give Popup view dependency named vm while calling this view since it is observable
struct ContentView: View {
#State private var showPopup1 = false
var body: some View {
VStack {
Button(action: { withAnimation { self.showPopup1.toggle()}}){
Text("showPopup1") }
Text("title")
DetailView() /// this line shows error
}
}
}
struct DetailView:View {
#ObservedObject var vm:ViewModel
var body : some View {
Text("value from VM")
}
}
class ViewModel: ObservableObject {
#Published var title:String = ""
}
You have to set your vm property when you init your View. Which is the usual way.
struct ContentView: View {
#State private var showPopup1 = false
var body: some View {
VStack {
Button(action: { withAnimation { self.showPopup1.toggle()}}){
Text("showPopup1") }
Text("title")
DetailView(vm: ViewModel()) // Initiate your ViewModel() and pass it as DetailView() parameter
}
}
}
struct DetailView:View {
var vm: ViewModel
var body : some View {
Text("value from VM")
}
}
class ViewModel: ObservableObject {
#Published var title:String = ""
}
Or you could use #EnvironmentObject. You have to pass an .environmentObject(yourObject) to the view where you want to use yourObject, but again you'll have to initialize it before passing it.
I'm not sure it's the good way to do it btw, as an environmentObject can be accessible to all childs view of the view you declared the .environmentObject on, and you usually need one ViewModel for only one View.
struct ContentView: View {
#State private var showPopup1 = false
var body: some View {
VStack {
Button(action: { withAnimation { self.showPopup1.toggle()}}){
Text("showPopup1") }
Text("title")
DetailView().environmentObject(ViewModel()) // Pass your ViewModel() as an environmentObject
}
}
}
struct DetailView:View {
#EnvironmentObject var vm: ViewModel // you can now use your vm, and access it the same say in all childs view of DetailView
var body : some View {
Text("value from VM")
}
}
class ViewModel: ObservableObject {
#Published var title:String = ""
}

How to swap my #State of my SwiftUI view for my view model #Published variable?

I have a button that triggers my view state. As I have now added a network call, I would like my view model to replace the #State with its #Publihed variable to perform the same changes.
How to use my #Published in the place of my #State variable?
So this is my SwiftUI view:
struct ContentView: View {
#ObservedObject var viewModel = OnboardingViewModel()
// This is the value I want to use as #Publisher
#State var isLoggedIn = false
var body: some View {
ZStack {
Button(action: {
// Before my #State was here
// self.isLoggedIn = true
self.viewModel.login()
}) {
Text("Log in")
}
if isLoggedIn {
TutorialView()
}
}
}
}
And this is my model:
final class OnboardingViewModel: ObservableObject {
#Published var isLoggedIn = false
private var subscriptions = Set<AnyCancellable>()
func demoLogin() {
AuthRequest.shared.login()
.sink(
receiveCompletion: { print($0) },
receiveValue: {
// My credentials
print("Login: \($0.login)\nToken: \($0.token)")
DispatchQueue.main.async {
// Once I am logged in, I want this
// value to change my view.
self.isLoggedIn = true } })
.store(in: &subscriptions)
}
}
Remove state and use view model member directly, as below
struct ContentView: View {
#ObservedObject var viewModel = OnboardingViewModel()
var body: some View {
ZStack {
Button(action: {
self.viewModel.demoLogin()
}) {
Text("Log in")
}
if viewModel.isLoggedIn { // << here !!
TutorialView()
}
}
}
}
Hey Roland I think that what you are looking for is this:
$viewMode.isLoggedIn
Adding the $ before the var will ensure that SwiftUI is aware of its value changes.
struct ContentView: View {
#ObservedObject var viewModel = OnboardingViewModel()
var body: some View {
ZStack {
Button(action: {
viewModel.login()
}) {
Text("Log in")
}
if $viewMode.isLoggedIn {
TutorialView()
}
}
}
}
class OnboardingViewModel: ObservableObject {
#Published var isLoggedIn = false
func login() {
isLoggedIn = true
}
}