I have a question why a Dynamic Property wrapper is not working as I expect it to work.
If I do this with @AppStorage it works as expected, but with @iCloudStorage it does not work.
Demo code with @AppStorage:
class DataClass: ObservableObject {
@AppStorage("dataSource")
var dataSource: String = "DataSource"
}
struct ContentView: View {
@ObservedObject var data = DataClass()
var body: some View {
VStack {
Text(data.dataSource) // this does update
Button("Change") {
data.dataSource = "New DataSource"
}
}
}
}
iCloudStorage property wrapper:
import Foundation
import SwiftUI
/// A property wrapper that reads and writes to iCloud.
///
/// Example:
/// ```
/// @iCloudStorage("key") var value: String = "default"
/// ```
@propertyWrapper
public struct iCloudStorage<T>: DynamicProperty {
// swiftlint:disable:previous type_name
/// The key to read and write to.
let key: String
/// The default value to use if the value is not set yet.
let defaultValue: T
@State private var value: T
/// Creates an `iCloudStorage` property.
///
/// - Parameter wrappedValue: The default value.
/// - Parameter key: The key to read and write to.
public init(wrappedValue: T, _ key: String) {
self.key = key
self.defaultValue = wrappedValue
self.value = NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue
}
/// The value of the key in iCloud.
public var wrappedValue: T {
get {
return value
}
nonmutating set {
NSUbiquitousKeyValueStore.default.set(newValue, forKey: key)
value = newValue
}
}
/// A binding to the value of the key in iCloud.
public var projectedValue: Binding<T> {
Binding {
return self.wrappedValue
} set: { newValue in
self.wrappedValue = newValue
}
}
}
Demo code with @iCloudStorage property wrapper:
class DataClass: ObservableObject {
@iCloudStorage("dataSource")
var dataSource: String = "DataSource"
}
struct ContentView: View {
@ObservedObject var data = DataClass()
var body: some View {
VStack {
Text(data.dataSource) // this does not update
Button("Change") {
data.dataSource = "New DataSource"
}
}
}
}
If I pass it directly to the ContentView it works as expected, but if I pass it to the DataClass class it does not work as expected.
This works "slightly" better, but this takes a lot of time to update, and only updates if something else changes in the view.
@propertyWrapper
public struct iCloudStorage<T>: DynamicProperty {
// swiftlint:disable:previous type_name
/// The key to read and write to.
let key: String
/// The default value to use if the value is not set yet.
let defaultValue: T
final private class Storage: ObservableObject {
var value: T {
willSet {
print("Willset")
objectWillChange.send()
}
}
init(_ value: T) {
self.value = value
}
}
@ObservedObject private var value: Storage
/// Creates an `iCloudStorage` property.
///
/// - Parameter wrappedValue: The default value.
/// - Parameter key: The key to read and write to.
public init(wrappedValue: T, _ key: String) {
self.key = key
self.defaultValue = wrappedValue
self.value = Storage(
NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue
)
}
/// The value of the key in iCloud.
public var wrappedValue: T {
get {
return value.value
}
nonmutating set {
NSUbiquitousKeyValueStore.default.set(newValue, forKey: key)
value.value = newValue
}
}
/// A binding to the value of the key in iCloud.
public var projectedValue: Binding<T> {
Binding {
return self.wrappedValue
} set: { newValue in
value.value = newValue
self.wrappedValue = newValue
}
}
}
AppStorage requires iOS 14… which is the same year that StateObject was released. That might be a clue. It's possible that State alone won't work… could you think of a creative way that StateObject could help?
import Foundation
import SwiftUI
/// A property wrapper that reads and writes to iCloud.
///
/// Example:
/// ```
/// @iCloudStorage("key") var value: String = "default"
/// ```
@propertyWrapper
public struct iCloudStorage<T>: DynamicProperty {
// swiftlint:disable:previous type_name
/// The key to read and write to.
let key: String
/// The default value to use if the value is not set yet.
let defaultValue: T
final private class Storage: ObservableObject {
var value: T {
willSet {
print("Willset")
objectWillChange.send()
}
}
init(_ value: T) {
self.value = value
}
}
@ObservedObject private var value: Storage
/// Creates an `iCloudStorage` property.
///
/// - Parameter wrappedValue: The default value.
/// - Parameter key: The key to read and write to.
public init(wrappedValue: T, _ key: String) {
self.key = key
self.defaultValue = wrappedValue
self.value = Storage(
NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue
)
}
/// The value of the key in iCloud.
public var wrappedValue: T {
get {
return value.value
}
nonmutating set {
NSUbiquitousKeyValueStore.default.set(newValue, forKey: key)
value.value = newValue
}
}
/// A binding to the value of the key in iCloud.
public var projectedValue: Binding<T> {
Binding {
return self.wrappedValue
} set: { newValue in
value.value = newValue
self.wrappedValue = newValue
}
}
}
And then
class DataClass: ObservableObject {
@iCloudStorage("dataSource")
var dataSource: String = "DataSource" {
willSet {
objectWillChange.send() // This is the trick
// hopefully I can find a way that I can omit this
}
}
}
struct ContentView: View {
@ObservedObject var data = DataClass()
@iCloudStorage("dataSource")
var dataSource: String = "DataSource"
var body: some View {
VStack {
Text(dataSource) // this does update
Button("Change") {
dataSource = [
"New DataSource",
"Test",
"qqqq"
].randomElement()!
}
Divider()
Text(data.dataSource) // this does update
Button("Change") {
data.dataSource = [
"New DataSource",
"Test",
"qqqq"
].randomElement()!
}
}
}
}
Sorry, I missed that this is also desired in an ObservableObject. Fwiw, wrappers like AppStorage are meant to be used within a View and using them in something like an ObservableObject will bring weird results. I've written a few custom wrappers pretty cleanly that work only in Views:
If you want both, a macro may be the most suitable option instead that makes some other storage Published variable to finally drive the changes, or calls objectWillChange like you've done.
Although, I think that the implementation would require knowing the surrounding context and the macro system doesn't have that yet. I may be wrong.
This will be my last update, I've finally got it working!
#if canImport(SwiftUI)
import Foundation
import SwiftUI
import Combine
/// A property wrapper that reads and writes to iCloud.
///
/// Example:
/// ```
/// @iCloudStorage("key") var value: String = "default"
/// ```
@propertyWrapper
public struct iCloudStorage<T>: DynamicProperty {
// swiftlint:disable:previous type_name
/// The key to read and write to.
let key: String
/// The default value to use if the value is not set yet.
let defaultValue: T
/// The cancellables to store.
var cancellables = Set<AnyCancellable>()
/// The observed storage
@ObservedObject private var store: Storage
/// Creates an `iCloudStorage` property.
///
/// - Parameter wrappedValue: The default value.
/// - Parameter key: The key to read and write to.
public init(wrappedValue: T, _ key: String) {
self.key = key
self.defaultValue = wrappedValue
self.store = Storage(
NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue
)
// Set-up notification for changed key.
NotificationCenter.default.publisher(
for: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: NSUbiquitousKeyValueStore.default
)
.receive(on: DispatchQueue.main)
.sink { [self] notification in
if let keys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String],
keys.contains(key) {
self.store.value = NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue
}
}
.store(in: &cancellables)
}
/// The value of the key in iCloud.
public var wrappedValue: T {
get {
return store.value
}
nonmutating set {
NSUbiquitousKeyValueStore.default.set(newValue, forKey: key)
store.value = newValue
}
}
/// A binding to the value of the key in iCloud.
public var projectedValue: Binding<T> {
$store.value
}
// MARK: - Storage
private final class Storage: ObservableObject {
var parentWillChange: ObservableObjectPublisher?
var value: T {
willSet {
objectWillChange.send()
parentWillChange?.send()
}
}
init(_ value: T) {
self.value = value
}
}
// MARK: - Get parent
/// Get the parent, to send a willChange event to there.
public static subscript<OuterSelf: ObservableObject>(
_enclosingInstance instance: OuterSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, T>,
storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
) -> T {
get {
instance[keyPath: storageKeyPath].store.parentWillChange = (
instance.objectWillChange as? ObservableObjectPublisher
)
return instance[keyPath: storageKeyPath].wrappedValue
}
set {
instance[keyPath: storageKeyPath].wrappedValue = newValue
}
}
}
#endif