UserDefaults .set not being picked up by state variable in SwiftUI - ios

I am attempting to to detect whether a user has onboarded or not e.g. launched the app. When I access the UserDefaults.standard.bool directly I can see the change but when I attempt to access it through the #State it retains the original false value.
As a workaround I directly change the value to true, but I have a feeling this isn't the way to do this.
How can I get the #State variable to pick up the change when changing the UserDefaults object without having to close and restart the app? I want the user to onboard, click a button, then switch immediately to the main app view.
#State var userOnboarded: Bool = UserDefaults.standard.bool(forKey: "userOnboarded")
var body: some Scene {
WindowGroup {
if userOnboarded {
Text("PLACEHOLDER: Main App View")
} else {
Button(action: {
UserDefaults.standard.set(true, forKey: "userOnboarded")
userOnboarded = true
print(UserDefaults.standard.bool(forKey: "userOnboarded"))
}) {
VStack {
Text("Click circle to onboard.")
Circle()
.foregroundColor(Color.red)
}
}
}
}

In SwiftUI there is a dedicated wrapper to watch UserDefaults. As previously mentioned in your comments, it's called #AppStorage and it's used like so.
#AppStorage("userOnboarded") var userOnboarded: Bool = false
It functions similarly to a UserDefault but will refresh the view and set the data when the value changes.
Button(action: {
userOnboarded = true
print(UserDefaults.standard.bool(forKey: "userOnboarded"))
}) //....
Notice in the button, we set the userOnboarded to true, this will update and refresh your view as well as update the user default value.

Related

Pulling from UserDefaults for a Picker

I feel like I must be doing something stupid. All I'm trying to do if pull from UserDefaults to set a Picker value, then save to UserDefaults when the Picker changes. The issue I'm running into comes when I try and make the code work for iOS14 and 15.
struct SettingsView: View {
#State var rangeSelection: Int = UserDefaults.standard.integer(forKey: "vocalRange")
#State var rangeOptions = ["Soprano", "Mezzo-Soprano/Alto", "Extended Range"]
var body: some View {
VStack{
Form {
Section {
Picker(selection: $rangeSelection, label: Text("Range")) {
ForEach(0 ..< rangeOptions.count, id: \.self) { range in
Text(rangeOptions[range])
.tag(range)
}
}
.onChange(of: rangeSelection, perform: { value in
UserDefaults.standard.setValue(rangeSelection, forKey: "vocalRange")
})
}
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
}
}
This seems to work fine, and the 'onChange' fires great. Only weird thing is, if you leave the page and immediately go back in, the 'rangeSelection' variable resets to what it was originally. Once you restart the app however, the change is reflected correctly.
As an alternative, I tried the following
#State var rangeSelection: Int = 0
...
.onAppear(perform:{
rangeSelection = UserDefaults.standard.integer(forKey: "vocalRange")
})
This creates another fun issue. In iOS15 it works fine, but in iOS14, once you've changed the picker, the OnAppear fires before the OnChange, and reverts the Picker back to its previous value. This only happens because I'm using the picker in a Form/List, so it changes pages when you select the Picker.
I feel like there is an obvious solution I should be using.
You can set delay in .OnAppear method to work it
.onAppear(perform:{
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
rangeSelection = UserDefaults.standard.integer(forKey: "vocalRange")
}
})
Thanks to #loremipsum I used AppStorage. I declared the variable like this, and no 'onChange' was needed!
#AppStorage("vocalRange") var rangeSelection: Int = UserDefaults.standard.integer(forKey: "vocalRange")

SwiftUI Schedule Local Notification Without Button?

This may have a very simple answer, as I am pretty new to Swift and SwiftUI and am just starting to learn. I'm trying to schedule local notifications that will repeat daily at a specific time, but only do it if a toggle is selected. So if a variable is true, I want that notification to be scheduled. I looked at some tutorials online such as this one, but they all show this using a button. Instead of a button I want to use a toggle. Is there a certain place within the script that this must be done? What do I need to do differently in order to use a toggle instead of a button?
You can observe when the toggle is turned on and turned off -- In iOS 14 you can use the .onChange modifier to do this:
import SwiftUI
struct ContentView: View {
#State var isOn: Bool = false
var body: some View {
Toggle(isOn: $isOn, label: {
Text("Notifications?")
})
/// right here!
.onChange(of: isOn, perform: { toggleIsOn in
if toggleIsOn {
print("schedule notification")
} else {
print("don't schedule notification")
}
})
}
}
For earlier versions, you can try using onReceive with Combine:
import SwiftUI
import Combine
struct ContentView: View {
#State var isOn: Bool = false
var body: some View {
Toggle(isOn: $isOn, label: {
Text("Notifications?")
})
/// a bit more complicated, but it works
.onReceive(Just(isOn)) { toggleIsOn in
if toggleIsOn {
print("schedule notification")
} else {
print("don't schedule notification")
}
}
}
}
You can find even more creative solutions to observe the toggle change here.

SwiftUI: dismiss sheet programmatically

I'm using SwiftUI and I'm trying to implement the auth logic for my app.
I have a LoginView with a Register Button and if I click on it I use a sheet to present the RegisterView. Once the user is registered, the LoginView (on background) goes to HomeView and RegisterView should disappear. The problem is that RegisterView is not disappearing.
#ObservedObject var viewModel = RegisterViewModel()
#EnvironmentObject var authenticatedUser : AuthenticatedUser
#Environment(\.presentationMode) var presentationMode
ButtonWithLoadStateView(title: K.REGISTER, isLoading: self.$vm.isLoading) {
self.viewModel.isLoading = true
self.viewModel.register() { user in
self.authenticatedUser.setLogged(user) // without this IT WORKS!
self.presentationMode.wrappedValue.dismiss()
}
}
If I remove the authenticatedUser.setLogged row then it works but it just encapsulate the user and store a token..
The provided code is not testable, so just only idea - try the following
ButtonWithLoadStateView(title: K.REGISTER, isLoading: self.$vm.isLoading) {
self.viewModel.isLoading = true
self.viewModel.register() { user in
DispatchQueue.main.async {
self.authenticatedUser.setLogged(user)
}
self.presentationMode.wrappedValue.dismiss()
}
}

SwiftUI NavigationLink push in onAppear immediately pops the view when using #ObservableObject

I want to programmatically be able to navigate to a link within a List of NavigationLinks when the view appears (building deep linking from push notification). I have a string -> Bool dictionary which is bound to a custom Binding<Bool> inside my view. When the view appears, I set the bool property, navigation happens, however, it immediately pops back. I followed the answer in SwiftUI NavigationLink immediately navigates back and made sure that each item in the List has a unique identifier, but the issue still persists.
Two questions:
Is my binding logic here correct?
How come the view pops back immediately?
import SwiftUI
class ContentViewModel: ObservableObject {
#Published var isLinkActive:[String: Bool] = [:]
}
struct ContentViewTwo: View {
#ObservedObject var contentViewModel = ContentViewModel()
#State var data = ["1", "2", "3"]
#State var shouldPushPage3: Bool = true
var page3: some View {
Text("Page 3")
.onAppear() {
print("Page 3 Appeared!")
}
}
func binding(chatId: String) -> Binding<Bool> {
return .init(get: { () -> Bool in
return self.contentViewModel.isLinkActive[chatId, default: false]
}) { (value) in
self.contentViewModel.isLinkActive[chatId] = value
}
}
var body: some View {
return
List(data, id: \.self) { data in
NavigationLink(destination: self.page3, isActive: self.binding(chatId: data)) {
Text("Page 3 Link with Data: \(data)")
}.onAppear() {
print("link appeared")
}
}.onAppear() {
print ("ContentViewTwo Appeared")
if (self.shouldPushPage3) {
self.shouldPushPage3 = false
self.contentViewModel.isLinkActive["3"] = true
print("Activating link to page 3")
}
}
}
}
struct ContentView: View {
var body: some View {
return NavigationView() {
VStack {
Text("Page 1")
NavigationLink(destination: ContentViewTwo()) {
Text("Page 2 Link")
}
}
}
}
}
The error is due to the lifecycle of the ViewModel, and is a limitation with SwiftUI NavigationLink itself at the moment, will have to wait to see if Apple updates the pending issues in the next release.
Update for SwiftUI 2.0:
Change:
#ObservedObject var contentViewModel = ContentViewModel()
to:
#StateObject var contentViewModel = ContentViewModel()
#StateObject means that changes in the state of the view model do not trigger a redraw of the whole body.
You also need to store the shouldPushPage3 variable outside the View as the view will get recreated every time you pop back to the root View.
enum DeepLinking {
static var shouldPushPage3 = true
}
And reference it as follows:
if (DeepLinking.shouldPushPage3) {
DeepLinking.shouldPushPage3 = false
self.contentViewModel.isLinkActive["3"] = true
print("Activating link to page 3")
}
The bug got fixed with the latest SwiftUI release. But to use this code at the moment, you will need to use the beta version of Xcode and iOS 14 - it will be live in a month or so with the next GM Xcode release.
I was coming up against this problem, with a standard (not using 'isActive') NavigationLink - for me the problem turned out to be the use of the view modifiers: .onAppear{code} and .onDisappear{code} in the destination view. I think it was causing a re-draw loop or something which caused the view to pop back to my list view (after approx 1 second).
I solved it by moving the modifiers onto a part of the destination view that's not affected by the code in those modifiers.

SwiftUI - Pop back in navigation stack does not deallocate a view

I would like to start by highlighting my views hierarchy. I just have FindUserView and WelcomeView.
FindUserView is used for retrieving users from the server if the entered email exists. If so, then it automatically redirects me to the next WelcomeView where I can enter password and login.
I've created a repo here and a video SwiftUI - Pop back does not deallocate view
My FindUserView: ---------------------------- and WelcomeView:
-----------------
By pressing NEXT button on FindUserView I fetch a user from the database:
func fetchUser(with email: String) {
userService.getUser(with: email) { (result) in
switch result {
case .success(_):
self.showActivityIndicator = false
self.showingAlert = false
self.showWelcomeView = true
break
case .failure:
self.showingAlert = true
break
}
}
}
I use NavigationView and programatically show WelcomeView by changing showWelcomeView state above:
NavigationLink(destination: WelcomeView(), isActive: $showWelcomeView) { EmptyView() }
Now I am on welcome view WelcomeView.
But when I press this button and pop back, my WelcomeView still exists.
As I use #EnvironmentObject with observable property state I see how it reflects to the view which is already dismissed. Is this correct behaviour? Or do I need to dealloc WelcomeView somehow? Does it lead to memory leaks?
I am a bit worry as in UIKit when you pop back in navigation stack the view controller it is deallocated by UINavigationController by removing view controller from the array automatically. How to pop back correctly in SwiftUI?
Actually it is not clear if it is defect or not - SwiftUI views are values, so there is no dealloc thing for them. It looks like NavigationView just keeps something like lastView variable until it is replaced with another one. Maybe worth submitting feedback to Apple.
Meanwhile here is solution that allows to defer real destination view creation until exactly NavigationLink tapped and cleanup it (w/ any related resources, like view model) when view is removed from stack by Back button.
Tested with Xcode 11.4 / iOS 13.4
Helper proxy view presenter:
struct LinkPresenter<Content: View>: View {
let content: () -> Content
#State private var invlidated = false
init(#ViewBuilder _ content: #escaping () -> Content) {
self.content = content
}
var body: some View {
Group {
if self.invlidated {
EmptyView()
} else {
content()
}
}
.onDisappear { self.invlidated = true }
}
}
Usage:
NavigationLink(destination: LinkPresenter { WelcomeView() },
isActive: $showWelcomeView) { EmptyView() }

Resources