9

I am trying to make a ObservableObject that has properties that wrap a UserDefaults variable.

In order to conform to ObservableObject, I need to wrap the properties with @Published. Unfortunately, I cannot apply that to computed properties, as I use for the UserDefaults values.

How could I make it work? What do I have to do to achieve @Published behaviour?

Nicolas Degen
  • 1,522
  • 2
  • 15
  • 24
  • 1
    Maybe I get you wrong, but there’s no need to wrap each property with @Published. Therefore you can have published properties and normal properties which are computed in your class side by side. If you share a bit of code to clarify what you try to achieve would help. – jboi Nov 27 '19 at 19:05

4 Answers4

12

When Swift is updated to enable nested property wrappers, the way to do this will probably be to create a @UserDefault property wrapper and combine it with @Published.

In the mean time, I think the best way to handle this situation is to implement ObservableObject manually instead of relying on @Published. Something like this:

class ViewModel: ObservableObject {
    let objectWillChange = ObservableObjectPublisher()

    var name: String {
        get {
            UserDefaults.standard.string(forKey: "name") ?? ""
        }
        set {
            objectWillChange.send()
            UserDefaults.standard.set(newValue, forKey: "name")
        }
    }
}

Property wrapper

As I mentioned in the comments, I don't think there is a way to wrap this up in a property wrapper that removes all boilerplate, but this is the best I can come up with:

@propertyWrapper
struct PublishedUserDefault<T> {
    private let key: String
    private let defaultValue: T

    var objectWillChange: ObservableObjectPublisher?

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

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

class ViewModel: ObservableObject {
    let objectWillChange = ObservableObjectPublisher()

    @PublishedUserDefault(key: "name")
    var name: String = "John"

    init() {
        _name.objectWillChange = objectWillChange
    }
}

You still need to declare objectWillChange and connect it to your property wrapper somehow (I'm doing it in init), but at least the property definition itself it pretty simple.

jjoelson
  • 5,771
  • 5
  • 31
  • 51
  • 1
    Yes I this works fine, but I explicitly wanted to get rid of the boilerplate by using PorpertyWrapper for that.. – Nicolas Degen Nov 27 '19 at 10:02
  • Unfortunately the behavior for automatically triggering `objectWillChange` is hardcoded to only work with `@Published` as far as I know, so you could create a custom property wrapper that saves to user defaults and also is a publisher as in your answer, but you're not going to get it to update automatically without some kind of extra boilerplate. – jjoelson Nov 27 '19 at 13:40
  • what would you exactly change to my answer to achieve this? – Nicolas Degen Nov 27 '19 at 16:33
  • ah i see. I had tried to pass a ref to objectWillChange on the property init, but as it is not initialized yet it does not work unfortunatly.. – Nicolas Degen Nov 28 '19 at 07:39
9

For an existing @Published property

Here's one way to do it, you can create a lazy property that returns a publisher derived from your @Published publisher:

import Combine

class AppState: ObservableObject {
  @Published var count: Int = 0
  lazy var countTimesTwo: AnyPublisher<Int, Never> = {
    $count.map { $0 * 2 }.eraseToAnyPublisher()
  }()
}

let appState = AppState()
appState.count += 1
appState.$count.sink { print($0) }
appState.countTimesTwo.sink { print($0) }
// => 1
// => 2
appState.count += 1
// => 2
// => 4

However, this is contrived and probably has little practical use. See the next section for something more useful...


For any object that supports KVO

UserDefaults supports KVO. We can create a generalizable solution called KeyPathObserver that reacts to changes to an Object that supports KVO with a single @ObjectObserver. The following example will run in a Playground:

import Foundation
import UIKit
import PlaygroundSupport
import SwiftUI
import Combine

let defaults = UserDefaults.standard

extension UserDefaults {
  @objc var myCount: Int {
    return integer(forKey: "myCount")
  }

  var myCountSquared: Int {
    return myCount * myCount
  }
}

class KeyPathObserver<T: NSObject, V>: ObservableObject {
  @Published var value: V
  private var cancel = Set<AnyCancellable>()

  init(_ keyPath: KeyPath<T, V>, on object: T) {
    value = object[keyPath: keyPath]
    object.publisher(for: keyPath)
      .assign(to: \.value, on: self)
      .store(in: &cancel)
  }
}

struct ContentView: View {
  @ObservedObject var defaultsObserver = KeyPathObserver(\.myCount, on: defaults)

  var body: some View {
    VStack {
      Text("myCount: \(defaults.myCount)")
      Text("myCountSquared: \(defaults.myCountSquared)")
      Button(action: {
        defaults.set(defaults.myCount + 1, forKey: "myCount")
      }) {
        Text("Increment")
      }
    }
  }
}
let viewController = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = viewController

note that we've added an additional property myCountSquared to the UserDefaults extension to calculate a derived value, but observe the original KeyPath.

screenshot

Gil Birman
  • 35,242
  • 14
  • 75
  • 119
  • I don't quite get how I would use this in a PropertyWrapper. Make wrappedValue of the type AnyPublisher? – Nicolas Degen Nov 27 '19 at 10:06
  • @NicolasDegen I added to my answer specifically about `UserDefaults`, is that more useful? – Gil Birman Nov 27 '19 at 18:50
  • that looks interesting! I have to say I like my proposed solution better, even though it does not work perfectly yet. I still have hope I can make it work as a simple PropertyWrapper:) – Nicolas Degen Nov 28 '19 at 07:44
5

Updated: With the EnclosingSelf subscript, one can do it!

Works like a charm!

import Combine
import Foundation

class LocalSettings: ObservableObject {
  static var shared = LocalSettings()

  @Setting(key: "TabSelection")
  var tabSelection: Int = 0
}

@propertyWrapper
struct Setting<T> {
  private let key: String
  private let defaultValue: T

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

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

  public static subscript<EnclosingSelf: ObservableObject>(
    _enclosingInstance object: EnclosingSelf,
    wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, T>,
    storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Setting<T>>
  ) -> T {
    get {
      return object[keyPath: storageKeyPath].wrappedValue
    }
    set {
      (object.objectWillChange as? ObservableObjectPublisher)?.send()
      UserDefaults.standard.set(newValue, forKey: object[keyPath: storageKeyPath].key)
    }
  }
}

Nicolas Degen
  • 1,522
  • 2
  • 15
  • 24
  • 2
    Yes, this works indeed, but segfaults the compiler should the enclosing instance not be a ObservableObject. Error "static subscript 'subscript(_enclosingInstance:wrapped:storage:)' requires that 'SomeType' conform to 'ObservableObject') - tested with Xcode 11.5 – Ralf Ebert Jun 08 '20 at 19:59
  • 1
    yes I have noticed that too. Not sure how one could fix that. OpenCombine does some @available tricks I have not figured out yet.. – Nicolas Degen Jun 08 '20 at 22:05
0

Now we have @AppStorage for this:

App Storage

A property wrapper type that reflects a value from UserDefaults and invalidates a view on a change in value in that user default.

https://developer.apple.com/documentation/swiftui/appstorage

malhal
  • 26,330
  • 7
  • 115
  • 133
  • 1
    Well sure, but it would be nice to have it in a `ObservableObject` – Nicolas Degen Jan 09 '21 at 09:24
  • @NicolasDegen that doesn't make sense, ObservableObject can only be used on classes so if you already have a class then you don't need a property wrapper. The reason these View struct property wrappers exist is to make the struct behave like an instance of a class. Every time a View struct is created the property wrapped properties have the same values as the last time the struct was created. Where as an instance of a class keeps its property values all the time anyway. – malhal Jan 10 '21 at 10:10
  • Sure, it is not needed. But just like `@Published` it would also be handy:) – Nicolas Degen Jan 10 '21 at 13:11
  • `@Published` sends the objectWillChange publisher to a View when the model object using it is set, why would you want to do that for a user default when the View can already use `@AppStorage` to do it itself. If you must then you can redefine the objectWillChange publisher as a UserDefaults KVO publisher and then you don't even need any kind of @Published property it'll be all automatic. – malhal Jan 11 '21 at 18:12
  • 2
    Just for readability, collecting all properties in an ObservableObject. I was also trying to implement it for some custom wrappers, where the issue is similar.. – Nicolas Degen Jan 11 '21 at 22:13
  • Objects are expensive in SwiftUI, use them only for model objects or fetchers/loaders. – malhal Jan 12 '21 at 23:06