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())
@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