Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Add @Published behaviour for computed property

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?

like image 979
Nicolas Degen Avatar asked Nov 25 '19 17:11

Nicolas Degen


People also ask

What computed property?

A Computed Property provides a getter and an optional setter to indirectly access other properties and values. It can be used in several ways. A common use-case is to derive value from other properties.

What is the difference between a computed property and a property set to a closure?

In short, the first is a stored property that is initialized via a closure, with that closure being called only one time, when it is initialized. The second is a computed property whose get block is called every time you reference that property.

What is computed stored properties?

Computed properties are for creating custom get and set methods for stored properties. Computed properties are provided by classes, structures, and enumerations to provide custom behavior for properties. Stored and computed properties are usually associated with a particular type but can be associated with any type.


4 Answers

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.

like image 182
jjoelson Avatar answered Oct 19 '22 14:10

jjoelson


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

like image 44
Gil Birman Avatar answered Oct 19 '22 14:10

Gil Birman


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)
    }
  }
}

like image 5
Nicolas Degen Avatar answered Oct 19 '22 15:10

Nicolas Degen


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

like image 1
malhal Avatar answered Oct 19 '22 16:10

malhal