Lets say i have an settings view in my app where a user can select 'Imperial' or 'Metric'. these settings are stored in userDefaults like so:
enum UserDefaultsKeys: String {
case measurementUnit = "measurementUnit"
}
enum MeasurementUnit: String, CaseIterable {
case metric = "metric"
case imperial = "Imperial"
}
class UserDefaultsWrapper: ObservableObject {
#Published var measurementUnit: String {
didSet {
UserDefaults.standard.set(measurementUnit, forKey: UserDefaultsKeys.measurementUnit.rawValue)
}
}
init() {
let locale = Locale.current
let systemMeasurementUnit = locale.usesMetricSystem ? MeasurementUnit.metric : MeasurementUnit.imperial
self.measurementUnit = UserDefaults.standard.string(forKey: UserDefaultsKeys.measurementUnit.rawValue) ?? systemMeasurementUnit.rawValue
}
}
struct SettingsView: View {
#StateObject var userDefaultsWrapper = UserDefaultsWrapper()
var body: some View {
Form {
HStack {
Text("Units \(userDefaultsWrapper.measurementUnit)")
Picker(selection: $userDefaultsWrapper.measurementUnit, label: Text("")) {
ForEach(MeasurementUnit.allCases, id: \.self) { unit in
Text(unit.rawValue)
.tag(unit.rawValue)
}
}
.pickerStyle(SegmentedPickerStyle())
}
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("Settings")
}
}
Now the app can add workout sessions which i will always store in metric and convert to imperial if needed. If they create a workout in imperial i will first convert it to metric to store it and to show it i will convert it back to imperial values.
to store these workouts i use core data. i want to have a formattedMeasurement computed property in my coreDataProperties file like this:
extension WorkoutSession {
#nonobjc public class func fetchRequest() -> NSFetchRequest<WorkoutSession> {
return NSFetchRequest<WorkoutSession>(entityName: "WorkoutSession")
}
#NSManaged public var created_at: Date?
#NSManaged public var id: UUID?
#NSManaged public var updated_at: Date?
#NSManaged public var measurement: Double
override public func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(UUID(), forKey: "id")
setPrimitiveValue(Date(), forKey: "updated_at")
}
var formattedMeasurement: String {
let userDefaultsWrapper = UserDefaultsWrapper()
let measurementUnit = MeasurementUnit(rawValue: userDefaultsWrapper.measurementUnit) ?? MeasurementUnit.metric
return measurementUnit == .metric ? // convert to metric : // convert to imperial
}
}
The settings tab and the workout tab are both accessible via a TabView.
Now if i change the measurement settings on the settings page and i go back to the workoutView the formattedMeasurement is not updated. only when i restart the app of force update that view by visiting another view outside the tabView.
I think this has something to do with the way classes work because i make 2 instances of the UserDefaulsWrapper class.
What is a better way of doing this?
Related
I'm using an approach similar to the one described on mockacoding - Dependency Injection in SwiftUI where my main ViewModel has the responsibility to create child viewModels.
In the code below I am not including the Factory, as it's very similar to the contents of the post above: it creates the ParentViewModel, passes to it dependencies and closures that construct the child view models.
struct Book { ... } // It's a struct, not a class
struct ParentView: View {
#StateObject var viewModel: ParentViewModel
var body: some View {
VStack {
if viewModel.book.bookmarked {
BookmarkedView(viewModel: viewModel.makeBookMarkedViewModel())
} else {
RegularView(viewModel: viewModel.makeBookMarkedViewModel())
}
}
}
}
class ParentViewModel: ObservableObject {
#Published var book: Book
// THIS HERE - This is how I am passing the #Published to #Binding
// Problem is I don't know if this is correct.
//
// Before, I was not using #Binding at all. All where #Published
// and I just pass the reference. But doing that would cause for
// the UI to NEVER update. That's why I changed it to use #Binding
private var boundBook: Binding<Book> {
Binding(get: { self.book }, set: { self.book = $0 })
}
// The Factory object passes down these closures
private let createBookmarkedVM: (_ book: Binding<Book>) -> BookmarkedViewModel
private let createRegularVM: (_ book: Binding<Book>) -> RegularViewModel
init(...) {...}
func makeBookmarkedViewModel() {
createBookmarkedVM(boundBook)
}
}
class BookmarkedView: View {
#StateObject var viewModel: BookmarkedViewModel
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Text(book.title) // <---- THIS IS THE PROBLEM. Not being updated
Button("Remove bookmark") {
viewModel.removeBookmark()
}
}
.onReceive(timer) { _ in
print("adding letter") // <-- this gets called
withAnimation {
viewModel.addLetterToBookTitle()
}
}
}
}
class BookmarkedViewModel: ObservableObject {
#Binding var book: Book
// ... some other dependencies passed by the Factory object
init(...) { ... }
public func removeBookmark() {
// I know a class would be better than a struct, bear with me
book = Book(title: book.title, bookmarked: false)
}
/// Adds an "a" to the title
public func addLetterToBookTitle() {
book = Book(title: book.title + "a", bookmarked: book.bookmarked)
print("letter added") // <-- this gets called as well
}
}
From the code above, let's take a look at BookmarkedView. If I click the button and viewModel.removeBookmark() gets called, the struct is re-assigned and ParentView now renders RegularView.
This tells me that I successfully bound #Published book: Book from ParentViewModel to #Binding book: Book from BookmarkedViewModel, through its boundBook computed property. This felt like the most weird thing I had to make.
However, the problem is that even though addLetterToBookTitle() is also re-assigning the book with a new title, and it should update the Text(book.title), it's not happening. The same title is being displayed.
I can guarantee that the book title has change (because of some other components of the app I'm omitting for simplicity), but the title's visual is not being updated.
This is the first time I'm trying out these pattern of having a view model build child view models, so I appreciate I may be missing something fundamental. What am I missing?
EDIT:
I made an MVP example here: https://github.com/christopher-francisco/TestMVVM/tree/main/MVVMTest.xcodeproj
I'm looking for whether:
My take at child viewmodels is fundamentally wrong and I should start from scratch, or
I have misunderstood #Binding and #Published attributes, or
Anything really
Like I said initially #Binding does not work in a class you have to use .sink to see the changes to an ObservableObject.
See below...
class MainViewModel: ObservableObject {
#Published var timer = YourTimer()
let store: Store
let nManager: NotificationManager
let wManager: WatchConnectionManager
private let makeNotYetStartedViewModelClosure: (_ parentVM: MainViewModel) -> NotYetStartedViewModel
private let makeStartedViewModelClosure: (_ parentVM: MainViewModel) -> StartedViewModel
init(
store: Store,
nManager: NotificationManager,
wManager: WatchConnectionManager,
makeNotYetStartedViewModel: #escaping (_ patentVM: MainViewModel) -> NotYetStartedViewModel,
makeStartedViewModel: #escaping (_ patentVM: MainViewModel) -> StartedViewModel
) {
self.store = store
self.nManager = nManager
self.wManager = wManager
self.makeNotYetStartedViewModelClosure = makeNotYetStartedViewModel
self.makeStartedViewModelClosure = makeStartedViewModel
}
}
// MARK: - child View Models
extension MainViewModel {
func makeNotYetStartedViewModel() -> NotYetStartedViewModel {
self.makeNotYetStartedViewModelClosure(self)
}
func makeStartedViewModel() -> StartedViewModel {
self.makeStartedViewModelClosure(self)
}
}
class NotYetStartedViewModel: ObservableObject {
var parentVM: MainViewModel
var timer: YourTimer{
get{
parentVM.timer
}
set{
parentVM.timer = newValue
}
}
var cancellable: AnyCancellable? = nil
init(parentVM: MainViewModel) {
self.parentVM = parentVM
//Subscribe to the parent
cancellable = parentVM.objectWillChange.sink(receiveValue: { [self] _ in
//Trigger reload
objectWillChange.send()
})
}
func start() {
// I'll make this into a class later on
timer = YourTimer(remainingSeconds: timer.remainingSeconds, started: true)
}
}
class StartedViewModel: ObservableObject {
var parentVM: MainViewModel
var timer: YourTimer{
get{
parentVM.timer
}
set{
parentVM.timer = newValue
}
}
var cancellable: AnyCancellable? = nil
init(parentVM: MainViewModel) {
self.parentVM = parentVM
cancellable = parentVM.objectWillChange.sink(receiveValue: { [self] _ in
//trigger reload
objectWillChange.send()
})
}
func tick() {
// I'll make this into a class later on
timer = YourTimer(remainingSeconds: timer.remainingSeconds - 1, started: timer.started)
}
func cancel() {
timer = YourTimer()
}
}
But this is an overcomplicated setup, stick to class or struct. Also, maintain a single source of truth. That is basically the center of how SwiftUI works everything should be getting its value from a single source.
try to save user setting, but UserDefaults is not working, Xcode 12.3, swiftui 2.0, when I am reload my app, my setting not updating for new value)
class PrayerTimeViewModel: ObservableObject {
#Published var lm = LocationManager()
#Published var method: CalculationMethod = .dubai {
didSet {
UserDefaults.standard.set(method.params, forKey: "method")
self.getPrayerTime()
}
}
func getPrayerTime() {
let cal = Calendar(identifier: Calendar.Identifier.gregorian)
let date = cal.dateComponents([.year, .month, .day], from: Date())
let coordinates = Coordinates(latitude: lm.location?.latitude ?? 0.0, longitude: lm.location?.longitude ?? 0.0)
var par = method.params
par.madhab = mashab
self.times = PrayerTimes(coordinates: coordinates, date: date, calculationParameters: par)
}
and view.. update with AppStorage
struct MethodView: View {
#ObservedObject var model: PrayerTimeViewModel
#Environment(\.presentationMode) var presentationMode
#AppStorage("method", store: UserDefaults(suiteName: "method")) var method: CalculationMethod = .dubai
var body: some View {
List(CalculationMethod.allCases, id: \.self) { item in
Button(action: {
self.model.objectWillChange.send()
self.presentationMode.wrappedValue.dismiss()
self.model.method = item
method = item
}) {
HStack {
Text("\(item.rawValue)")
if model.method == item {
Image(systemName: "checkmark")
.foregroundColor(.black)
}
}
}
}
}
}
You have two issues.
First, as I mentioned in my comment above that you are using two different suites for UserDefaults. This means that you are storing and retrieving from two different locations. Either use UserDefaults.standard or use the one with your chosen suite UserDefaults(suitName: "method") - you don't have to use a suite unless you plan on sharing your defaults with other extensions then it would be prudent to do so.
Secondly you are storing the wrong item in UserDefaults. You are storing a computed property params rather than the actual enum value. When you try to retrieve the value it fails as it is not getting what it expects and uses the default value that you have set.
Here is a simple example that shows what you could do. There is a simple enum that has a raw value (String) and conforms to Codable, it also has a computed property. This matches your enum.
I have added an initialiser to my ObservableObject. This serves the purpose to populate my published Place from UserDefaults when the Race object is constructed.
Then in my ContentView I update the place depending on a button press. This updates the UI and it updates the value in UserDefaults.
This should be enough for you to understand how it works.
enum Place: String, Codable {
case first
case second
case third
case notPlaced
var someComputedProperty: String {
"Value stored: \(self.rawValue)"
}
}
class Race: ObservableObject {
#Published var place: Place = .notPlaced {
didSet {
// Store the rawValue of the enum into UserDefaults
// We can store the actual enum but that requires more code
UserDefaults.standard.setValue(place.rawValue, forKey: "method")
// Using a custom suite
// UserDefaults(suiteName: "method").setValue(place.rawValue, forKey: "method")
}
}
init() {
// Load the value from UserDefaults if it exists
if let rawValue = UserDefaults.standard.string(forKey: "method") {
// We need to nil-coalesce here as this is a failable initializer
self.place = Place(rawValue: rawValue) ?? .notPlaced
}
// Using a custom suite
// if let rawValue = UserDefaults(suiteName: "method")?.string(forKey: "method") {
// self.place = Place(rawValue: rawValue) ?? .notPlaced
// }
}
}
struct ContentView: View {
#StateObject var race: Race = Race()
var body: some View {
VStack(spacing: 20) {
Text(race.place.someComputedProperty)
.padding(.bottom, 20)
Button("Set First") {
race.place = .first
}
Button("Set Second") {
race.place = .second
}
Button("Set Third") {
race.place = .third
}
}
}
}
Addendum:
Because the enum conforms to Codable it would be possible to use AppStorage to read and write the property. However, that won't update the value in your ObservableObject so they could easily get out of sync. It is best to have one place where you control a value. In this case your ObservableObject should be the source of truth, and all updates (reading and writing to UserDefaults) should take place through there.
You write in one UserDefaults domain but read from the different. Assuming your intention is to use suite only UserDefaults, you should change one in model, like
#Published var method: CalculationMethod = .dubai {
didSet {
UserDefaults(suiteName: "method").set(method.params, forKey: "method")
self.getPrayerTime()
}
}
or if you want to use standard then just use AppStorage with default constructor, like
// use UserDefaults.standard by default
#AppStorage("method") var method: CalculationMethod = .dubai
I was very kindly helped to allow my picker to select a value from my Firestore database. What I would like to do is once that value is selected in my picker I would like to be able to show that value in other views. I have tried setting this up using UserDefaults but I'm not sure that's the way to go? If you could suggest a better method I'd be more than grateful. My code currently is below.
The value in the below code returns Unknown School each time but without the user defaults works flawlessly in fetching the data.
Thanks in advance.
import SwiftUI
import Firebase
struct SchoolDetailsView: View {
#ObservedObject var schoolData = getSchoolData()
#State private var selectedSchool = UserDefaults.standard.string(forKey: "") // `schoolName.id` is of type String
var body: some View {
VStack {
Form {
Section {
Picker(selection: $selectedSchool, label: Text("School Name")) {
ForEach(schoolData.datas, id: \.id) {
Text($0.name)
}
}
Text("Selected School: \(selectedSchool ?? "Unknown School")")
}
}.navigationBarTitle("School Selection")
}
}
struct SchoolPicker_Previews: PreviewProvider {
static var previews: some View {
SchoolDetailsView()
}
}
class getSchoolData : ObservableObject{
#Published var datas = [schoolName]()
init() {
let db = Firestore.firestore()
db.collection("School Name").addSnapshotListener { (snap, err) in
if err != nil{
print((err?.localizedDescription)!)
return
}
for i in snap!.documentChanges{
let id = i.document.documentID
let name = i.document.get("Name") as! String
self.datas.append(schoolName(id: id, name: name))
}
}
}
}
struct schoolName : Identifiable {
var id : String
var name : String
}
}
First, the UserDefaults key for your variable can't be empty:
#State private var selectedSchool: String = UserDefaults.standard.string(forKey: "selectedSchool") ?? "Unknown School"
Then you can use onReceive to update the variable:
.onReceive(Just(selectedSchool)) {
UserDefaults.standard.set($0, forKey: "selectedSchool")
}
Full code:
import Combine
import Firebase
import SwiftUI
struct SchoolDetailsView: View {
#ObservedObject var schoolData = getSchoolData()
#State private var selectedSchool = UserDefaults.standard.string(forKey: "selectedSchool")
var body: some View {
VStack {
Form {
Section {
Picker(selection: $selectedSchool, label: Text("School Name")) {
ForEach(schoolData.datas, id: \.id) {
Text($0.name)
}
}
.onReceive(Just(selectedSchool)) {
UserDefaults.standard.set($0, forKey: "selectedSchool")
}
Text("Selected School: \(selectedSchool)")
}
}.navigationBarTitle("School Selection")
}
}
}
Note that in SwiftUI 2 / iOS 14 you can use #AppStorage instead.
I've set up a mvvm architecture. I've got a model, a bunch of views and for each view one single store. To illustrate my problem, consider the following:
In my model, there exists a user object user and two Views (A and B) with two Stores (Store A, Store B) which both use the user object. View A and View B are not dependent on each other (both have different stores which do not share the user object) but are both able to edit the state of the user object. Obviously, you need to propagate somehow the changes from one store to the other. In order to do so, I've built a hierarchy of stores with one root store who maintains the entire "app state" (all states of shared objects like user). Now, Store A and B only maintain references on root stores objects instead of maintaining objects themselves. I'd expected now, that if I change the object in View A, that Store A would propagate the changes to the root store which would propagate the changes once again to Store B. And when I switch to View B, I should be able now to see my changes. I used Bindings in Store A and B to refer to the root stores objects. But this doesn't work properly and I just don't understand the behavior of Swift's Binding. Here is my concrete set up as a minimalistic version:
public class RootStore: ObservableObject {
#Published var storeA: StoreA?
#Published var storeB: StoreB?
#Published var user: User
init(user: User) {
self.user = user
}
}
extension ObservableObject {
func binding<T>(for keyPath: ReferenceWritableKeyPath<Self, T>) -> Binding<T> {
Binding(get: { [unowned self] in self[keyPath: keyPath] },
set: { [unowned self] in self[keyPath: keyPath] = $0 })
}
}
public class StoreA: ObservableObject {
#Binding var user: User
init(user: Binding<User>) {
_user = user
}
}
public class StoreB: ObservableObject {
#Binding var user: User
init(user: Binding<User>) {
_user = user
}
}
In my SceneDelegate.swift, I've got the following snippet:
user = User()
let rootStore = RootStore(user: user)
let storeA = StoreA(user: rootStore.binding(for: \.user))
let storeB = StoreB(user: rootStore.binding(for: \.user))
rootStore.storeA = storeA
rootStore.storeB = storeB
let contentView = ContentView()
.environmentObject(appState) // this is used for a tabView. You can safely ignore this for this question
.environmentObject(rootStore)
then, the contentView is passed as a rootView to the UIHostingController. Now my ContentView:
struct ContentView: View {
#EnvironmentObject var appState: AppState
#EnvironmentObject var rootStore: RootStore
var body: some View {
TabView(selection: $appState.selectedTab) {
ViewA().environmentObject(rootStore.storeA!).tabItem {
Image(systemName: "location.circle.fill")
Text("ViewA")
}.tag(Tab.viewA)
ViewB().environmentObject(rootStore.storeB!).tabItem {
Image(systemName: "waveform.path.ecg")
Text("ViewB")
}.tag(Tab.viewB)
}
}
}
And now, both Views:
struct ViewA: View {
// The profileStore manages user related data
#EnvironmentObject var storeA: StoreA
var body: some View {
Section(header: HStack {
Text("Personal Information")
Spacer()
Image(systemName: "info.circle")
}) {
TextField("First name", text: $storeA.user.firstname)
}
}
}
struct ViewB: View {
#EnvironmentObject var storeB: StoreB
var body: some View {
Text("\(storeB.user.firstname)")
}
}
Finally, my issue is, that changes are just not reflected as they are supposed to be. When I change something in ViewA and switch to ViewB, I don't see the updated first name of the user. When I change back to ViewA my change is also lost. I used didSet inside the stores and similar for debugging purposes and the Binding actually seems to work. The change is propagated but somehow the View just doesn't update. I also forced with some artificial state changing (adding a state bool variable and just toggling it in an onAppear()) that the view rerenders but still, it doesn't take the updated value and I just don't know what to do.
EDIT: Here is a minimal version of my User object
public struct User {
public var id: UUID?
public var firstname: String
public var birthday: Date
public init(id: UUID? = nil,
firstname: String,
birthday: Date? = nil) {
self.id = id
self.firstname = firstname
self.birthday = birthday ?? Date()
}
}
For simplicity, I didn't pass the attributes in the SceneDelegate.swift snippet above.
In your scenario it is more appropriate to have User as-a ObservableObject and pass it by reference between stores, as well as use in corresponding views explicitly as ObservedObject.
Here is simplified demo combined from your code snapshot and applied the idea.
Tested with Xcode 11.4 / iOS 13.4
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let user = User(id: UUID(), firstname: "John")
let rootStore = RootStore(user: user)
let storeA = StoreA(user: user)
let storeB = StoreB(user: user)
rootStore.storeA = storeA
rootStore.storeB = storeB
return ContentView().environmentObject(rootStore)
}
}
public class User: ObservableObject {
public var id: UUID?
#Published public var firstname: String
#Published public var birthday: Date
public init(id: UUID? = nil,
firstname: String,
birthday: Date? = nil) {
self.id = id
self.firstname = firstname
self.birthday = birthday ?? Date()
}
}
public class RootStore: ObservableObject {
#Published var storeA: StoreA?
#Published var storeB: StoreB?
#Published var user: User
init(user: User) {
self.user = user
}
}
public class StoreA: ObservableObject {
#Published var user: User
init(user: User) {
self.user = user
}
}
public class StoreB: ObservableObject {
#Published var user: User
init(user: User) {
self.user = user
}
}
struct ContentView: View {
#EnvironmentObject var rootStore: RootStore
var body: some View {
TabView {
ViewA(user: rootStore.user).environmentObject(rootStore.storeA!).tabItem {
Image(systemName: "location.circle.fill")
Text("ViewA")
}.tag(1)
ViewB(user: rootStore.user).environmentObject(rootStore.storeB!).tabItem {
Image(systemName: "waveform.path.ecg")
Text("ViewB")
}.tag(2)
}
}
}
struct ViewA: View {
#EnvironmentObject var storeA: StoreA // keep only if it is needed in real view
#ObservedObject var user: User
init(user: User) {
self.user = user
}
var body: some View {
VStack {
HStack {
Text("Personal Information")
Image(systemName: "info.circle")
}
TextField("First name", text: $user.firstname)
}
}
}
struct ViewB: View {
#EnvironmentObject var storeB: StoreB
#ObservedObject var user: User
init(user: User) {
self.user = user
}
var body: some View {
Text("\(user.firstname)")
}
}
Providing an alternative answer here with some changes to your design as a comparison.
The shared state here is the user object. Put it in #EnvironmentObject, which is by definition the external state object shared by views in the hierarchy. This way you don't need to notify StoreA which notifies RootStore which then notifies StoreB.
Then StoreA, StoreB can be local #State, and RootStore is not required. Store A, B can be value types since there's nothing to observe.
Since #EnvironmentObject is by definition an ObservableObject, we don't need User to
conform to ObservableObject, and can thus make User a value type.
final class EOState: ObservableObject {
#Published var user = User()
}
struct ViewA: View {
#EnvironmentObject eos: EOState
#State storeA = StoreA()
// ... TextField("First name", text: $eos.user.firstname)
}
struct ViewB: View {
#EnvironmentObject eos: EOState
#State storeB = StoreB()
// ... Text("\(eos.user.firstname)")
}
The rest should be straight-forward.
What is the take-away in this comparison?
Should avoid objects observing each other, or a long publish chain. It's confusing, hard to track, and not scalable.
MVVM tells you nothing about managing state. SwiftUI is most powerful when you've learnt how to allocate and manage your states. MVVM however heavily relies upon #ObservedObject for binding, because iOS had no binding. For beginners this is dangerous, because it needs to be reference type. The result might be, as in this case, overuse of reference types which defeats the whole purpose of a SDK built around value types.
It also removes most of the boilerplate init codes, and one can focus on 1 shared state object instead of 4.
If you think SwiftUI creators are idiots, SwiftUI is not scalable and requires MVVM on top of it, IMO you are sadly mistaken.
struct ContentView: View {
#State var settingsConfiguration: Settings
struct Settings {
var passwordLength: Double = 20
var moreSpecialCharacters: Bool = false
var specialCharacters: Bool = false
var lowercaseLetters: Bool = true
var uppercaseLetters: Bool = true
var numbers: Bool = true
var space: Bool = false
}
var body: some View {
VStack {
HStack {
Text("Password Length: \(Int(settingsConfiguration.passwordLength))")
Spacer()
Slider(value: $settingsConfiguration.passwordLength, from: 1, through: 512)
}
Toggle(isOn: $settingsConfiguration.moreSpecialCharacters) {
Text("More Special Characters")
}
Toggle(isOn: $settingsConfiguration.specialCharacters) {
Text("Special Characters")
}
Toggle(isOn: $settingsConfiguration.space) {
Text("Spaces")
}
Toggle(isOn: $settingsConfiguration.lowercaseLetters) {
Text("Lowercase Letters")
}
Toggle(isOn: $settingsConfiguration.uppercaseLetters) {
Text("Uppercase Letters")
}
Toggle(isOn: $settingsConfiguration.numbers) {
Text("Numbers")
}
Spacer()
}
.padding(.all)
.frame(width: 500, height: 500)
}
}
So I have all this code here and I want to use UserDefaults to save settings whenever a switch is changed or a slider is slid and to retrieve all this data when the app launches but I have no idea how I would go about using UserDefaults with SwiftUI (Or UserDefaults in general, I've just started looking into it so I could use it for my SwiftUI app but all the examples I see are for UIKit and when I try implementing them in SwiftUI I just run into a ton of errors).
The approach from caram is in general ok but there are so many problems with the code that SmushyTaco did not get it work. Below you will find an "Out of the Box" working solution.
1. UserDefaults propertyWrapper
import Foundation
import Combine
#propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
init(_ key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T {
get {
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
2. UserSettings class
final class UserSettings: ObservableObject {
let objectWillChange = PassthroughSubject<Void, Never>()
#UserDefault("ShowOnStart", defaultValue: true)
var showOnStart: Bool {
willSet {
objectWillChange.send()
}
}
}
3. SwiftUI view
struct ContentView: View {
#ObservedObject var settings = UserSettings()
var body: some View {
VStack {
Toggle(isOn: $settings.showOnStart) {
Text("Show welcome text")
}
if settings.showOnStart{
Text("Welcome")
}
}
}
Starting from Xcode 12.0 (iOS 14.0) you can use #AppStorage property wrapper for such types: Bool, Int, Double, String, URL and Data.
Here is example of usage for storing String value:
struct ContentView: View {
static let userNameKey = "user_name"
#AppStorage(Self.userNameKey) var userName: String = "Unnamed"
var body: some View {
VStack {
Text(userName)
Button("Change automatically ") {
userName = "Ivor"
}
Button("Change manually") {
UserDefaults.standard.setValue("John", forKey: Self.userNameKey)
}
}
}
}
Here you are declaring userName property with default value which isn't going to the UserDefaults itself. When you first mutate it, application will write that value into the UserDefaults and automatically update the view with the new value.
Also there is possibility to set custom UserDefaults provider if needed via store parameter like this:
#AppStorage(Self.userNameKey, store: UserDefaults.shared) var userName: String = "Mike"
and
extension UserDefaults {
static var shared: UserDefaults {
let combined = UserDefaults.standard
combined.addSuite(named: "group.myapp.app")
return combined
}
}
Notice: ff that value will change outside of the Application (let's say manually opening the plist file and changing value), View will not receive that update.
P.S. Also there is new Extension on View which adds func defaultAppStorage(_ store: UserDefaults) -> some View which allows to change the storage used for the View. This can be helpful if there are a lot of #AppStorage properties and setting custom storage to each of them is cumbersome to do.
The code below adapts Mohammad Azam's excellent solution in this video:
import SwiftUI
struct ContentView: View {
#ObservedObject var userDefaultsManager = UserDefaultsManager()
var body: some View {
VStack {
Toggle(isOn: self.$userDefaultsManager.firstToggle) {
Text("First Toggle")
}
Toggle(isOn: self.$userDefaultsManager.secondToggle) {
Text("Second Toggle")
}
}
}
}
class UserDefaultsManager: ObservableObject {
#Published var firstToggle: Bool = UserDefaults.standard.bool(forKey: "firstToggle") {
didSet { UserDefaults.standard.set(self.firstToggle, forKey: "firstToggle") }
}
#Published var secondToggle: Bool = UserDefaults.standard.bool(forKey: "secondToggle") {
didSet { UserDefaults.standard.set(self.secondToggle, forKey: "secondToggle") }
}
}
First, create a property wrapper that will allow us to easily make the link between your Settings class and UserDefaults:
import Foundation
#propertyWrapper
struct UserDefault<Value: Codable> {
let key: String
let defaultValue: Value
var value: Value {
get {
let data = UserDefaults.standard.data(forKey: key)
let value = data.flatMap { try? JSONDecoder().decode(Value.self, from: $0) }
return value ?? defaultValue
}
set {
let data = try? JSONEncoder().encode(newValue)
UserDefaults.standard.set(data, forKey: key)
}
}
}
Then, create a data store that holds your settings:
import Combine
import SwiftUI
final class DataStore: BindableObject {
let didChange = PassthroughSubject<DataStore, Never>()
#UserDefault(key: "Settings", defaultValue: [])
var settings: [Settings] {
didSet {
didChange.send(self)
}
}
}
Now, in your view, access your settings:
import SwiftUI
struct SettingsView : View {
#EnvironmentObject var dataStore: DataStore
var body: some View {
Toggle(isOn: $settings.space) {
Text("\(settings.space)")
}
}
}
If you are persisting a one-off struct such that a property wrapper is overkill, you can encode it as JSON. When decoding, use an empty Data instance for the no-data case.
final class UserData: ObservableObject {
#Published var profile: Profile? = try? JSONDecoder().decode(Profile.self, from: UserDefaults.standard.data(forKey: "profile") ?? Data()) {
didSet { UserDefaults.standard.set(try? JSONEncoder().encode(profile), forKey: "profile") }
}
}
I'm supriced no one wrote the new way, anyway, Apple migrated to this method now and you don't need all the old code, you can read and write to it like this:
#AppStorage("example") var example: Bool = true
that's the equivalent to read/write in the old UserDefaults. You can use it as a regular variable.
Another great solution is to use the unofficial static subscript API of #propertyWrapper instead of the wrappedValue which simplifies a lot the code. Here is the definition:
#propertyWrapper
struct UserDefault<Value> {
let key: String
let defaultValue: Value
init(wrappedValue: Value, _ key: String) {
self.key = key
self.defaultValue = wrappedValue
}
var wrappedValue: Value {
get { fatalError("Called wrappedValue getter") }
set { fatalError("Called wrappedValue setter") }
}
static subscript(
_enclosingInstance instance: Preferences,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<Preferences, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<Preferences, Self>
) -> Value {
get {
let wrapper = instance[keyPath: storageKeyPath]
return instance.userDefaults.value(forKey: wrapper.key) as? Value ?? wrapper.defaultValue
}
set {
instance.objectWillChange.send()
let key = instance[keyPath: storageKeyPath].key
instance.userDefaults.set(newValue, forKey: key)
}
}
}
Then you can define your settings object like this:
final class Settings: ObservableObject {
let userDefaults: UserDefaults
init(defaults: UserDefaults = .standard) {
userDefaults = defaults
}
#UserDefaults("yourKey") var yourSetting: SettingType
...
}
However, be careful with this kind of implementation. Users tend to put all their app settings in one of such object and use it in every view that depends on one setting. This can result in slow down caused by too many unnecessary objectWillChange notifications in many view.
You should definitely separate concerns by breaking down your settings in many small classes.
The #AppStorage is a great native solution but the drawback is that is kind of break the unique source of truth paradigm as you must provide a default value for every property.