Userdefaults with published enum - swift

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

Related

SwiftUI Combine Data Flow

Been away from the swift-ing for a good 3 years now.
Getting back into it now and trying to learn Combine and SwiftUI.
Making a test Workout app.
Add an exercise, record reps and weights for 3 sets.
Save data.
I'm having issues moving some data around from views to data store.
I think I'm confusing all the different property wrappers.
Summary at the bottom after code.
App:
#main
struct TestApp: App {
#StateObject private var store = ExerciseStore()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
}
}
}
Views:
struct ContentView: View {
#EnvironmentObject var store: ExerciseStore
var body: some View {
List {
ForEach($store.completedExercises) { $exercise in
ExerciseView(exercise: $exercise)
}
}
}
}
struct ExerciseView: View {
#Binding var exercise: CompletedExercise
var body: some View {
VStack {
Text(exercise.exercise.name)
SetView(set: $exercise.sets[0])
SetView(set: $exercise.sets[1])
SetView(set: $exercise.sets[2])
}
}
}
struct SetView: View {
#Binding var set: ExerciseSet
var body: some View {
HStack {
TextField(
"Reps",
value: $set.reps,
formatter: NumberFormatter()
)
TextField(
"Weight",
value: $set.weight,
formatter: NumberFormatter()
)
}
}
}
Store:
class ExerciseStore: ObservableObject {
#Published var completedExercises: [CompletedExercise] = [CompletedExercise(Exercise())]
init() {
if let data = UserDefaults.standard.data(forKey: "CompletedExercise") {
if let decoded = try? JSONDecoder().decode([CompletedExercise].self, from: data) {
completedExercises = decoded
return
}
}
}
func save() {
if let encoded = try? JSONEncoder().encode(completedExercises) {
UserDefaults.standard.set(encoded, forKey: "CompletedExercise")
}
}
}
Models:
class CompletedExercise: Codable, Identifiable, ObservableObject {
var id = UUID().uuidString
var exercise: Exercise
#Published var sets = [
ExerciseSet(),
ExerciseSet(),
ExerciseSet()
]
init(exercise: Exercise) {
self.exercise = exercise
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
exercise = try container.decode(Exercise.self, forKey: .exercise)
sets = try container.decode([ExerciseSet].self, forKey: .sets)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(exercise, forKey: .exercise)
try container.encode(sets, forKey: .sets)
}
}
private enum CodingKeys: CodingKey {
case id, exercise, sets
}
struct Exercise: Codable, Identifiable {
var id = -1
var name = "Bench Press"
}
class ExerciseSet: Codable, ObservableObject {
#Published var reps: Int?
#Published var weight: Int?
init() {}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
reps = try container.decodeIfPresent(Int.self, forKey: .reps)
weight = try container.decodeIfPresent(Int.self, forKey: .weight)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(reps, forKey: .reps)
try container.encode(weight, forKey: .weight)
}
}
private enum CodingKeys: CodingKey {
case reps, weight
}
Thats more or less the current code.
I've added a bunch of print statements in the save function in ExerciseStore to see what gets saved.
No matter what I've tried, I can't get the reps/weight via the SetView text fields to persist through the ExerciseStore and get saved.
I've played around with #Binding and such as well but can't get it working.
What am I missing/messing up with the new SwiftUI data flows.
You're code is great, but the values in your ExerciseSet & CompletedExercise aren't marked with #Published.
In order to access the ObservableObject capabilities you need to publish those values enabling your Views to listen, and your class to bind to the changes made.
Also substitute ObservedObject with StateObject, same case for EnvironmentObject.
There are a couple of things I note about your code. First you declare the store as an #EnvironmentObject but you don't show the code where you set up the EnvironmentKey and EnvironmentValues to use it. It may be that you chose to leave that code out, but if you don't have it, it will be worth looking at. See the docs for EnvironmentKey, I think it explains both. https://developer.apple.com/documentation/swiftui/environmentkey.
#EnvironmentObject just declares that you want to take a property from the environment and observe it. You have to put the property in the environment (usually from a top-level view) using the environmentObject View modifier (https://developer.apple.com/documentation/swiftui/image/environmentobject(_:)/). You don't show that code either. You may have put it on your app (or whichever view instantiates ContentView).
Secondly you have #ObservableObjects but no #Published properties on those objects. #Published is how Combine knows which properties you want notifications about.
Thirdly you use #ObservedObject in a lot of your views. #ObservedObject declares that "someone else is going to own an Observable object, and they are going to give it to this view at runtime and this view is going to watch it". At some point, however, someone needs to own the object. It will be the "source of truth" that gets shared using #ObservedObject. The view that wants to own the object should declare that ownership using #StateObject. It can then pass the observed object to children and the children will use #ObservedObject to watch the object owned by the parent. If you have something that's not a observable object you want a view to own, you declare ownership using the #State modifier. Then if you want to share that state to children and let them modify it, you will use a Binding.
It's best to just have one class which is the store and make all the other model types as structs. That way things will update properly. You can flatten the model by using the IDs to cross reference, e.g.
class ExerciseStore: ObservableObject {
#Published var exercises = [Excercise]
#Published var sets
#Published var completedIDs
You can see an example of this in the Fruta sample project where there is a favourite smoothies array that is IDs and not duplicates of the smoothie structs.

Convert computed property for #State for Swiftui Views

I'm new with SwiftUI and i want to convert basically my computed property for being used in SwiftUI views with combine and all that. I couldn’t use it like that because "get set" doesn't work for my SwiftUI views and I kinda struggled here.
Maybe someone has a good solution how can i convert it with with combine to use in swift ui.
Storage service saves the Authdata into userdefaults.
var currentAuthData: AuthData? {
get {
return self.storageService.get(AuthData.self, forKey: authDataStorageKey)
}
set {
if let value = newValue {
self.storageService.store(value, forKey: authDataStorageKey)
}
}
}
This is how you would turn a computed property in SwiftUI, by making it a #propertyWrapper. I have added a solution to read the data using Combine if you need it. I also make you property optional.
I assume this is the model you want to save.
struct AuthData {
var name: String
var email: String
}
Prepare the protocol for your property wrapper to be able to be set to nil using Optional.
public protocol AnyOptional {
var isNil: Bool { get }
}
extension Optional: AnyOptional {
public var isNil: Bool { self == nil }
}
Extend UserDefaults to conform to this protocol and be optional.
extension UserDefault where Value: ExpressibleByNilLiteral {
init(key: String, _ container: UserDefaults = .standard) {
self.init(key: key, defaultValue: nil, container: container)
}
}
Create the property wrapper that is the same thing as a getter and setter for SwiftUI views. This is a generic one and can be use to any type. You can set them in the UserDefaults extension after this bloc of code.
import Combine
#propertyWrapper
struct UserDefault<Value> {
let key: String
let defaultValue: Value
var container: UserDefaults = .standard
// Set a Combine publisher for your new value to
// always read its changes when using Combine.
private let publisher = PassthroughSubject<Value, Never>()
var wrappedValue: Value {
get {
// Get the new value or nil if any.
container.object(forKey: key) as? Value ?? defaultValue
}
set {
// Check if the value is nil and remove it from your object.
if let optional = newValue as? AnyOptional, optional.isNil {
container.removeObject(forKey: key)
}
else {
// Set your new value inside UserDefaults.
container.set(newValue, forKey: key)
}
// Add the newValue to your combine publisher
publisher.send(newValue)
}
}
var projectedValue: AnyPublisher<Value, Never> {
publisher.eraseToAnyPublisher()
}
}
Create the extension in UserDefaults to use your property wrapper in your code.
extension UserDefaults {
#UserDefault(key: "authDataStorageKey", defaultValue: nil)
static var savedAuthData: AuthData?
// You can create as many #propertyWrapper as you want that fits your need
}
examples of use
// Set a new value
UserDefaults.savedAuthData = AuthData(name: "Muli", email: "muli#stackoverflow.com")
// Read the saved value
print(UserDefaults.savedAuthData as Any)
// When using combine
var subscriptions = Set<AnyCancellable>()
UserDefaults.$savedAuthData
.sink { savedValue in
print(savedValue as Any) } // Yours saved value that changes over time.
.store(in: &subscriptions)

Formatting text based on UserDefaults

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?

didSet not called by Array.append() in Swift

I am following the 100 Days of SwiftUI and have reached Day 37. While doing Making changes permanent with UserDefaults, I encounter a problem with didSet.
(I am using Swift 5 with iOS 13.4)
In the example code, it writes
.navigationBarItems(trailing: Button("Save") {
if let actualAmount = Int(self.amount) {
let item = ExpenseItem(name: self.name, type: self.type, amount: actualAmount)
self.expenses.items.append(item)
}
})
where didSet should be called by .append().
However, in practice, the didSet is not called unless I change the above code to
.navigationBarItems(trailing: Button("Save") {
if let actualAmount = Int(self.amount) {
let item = ExpenseItem(name: self.name, type: self.type, amount: actualAmount)
let newItems = self.expenses.items + [item]
self.expenses.items = newItems
}
})
I also write a small test (see below) in Playground which shows that .append() works pretty well with didSet
struct Count {
var array: [Int] {
didSet {
print("struct Count - didSet() called")
}
}
}
class CountClass {
var array: [Int] {
didSet {
print("class CountClass - didSet() called")
}
}
init() {
array = [1, 2, 3]
}
}
struct Test {
var countA = Count(array: [1, 2, 3])
var countB = CountClass()
mutating func testDidSet() {
countA.array.append(4)
countB.array.append(4)
}
}
var t = Test()
t.testDidSet()
This strange behaviour really makes me wonder how didSet works. Or is this problem related to the use of #ObservedObject (which is the case of the example project)?
PS: I have downloaded the finished version from Project7 and it also has the problem.
This is a known Swift 5.2 bug: observers of wrapped properties are not called upon modification (https://bugs.swift.org/browse/SR-12089). They had known about this since January and all the same released an update breaking a bunch of production code ¯\_(ツ)_/¯
A temporary workaround is already presented in the question - property reassignment instead of modification.
I went through this project as well. Make sure your items property in the Expenses class is marked as #Published. As below;
import SwiftUI
struct ExpenseItem: Identifiable, Codable {
let id = UUID()
let name: String
let type: String
let amount: Int
}
class Expenses: ObservableObject {
init() {
if let items = UserDefaults.standard.data(forKey: "Items") {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode([ExpenseItem].self, from: items) {
self.items = decoded
return
}
}
self.items = []
}
#Published var items: [ExpenseItem] {
didSet {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(items) {
UserDefaults.standard.set(encoded, forKey: "Items")
}
}
}
}
and add self.presentationMode.wrappedValue.dismiss() in your navigation bar Save button.
.navigationBarItems(trailing: Button("Save") {
if let actualAmount = Int(self.amount) {
let item = ExpenseItem(name: self.name, type: self.type, amount: actualAmount)
self.expenses.items.append(item)
self.presentationMode.wrappedValue.dismiss()
} else {
self.isShowingAlert = true
}
})
I finished this project last week I think, but checking the Swift GitHub page it seems there was an update on the 24th.
When I did this project everything worked fine, but now I'm on day 47 and I'm having this very problem. Maybe it's related to the Swift 5.2 update.
What's happening is that Swift is not recognizing the .append() as setting the variable. You can get around this by copying your array to a new temporary array, append the new value and then set your class array to this temporary array.
var newActivityList = [Activity]() //new temporary array
for activity in self.activityList.activities {
newActivityList.append(activity) //copy class array to temp array
}
newActivityList.append(newActivity) //append new value
self.activityList.activities = newActivityList //set class array to temp array
PS: the example above is for day 47, but the logic is the same.

How do I use UserDefaults with SwiftUI?

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.