Skip to content

Instantly share code, notes, and snippets.

@Gernot
Created October 16, 2020 16:40
Show Gist options
  • Save Gernot/9d61dff3d7579b7cdaa5ed6760ab502f to your computer and use it in GitHub Desktop.
Save Gernot/9d61dff3d7579b7cdaa5ed6760ab502f to your computer and use it in GitHub Desktop.
Model with Properties form AppStorage
import Foundation
import SwiftUI
import Combine
import PlaygroundSupport
struct Model {
@AppStorage(wrappedValue: true, "Test") var isEnabled
}
struct MyView: View {
@State var model = Model()
@AppStorage(wrappedValue: true, "Test") var valueFromPreferences
var body: some View {
Form {
Toggle("State", isOn: model.$isEnabled)
Toggle("Preferences", isOn: $valueFromPreferences)
}
}
}
PlaygroundPage.current.setLiveView(MyView())
@Gernot
Copy link
Author

Gernot commented Oct 16, 2020

Unfortunately this has the same issues as the original, see this Twitter Thread

Here is as far as I am with writing a custom Property Wrapper that observes the settings value:

import Foundation
import SwiftUI
import Combine
import PlaygroundSupport

private var keyChangedContext = "KeyChangedContext"

@propertyWrapper
class Storage<T>: NSObject, DynamicProperty {
    
    init(wrappedValue defaultValue: T, _ key: String, store: UserDefaults = UserDefaults.standard) {
        self.defaultValue = defaultValue
        self.key = key
        self.store = store
        super.init()
        store.addObserver(self, forKeyPath: key, options: [], context: &keyChangedContext)
    }
    
    deinit {
        store.removeObserver(self, forKeyPath: key)
    }

    private let store: UserDefaults
    private let key: String
    private let defaultValue: T
    
    var wrappedValue: T {
        get {
            store.value(forKey: key) as? T ?? defaultValue
        }
        set {
            store.setValue(newValue, forKey: key)
        }
    }
    
    //Question: Should this be one shared binding or should it be a new binding on every get?
    var projectedValue: Binding<T> {
        Binding(
            get: { self.wrappedValue },
            set: { newValue in self.wrappedValue = newValue }
        )
    }
    
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if context == &keyChangedContext,
           keyPath == self.key {
            print("\(keyPath) changed value")
            //++++++++++++++++++++++++
            //TODO: Somehow get the binding to send the value and trigger the DynamicProperty updating.
            //++++++++++++++++++++++++
            return
        }
        super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
    }
}

struct Model {
    @Storage(wrappedValue: true, "Test") var isEnabled
}

struct MyView: View {
    
    init(_ model: Model) {
        self._model = .init(initialValue: model)
    }
    
    @State var model: Model
    @Storage(wrappedValue: true, "Test") var valueFromPreferences
    //@Binding var valueFromModel: Bool
    
    var body: some View {
        Form {
            Toggle("State", isOn: model.$isEnabled)
            Toggle("Preferences", isOn: $valueFromPreferences)
        }
    }
}

PlaygroundPage.current.setLiveView(MyView(Model()))

@pcolton
Copy link

pcolton commented Oct 16, 2020

This is my current version, it almost works, the main issue is cycle detected through attribute which I think is happening because both AppStorage and the Model are both sending updates.

class Model: ObservableObject {
    
    @UserDefault(wrappedValue: true, "Test") var isEnabledProperty

    var isEnabled: Bool = false
    {
        didSet {
            if _isEnabledProperty.wrappedValue != isEnabled {
                _isEnabledProperty.wrappedValue = isEnabled
            }
        }
    }

    private var cancellable: Cancellable? = nil

    init() {
        cancellable = NotificationCenter.default
            .publisher(for: UserDefaults.didChangeNotification)
            .map { _ in () }
            .debounce(for: .milliseconds(150), scheduler: RunLoop.main)
            .sink(receiveValue: {
                let currentValue = self._isEnabledProperty.wrappedValue
                if self.isEnabled != currentValue {
                    self.isEnabled = currentValue
                    self.objectWillChange.send()
                }
            })
    }
}

@pcolton
Copy link

pcolton commented Oct 16, 2020

Ok, this works as expected, and plays nice with @AppStorage...

import Foundation
import SwiftUI
import Combine
import PlaygroundSupport

@propertyWrapper
public struct UserDefault<T> {
    let key: String
    let defaultValue: T

    public init(wrappedValue: T, _ key: String) {
        self.key = key
        self.defaultValue = wrappedValue
    }

    public var wrappedValue: T {
        get {
            UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}


class Model: ObservableObject {
    
    @UserDefault(wrappedValue: true, "Test") var isEnabled

    public let objectWillChange = PassthroughSubject<Void, Never>()

    private var cancellable: Cancellable? = nil

    init() {
        cancellable = NotificationCenter.default
            .publisher(for: UserDefaults.didChangeNotification)
            .map { _ in () }
            .debounce(for: .milliseconds(150), scheduler: RunLoop.main)
            .subscribe(objectWillChange)
    }
}

struct MyView: View {
    
    @StateObject var model = Model()
    @AppStorage(wrappedValue: true, "Test") var isEnabledProperty

    var body: some View {
        Form {
            Toggle("State", isOn: $model.isEnabled)
            Toggle("Preferences", isOn: $isEnabledProperty)
        }
    }
}

PlaygroundPage.current.setLiveView(MyView())

@Gernot
Copy link
Author

Gernot commented Oct 16, 2020

Thanks a lot! This works, but I see a few issues:

  • The model is now a class, and for reasons that are out of scope here, that's not a good option for me. I am looking for extending a struct with a property, without making additional changes to the model.
  • Observing the UserDefaults with a catch-all-notification triggers too often. You debounce it, but it still triggers a lot on unrelated changes. It'd be better to observe the key instead of the whole defaults.
  • ObservableObject already has a default objectWillChange publisher that does not need to be overridden.

I went for a different approach in the meantime:

  • I am still using a property wrapper, but that wraps the original AppStorage.
  • In the View, I have to add the AppStorage as a View property that can trigger the update. This way I get around having to turn the struct into an observableObject class.
  • I am using the projectedValue for getting the AppStorage.
import Foundation
import SwiftUI
import Combine
import PlaygroundSupport

@propertyWrapper
struct WrappedAppStorage<T> {
    
    init(wrappedValue defaultValue: T,
         _ key: String,store: UserDefaults = UserDefaults.standard) where T == Bool {
        self.appStorage = AppStorage<T>(wrappedValue: defaultValue, key, store: store)
    }
    
    let appStorage: AppStorage<T>
    
    var wrappedValue: T {
        get { appStorage.wrappedValue }
        set { appStorage.wrappedValue = newValue }
    }
    
    var projectedValue: AppStorage<T> { appStorage }
    
}

struct Model {
    @WrappedAppStorage(wrappedValue: true, "Test") var isEnabled
}

struct MyView: View {
    
    init(_ model: Model) {
        self._model = .init(initialValue: model)
        _valueFromModel = model.$isEnabled
    }

    @State var model: Model
    
    @AppStorage var valueFromModel: Bool
    @AppStorage(wrappedValue: true, "Test") var valueFromPreferences
    
    var body: some View {
        Form {
            Toggle("State", isOn: $valueFromModel)
            Toggle("Preferences", isOn: $valueFromPreferences)
        }
        .animation(.default)
    }
}

PlaygroundPage.current.setLiveView(MyView(Model()))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment