2

I have a caching system that works. I have a property wrapper. Now I want to turn that into a property wrapper that publishes for use in a SwiftUI view but I can't work that out.

Here is my code for the property wrapper (that doesn't successfully publish). This was sort of taken from another answer on stack overflow, where I've defined an internal CurrentValueSubject that gets fired every time a new value is set to the wrappedValue:

import UIKit
import Combine

@propertyWrapper final class CachedPublisher<Value: Codable> {
  enum Key: String, Codable {
    case user
  }

  let key: Key
  let cache: Cache<Key, Value>
  private let defaultValue: Value
  private var cancellables = Set<AnyCancellable>()
  private lazy var subject = CurrentValueSubject<Value, Error>(wrappedValue)

  var wrappedValue: Value {
    get {
      let value = cache[key]
      return value ?? defaultValue
    }
    set {
      cache[key] = newValue
      subject.send(newValue)
    }
  }

  var projectedValue: AnyPublisher<Value, Error> {
    return subject.eraseToAnyPublisher()
  }

  init(
    wrappedValue defaultValue: Value,
    key: Key
  ) {
    self.key = key
    self.defaultValue = defaultValue
    if let cache = try? Cache<Key, Value>.retrieveFromDisk(withName: key.rawValue) {
      self.cache = cache
    } else {
      cache = Cache<Key, Value>()
    }
  }
}

// MARK: - ExpressibleByNilLiteral
extension CachedPublisher where Value: ExpressibleByNilLiteral {
  convenience init(key: Key) {
    self.init(wrappedValue: nil, key: key)
  }
}

This is to be specified like this inside an ObservableObject:

@CachedPublisher(key: .user)
var user: User?

Then I want to use it in a SwiftUI view so my view updates whenever user changes.


Update in response to Asperi:

I have an @EnvironmentObject that is my source of truth:

final class MyTruthObject: ObservableObject {
    @CachedPublisher(key: .user)
    var user: User? = User(name: "Unknown")
}


struct DemoView: View {
    @EnvironmentObject private var myTruth: MyTruthObject

    var body: some View {
        VStack {
            Text("User: \(myTruth.user?.name ?? "")")
            Divider()
            Button("Update") {
                myTruth.user = User(name: "John Smith")
            }
            Button("Reset") {
                myTruth.user = nil
            }
        }
    }
}

Would your answer work with this layout?

Tometoyou
  • 7,792
  • 12
  • 62
  • 108
  • 1
    Isn't this similar to a UserDefaults front-end property-wrapper? Those are pretty common and it sounds to me like this would work the same way. Ideally the back end (cache, UserDefaults, file, whatever) would be a hidden implementation detail anyway. – matt Dec 28 '21 at 19:31
  • @matt yeah (although I'm actually using NSCache). I just can't get it to actually "publish" to update my swift views. I've also tried using the answer here, with an inner `@Published` property but to no avail: https://stackoverflow.com/a/69183796/362840 – Tometoyou Dec 28 '21 at 19:39
  • I guess the cache part is secondary - I just need to be able to publish from within a property wrapper first and I can add the cache in myself – Tometoyou Dec 28 '21 at 19:40
  • According to the documentation, Apple's `Published` property wrapper does its publishing in the `willSet` for the property instead of in `set`. I don't know if that will help you're, but it's something I noticed about your code. – Scott Thompson Dec 31 '21 at 02:32

1 Answers1

2

We need a dynamic property in SwiftUI view to make view updated. Here is possible approach based on aggregated State

demo

@propertyWrapper struct CachedPublisher<Value: Codable>: DynamicProperty {
    enum Key: String, Codable {
        case user
    }

    let key: Key
    let cache: Cache<Key, Value>
    private let defaultValue: Value?
    let storage: State<Value?>

    var wrappedValue: Value? {
        get {
            storage.wrappedValue
        }
        nonmutating set {
            let value = newValue ?? defaultValue
            cache[key] = value
            storage.wrappedValue = value
        }
    }

    var projectedValue: Binding<Value?> {
        storage.projectedValue
    }

    init(
        wrappedValue defaultValue: Value?,
        key: Key
    ) {
        self.key = key
        self.defaultValue = defaultValue
        if let cache = try? Cache<Key, Value>.retrieveFromDisk(withName: key.rawValue) {
            self.cache = cache
        } else {
            cache = Cache<Key, Value>()
        }
        self.storage = State(initialValue: cache[key] ?? defaultValue)
    }
}

and tested with Xcode 13.2 / iOS 15.2 (+ some replication for missed components) using

struct DemoView: View {
    @CachedPublisher(key: .user)
    var user: User? = User(name: "Unknown")

    var body: some View {
        VStack {
            Text("User: \(user?.name ?? "")")
            Divider()
            Button("Update") {
                user = User(name: "John Smith")
            }
            Button("Reset") {
                user = nil
            }
        }
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Thanks for the detailed answer. This is exactly what I'm looking for... But I can't seem to get it to actually work for me. My `@CachedPublisher` is referenced inside from an `@EnvironmentObject`. Much like you would have `@Published` properties. Would that matter? – Tometoyou Jan 01 '22 at 17:14
  • Also does it need to be a struct, can it be a class? – Tometoyou Jan 01 '22 at 17:20
  • As it is struct and not ObservableObject you cannot use it as EnvironmentObject. You should use it directly in view. – Asperi Jan 01 '22 at 17:21
  • I've add to my question to show you what I mean – Tometoyou Jan 01 '22 at 17:25
  • Okay I just tried it in playground with your example and it defo doesn't work when it's within an observable object. It needs to be... – Tometoyou Jan 01 '22 at 17:37