Efficiently refactoring piece of Swift code to be less redundant - swift

In the code below, A key is remapped to B key, and vice versa. The remapping is activated via a SwiftUI toggle switch.
In example presented here the same block of code is used in three different functions.
Additionally, the loop that iterates through the function call is also used in all three of these functions.
I've been struggling to simplify this code and make it less redundant for more than a day. Any help would be greatly appreciated.
let aKey: UInt64 = 0x700000004
let bKey: UInt64 = 0x700000005
func isKeyboardServiceClientForUsagePage(_ serviceClient: IOHIDServiceClient, _ usagePage: UInt32, _ usage: UInt32) -> Bool {
return IOHIDServiceClientConformsTo(serviceClient, usagePage, usage) == 1
}
func updateKeyboardKeyMapping(_ keyMap: [[String: UInt64]]) {
let eventSystemClient = IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault)
guard let serviceClients = IOHIDEventSystemClientCopyServices(eventSystemClient) as? [IOHIDServiceClient] else {
return
}
for serviceClient in serviceClients {
let usagePage = UInt32(kHIDPage_GenericDesktop)
let usage = UInt32(kHIDUsage_GD_Keyboard)
if isKeyboardServiceClientForUsagePage(serviceClient, usagePage, usage) {
IOHIDServiceClientSetProperty(serviceClient, kIOHIDUserKeyUsageMapKey as CFString, keyMap as CFArray)
}
}
}
func areKeysMappedOnAnyServiceClient() -> Bool {
let eventSystemClient = IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault)
guard let serviceClients = IOHIDEventSystemClientCopyServices(eventSystemClient) as? [IOHIDServiceClient] else {
return false
}
for serviceClient in serviceClients {
let usagePage = UInt32(kHIDPage_GenericDesktop)
let usage = UInt32(kHIDUsage_GD_Keyboard)
if isKeyboardServiceClientForUsagePage(serviceClient, usagePage, usage) {
guard let keyMapping = IOHIDServiceClientCopyProperty(serviceClient, kIOHIDUserKeyUsageMapKey as CFString) as? [[String: UInt64]] else {
return false
}
if keyMapping.contains(where: { $0[kIOHIDKeyboardModifierMappingSrcKey] == aKey && $0[kIOHIDKeyboardModifierMappingDstKey] == bKey }) &&
keyMapping.contains(where: { $0[kIOHIDKeyboardModifierMappingSrcKey] == bKey && $0[kIOHIDKeyboardModifierMappingDstKey] == aKey })
{
return true
}
}
}
return false
}
func remapABBA() {
let keyMap: [[String: UInt64]] = [
[
kIOHIDKeyboardModifierMappingSrcKey: aKey,
kIOHIDKeyboardModifierMappingDstKey: bKey,
],
[
kIOHIDKeyboardModifierMappingSrcKey: bKey,
kIOHIDKeyboardModifierMappingDstKey: aKey,
],
]
updateKeyboardKeyMapping(keyMap)
}
func resetKeyMapping() {
updateKeyboardKeyMapping([])
}
And here’s the SwiftUI part if you would like to try the app:
import SwiftUI
struct ContentView: View {
#State private var remapKeys = areKeysMappedOnAnyServiceClient()
var body: some View {
HStack {
Spacer()
Toggle(isOn: $remapKeys, label: { Text("Remap A → B and B → A.") })
.toggleStyle(SwitchToggleStyle())
.onChange(of: remapKeys, perform: toggleKeyboardRemapping)
Spacer()
}
}
}
private func toggleKeyboardRemapping(_ remapKeys: Bool) {
if remapKeys {
remapABBA()
} else {
resetKeyMapping()
}
}

OK... this is going to take some time to answer.
It seems like you're lacking in a place to store things. That's why you have to use the same block of code over and over. We can solve that with a view model...
In here I'm going to hide away the logic of what is happening from the view and only expose what the view needs access to in order to display itself.
// we make it observable so the view can subscribe to it.
class KeyMappingViewModel: ObservableObject {
private let aKey: UInt64 = 0x700000004
private let bKey: UInt64 = 0x700000005
private let srcKey = kIOHIDKeyboardModifierMappingSrcKey
private let dstKey = kIOHIDKeyboardModifierMappingDstKey
private var keyMap: [[String: UInt64]] {
[
[
srcKey: aKey,
dstKey: bKey,
],
[
srcKey: bKey,
dstKey: aKey,
],
]
}
// A more concise way to get hold of the client ref
private var client: IOHIDEventSystemClientRef {
IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault)
}
// Making this published means the view can use it as state in the Toggle
#Published var toggleState: Bool {
didSet {
if toggleState {
client.updateKeyMapping(keyMap)
} else {
client.updateKeyMapping([])
}
}
}
init() {
// set the initial value by asking the client if it has any keys mapped
toggleState = client.areKeysMappedOnAnyServiceClient(aKey: aKey, bKey: bKey)
}
}
I'm going to make extensions of IOHIDServiceClient and IOHIDEventSystemClientRef to encapsulate your logic...
extension IOHIDEventSystemClientRef {
private var srcKey: String { kIOHIDKeyboardModifierMappingSrcKey }
private var dstKey: String { kIOHIDKeyboardModifierMappingDstKey }
// Make this an optional var on the client ref itself.
private var serviceClients: [IOHIDServiceClient]? {
IOHIDEventSystemClientCopyServices(self) as? [IOHIDServiceClient]
}
func areKeysMappedOnAnyServiceClient(aKey: UInt64, bKey: UInt64) -> Bool {
// Nice Swift 5.7 syntax with the optional var
guard let serviceClients else {
return false
}
// I made this more concise with a filter and map.
// Also, using the extension we can make use of keyPaths to get the values.
return serviceClients.filter(\.isForGDKeyboard)
.compactMap(\.keyMapping)
.map { keyMapping in
keyMapping.contains(where: { $0[srcKey] == aKey && $0[dstKey] == bKey }) &&
keyMapping.contains(where: { $0[srcKey] == bKey && $0[dstKey] == aKey })
}
.contains(true)
}
func updateKeyMapping(_ keyMap: [[String: UInt64]]) {
// serviceClients is optional so we can just ? it.
// if it's nil, nothing after the ? happens.
serviceClients?.filter(\.isForGDKeyboard)
.forEach {
IOHIDServiceClientSetProperty($0, kIOHIDUserKeyUsageMapKey as CFString, keyMap as CFArray)
}
}
}
extension IOHIDServiceClient {
var isForGDKeyboard: Bool {
let usagePage = UInt32(kHIDPage_GenericDesktop)
let usage = UInt32(kHIDUsage_GD_Keyboard)
return IOHIDServiceClientConformsTo(self, usagePage, usage) == 1
}
var keyMapping: [[String: UInt64]]? {
IOHIDServiceClientCopyProperty(self, kIOHIDUserKeyUsageMapKey as CFString) as? [[String: UInt64]]
}
}
Doing all of this means that your view can look something like this...
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel: KeyMappingViewModel = .init()
var body: some View {
HStack {
Spacer()
Toggle(isOn: $viewModel.toggleState) {
Text("Remap A → B and B → A.")
}
.toggleStyle(SwitchToggleStyle())
Spacer()
}
}
}
This contains all your same logic and TBH wasn't too bad already.
My main changes were to take the free functions and vars and add them to their respective types.
So, the update and areKeysMapped... functions now belong to the IOHIDEventSystemClientRef type.
The isForGDKeyboard and keyMapping vars now belong to the IOHIDServiceClient type.
Doing this removed a lot of the repeated code you had as you no longer had to continuously call free functions. It also meant we unlocked some very Swifty keyPath usage which helped make some of the logic more concise.
Then we made a view model. This allowed us to keep all the moving parts of the view in one place. It had a place to easily get hold of the client. It also meant we could hide a lot of the stuff inside the view model by making it private.
This meant that the view only had one thing it could do. Which is to use the binding to the toggleState. Everything else was behind closed doors to the view.

Related

How to reduce the memory footprint of a large list of images in SwiftUI

I'm playing with SwiftUI and I'm currently struggling with images. Basically, I want to display an list of images with an infinite scroll but I want to keep the memory usage reasonable.
I have the following (truncated) code:
struct HomeView: View {
#State private var wallpapers: Loadable<[Wallpaper]> = .notRequested
#State private var currentPage = 1
#Environment(\.container) private var container
var body: some View {
content
.onAppear { loadWallpapers() }
}
private var content: some View {
VStack {
wallpapersList(data: wallpapers.value ?? [])
// ...
}
}
private func wallpapersList(data: [Wallpaper]) -> some View {
ScrollView {
LazyVStack(spacing: 5) {
ForEach(data) { w in
networkImage(url: w.thumbs.original)
.onAppear { loadNextPage(current: w.id) }
}
}
}
}
private func networkImage(url: String) -> some View {
// I use https://github.com/onevcat/Kingfisher to handle image loading
KFImage(URL(string: url))
// ...
}
private func loadWallpapers() {
container.interactors.wallpapers.load(data: $wallpapers, page: currentPage)
}
private func loadNextPage(current: String) {
// ...
}
}
struct WallpapersInteractor: PWallpapersInteractor {
let state: Store<AppState>
let agent: PWallpapersAgent
func load(data: LoadableSubject<[Wallpaper]>, page: Int) {
let store = CancelBag()
data.wrappedValue.setLoading(store: store)
Just.withErrorType((), Error.self)
.flatMap { _ in
agent.loadWallpapers(page: page) // network call here
}
.map { response in
response.data
}
.sink { subCompletion in
if case let .failure(error) = subCompletion {
data.wrappedValue.setFailed(error: error)
}
} receiveValue: {
if var currentWallpapers = data.wrappedValue.value {
currentWallpapers.append(contentsOf: $0) // /!\
data.wrappedValue.setLoaded(value: currentWallpapers)
} else {
data.wrappedValue.setLoaded(value: $0)
}
}
.store(in: store)
}
}
Because I append the new data to my Binding every time I request a new batch of images, the memory consumption quickly becomes stupidly high.
I tried to remove data from the array using .removeFirst(pageSize) once I get to the third page so that my array contains at most 2 * pageSize elements (pageSize being 64 in this case). But doing so makes my list all jumpy because the content goes up, which creates more problems than it solves.
I tried searching for a solution but I surprisingly didn't find anything on this particular topic, am I missing something obvious ?

SwiftUI - Is it possible to change an ActionSheet button text after it is displayed?

I would like to show an ActionSheet containing InApp purchase objects the user can purchase.
But I want that sheet to contain the prices of such objects, like:
Object 1 ($1.99)
Object 2 ($2.99)
...
but the price is asynchronous, cause it has to be retrieved from the store.
So, I thought about doing this:
struct Package {
enum Packtype:String {
typealias RawValue = String
case obj1 = "com.example.object1"
case obj2 = "com.example.object2"
}
var productID:String = ""
#State var namePriceString:String = ""
init(productID:String) {
self.productID = productID
}
}
then, when I create the action sheet button I do this:
var obj1 = Package(productID: Package.Packtype.obj1.rawValue)
var obj2 = Package(productID: Package.Packtype.obj2.rawValue)
self.getPrices(packages:[obj1, obj2])
let obj1Button = ActionSheet.Button.default(Text(obj1.$namePriceString)) {
// do something with obj1
}
let obj2Button = ActionSheet.Button.default(Text(obj2.$namePriceString)) {
// do something with obj1
}
// build the actionsheet
later in the code:
func getPrices(packages:[Package]) {
let productIDS = Set(packages.map {$0.productID})
SwiftyStoreKit.retrieveProductsInfo(productIDS) { (answer) in
if answer.invalidProductIDs.first != nil { return }
let results = answer.retrievedProducts
if results.count == 0 { return }
for result in answer {
if let package = packages.filter({ ($0.productID == result.productIdentifier) }).first {
package.namePriceString = result.localizedTitle + "(" + "\(result.localizedPrice!)" + ")"
}
}
}
}
I have an error pointing to Text on the button creation lines saying
Initializer 'init(_:)' requires that 'Binding' conform to
'StringProtocol'
In a nutshell I need this:
I display the actionsheet. Its buttons contain no price.
I retrieve the prices
Actionsheet buttons are updated with the prices.
A possible solution is to return prices in a completion handler and only then display the action sheet:
struct ContentView: View {
#State var showActionSheet = false
#State var localizedPrices = [Package: String]()
var body: some View {
Button("Get prices") {
getPrices(packages: Package.allCases, completion: {
localizedPrices = $0
showActionSheet = true
})
}
.actionSheet(isPresented: $showActionSheet) {
let buttons = localizedPrices.map { package, localizedPrice in
ActionSheet.Button.default(Text(localizedPrice), action: { buy(package: package) })
}
return ActionSheet(title: Text("Title"), message: Text("Message"), buttons: buttons + [.cancel()])
}
}
}
func getPrices(packages: [Package], completion: #escaping ([Package: String]) -> Void) {
// simulates an asynchronous task, should be replaced with the actual implementation
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
let localizedPrices = Dictionary(uniqueKeysWithValues: packages.map { ($0, "\(Int.random(in: 1 ..< 100))") })
completion(localizedPrices)
}
}
func buy(package: Package) {
print("Buying \(package.rawValue)")
}
enum Package: String, CaseIterable {
case obj1 = "com.example.object1"
case obj2 = "com.example.object2"
}
This can be further tuned with loading animations etc...

Using Combine to parse phone number String

I'm trying to wrap my mind around how Combine works. I believe I'm doing something wrong when I use the .assign operator to mutate the #Published property I'm operating on. I've read the documentation on Publishers, Subscribers, and Operators. But I'm a bit loose on where exactly to create the Publisher if I don't want it to be a function call.
import SwiftUI
import Combine
struct PhoneNumberField: View {
let title: String
#ObservedObject var viewModel = ViewModel()
var body: some View {
TextField(title,text: $viewModel.text)
}
class ViewModel: ObservableObject {
#Published var text: String = ""
private var disposables = Set<AnyCancellable>()
init() {
$text.map { value -> String in
self.formattedNumber(number: value)
}
//something wrong here
.assign(to: \.text, on: self)
.store(in: &disposables)
}
func formattedNumber(number: String) -> String {
let cleanPhoneNumber = number.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
let mask = "+X (XXX) XXX-XXXX"
var result = ""
var index = cleanPhoneNumber.startIndex
for ch in mask where index < cleanPhoneNumber.endIndex {
if ch == "X" {
result.append(cleanPhoneNumber[index])
index = cleanPhoneNumber.index(after: index)
} else {
result.append(ch)
}
}
return result
}
}
}
struct PhoneNumberParser_Previews: PreviewProvider {
static var previews: some View {
PhoneNumberField(title: "Phone Number")
}
}
Use .receive(on:):
$text.map { self.formattedNumber(number: $0) }
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] value in
self?.text = value
})
.store(in: &disposables)
This will allow you to listen to changes of the text variable and update it in the main queue. Using main queue is necessary if you want to update #Published variables read by some View.
And to avoid having a retain cycle (self -> disposables -> assign -> self) use sink with a weak self.

Is it possible to exclude certain property (Like class type) from being copied during struct copy?

I have the following struct, which contains class.
import Foundation
func generateRichText(body: String?) -> NSMutableAttributedString? {
if body == nil {
return nil
}
// TODO: Some complex logic to decorate body string will be added soon...
let myAttrString = NSMutableAttributedString(string: body!)
return myAttrString
}
struct Note {
var body: String?
// Technique described in https://stackoverflow.com/a/25073176/72437
var bodyAsRichText: NSMutableAttributedString? {
mutating get {
if (cachedBodyAsRichText == nil) {
cachedBodyAsRichText = generateRichText(body: body)
}
return cachedBodyAsRichText
}
}
// TODO: This is a class. I don't want it to be copied over during struct copy.
// If it is copied during struct copy, both struct will be sharing the same
// class instance.
private var cachedBodyAsRichText: NSMutableAttributedString?
}
var note = Note()
note.body = "hello"
print("note.bodyAsRichText = \(Unmanaged.passUnretained(note.bodyAsRichText!).toOpaque())")
var note_copy = note
print("note_copy.bodyAsRichText = \(Unmanaged.passUnretained(note_copy.bodyAsRichText!).toOpaque())")
For the above code, the output will be
note.bodyAsRichText = 0x000055c035cfce70
note_copy.bodyAsRichText = 0x000055c035cfce70
What my desired output is, different struct instance, should be having their very own class instance (cachedBodyAsRichText)
Hence, is there a way, to exclude cachedBodyAsRichText from being copied over, during struct copy?
Your solution is incomplete. Here is a complete and correct solution.
struct Note {
var body: String = "" {
didSet {
cachedBodyAsRichText = nil
}
}
var bodyAsRichText: NSAttributedString {
mutating get {
if (cachedBodyAsRichText == nil) {
cachedBodyAsRichText = generateRichText(body: body)
}
return cachedBodyAsRichText!.copy() as! NSAttributedString
}
}
private var cachedBodyAsRichText: NSAttributedString? = nil
}
You need to clear out the cache every time the body is modified. Once you do that, it won't matter if the object is shared among structs.

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.