0

I have a first @property wrapper that is made to be UserDefaults called @UserDefault(key: .isSignedIn, defaultValue: false) var isSignedIn: Bool. it is working fine as a publisher by using it this way, $isSignedIn to update my SwiftUI view when used by itself. But I would like to make it in a dependency set in a view model.

Now, to hold UserDefaults values, I create a dependency set as a @propertyWrapper called @Injection(\.localDataManager) var localDataManager that will also be used in unit tests using a subscript solution associated with key called InjectedDependency[\.localDataManager] = LocalDataManagerMock()

The problem is that LocalDataManager class is not a @ObservableObject and does not update my SwiftUI view over time.

  • How can LocalDataManager be an @ObservableObject using this InjectedDependency unit testing solution?

The SwiftUI view with the view model:

import SwiftUI

struct ContentView: View {

  @StateObject var viewModel = DataViewModel()

  var body: some View {
    VStack(spacing: 16) {
      Text(viewModel.localDataManager.isSignedIn.description)

      Button(action: { viewModel.localDataManager.isSignedIn.toggle() }) {
        Text("Toggle boolean button")
      }
    }
  }
}

class DataViewModel: ObservableObject {

  @Injection(\.localDataManager) var localDataManager
}

What I would like to use as a @ObservableObject:

import Foundation

protocol LocalDataManagerProtocol {
  var isSignedIn: Bool { get set }
}

final class LocalDataManager: LocalDataManagerProtocol {

  @UserDefault(key: .isSignedIn, defaultValue: false)
  var isSignedIn: Bool
}

@propertyWrapper
struct Injection<T> {

  private let keyPath: WritableKeyPath<InjectedDependency, T>

  var wrappedValue: T {
    get { InjectedDependency[keyPath] }
    set { InjectedDependency[keyPath] = newValue }
  }

  init(_ keyPath: WritableKeyPath<InjectedDependency, T>) {
    self.keyPath = keyPath
  }
}

The @UserDefault code:

import Combine
import SwiftUI

enum UserDefaultsKey: String {
  case isSignedIn
}

protocol UserDefaultsProtocol {
  func object(forKey defaultName: String) -> Any?
  func set(_ value: Any?, forKey defaultName: String)
}
extension UserDefaults: UserDefaultsProtocol {}

@propertyWrapper
struct UserDefault<Value> {

  let key: UserDefaultsKey
  let defaultValue: Value
  var container: UserDefaultsProtocol = UserDefaults.standard

  private let publisher = PassthroughSubject<Value, Never>()

  var wrappedValue: Value {
    get {
      return container.object(forKey: key.rawValue) as? Value ?? defaultValue
    }
    set {
      container.set(newValue, forKey: key.rawValue)
      publisher.send(newValue)
    }
  }

  var projectedValue: AnyPublisher<Value, Never> {
    publisher.eraseToAnyPublisher()
  }
}

The InjectedDependency solution for Unit Testing:

// Use in Unit Test code in SetUp()
// `InjectedDependency[\.localDataManager] = LocalDataManagerMock()`

protocol InjectedKeyProtocol {
  associatedtype Value
  static var currentValue: Self.Value { get set }
}

struct InjectedDependency {

private static var current = InjectedDependency()

static subscript<K>(key: K.Type) -> K.Value where K: InjectedKeyProtocol {
    get { key.currentValue }
    set { key.currentValue = newValue }
  }

  static subscript<T>(_ keyPath: WritableKeyPath<InjectedDependency, T>) -> T {
    get { current[keyPath: keyPath] }
    set { current[keyPath: keyPath] = newValue }
  }

  var localDataManager: LocalDataManagerProtocol {
    get { Self[LocalDataManagerKey.self] }
    set { Self[LocalDataManagerKey.self] = newValue }
  }
}

private struct LocalDataManagerKey: InjectedKeyProtocol {
  static var currentValue: LocalDataManagerProtocol = LocalDataManager()
}
Roland Lariotte
  • 2,606
  • 1
  • 16
  • 40
  • 2
    Can you create a [mre] with this code, so we can copy & paste it directly into a project and find the solution? Just a simple `ContentView` showing the example would be perfect, and then you'll be able to get some useful answers (and not guesses) – George Sep 01 '21 at 22:15
  • 1
    The code was updated with the minimal reproducible example and the ContentView that would show off my issue. – Roland Lariotte Sep 02 '21 at 18:40

1 Answers1

1

You need to conform LocalDataManager to ObservableObject, and then manually send the changed value down.

Notice the new willSet parts:

class DataViewModel: ObservableObject {
    @Injection(\.localDataManager) var localDataManager {
        willSet {
            objectWillChange.send()
        }
    }
}
final class LocalDataManager: ObservableObject, LocalDataManagerProtocol {
    @UserDefault(key: .isSignedIn, defaultValue: false)
    var isSignedIn: Bool {
        willSet {
            objectWillChange.send()
        }
    }
}

Result:

George
  • 25,988
  • 10
  • 79
  • 133