diff --git a/Defaults.xcodeproj/project.pbxproj b/Defaults.xcodeproj/project.pbxproj index 5ff9ffb..7bfbecb 100644 --- a/Defaults.xcodeproj/project.pbxproj +++ b/Defaults.xcodeproj/project.pbxproj @@ -29,6 +29,10 @@ E339B3B92449F10D00E7A40A /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E339B3B72449F10D00E7A40A /* UserDefaults.swift */; }; E339B3BA2449F10D00E7A40A /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E339B3B72449F10D00E7A40A /* UserDefaults.swift */; }; E339B3BB2449F10D00E7A40A /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E339B3B72449F10D00E7A40A /* UserDefaults.swift */; }; + E38C9F27244ADA2F00A6737A /* SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38C9F26244ADA2F00A6737A /* SwiftUI.swift */; }; + E38C9F28244ADA2F00A6737A /* SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38C9F26244ADA2F00A6737A /* SwiftUI.swift */; }; + E38C9F29244ADA2F00A6737A /* SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38C9F26244ADA2F00A6737A /* SwiftUI.swift */; }; + E38C9F2A244ADA2F00A6737A /* SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38C9F26244ADA2F00A6737A /* SwiftUI.swift */; }; E3EB3E33216505920033B089 /* util.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3EB3E32216505920033B089 /* util.swift */; }; E3EB3E35216507AE0033B089 /* Observation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3EB3E34216507AE0033B089 /* Observation.swift */; }; E3EB3E36216507B50033B089 /* Observation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3EB3E34216507AE0033B089 /* Observation.swift */; }; @@ -79,6 +83,7 @@ E286D0C623B8D51100570D1E /* Observation+Combine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = "Observation+Combine.swift"; sourceTree = ""; usesTabs = 1; }; E339B3B22449ED2000E7A40A /* Reset.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Reset.swift; sourceTree = ""; usesTabs = 1; }; E339B3B72449F10D00E7A40A /* UserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = UserDefaults.swift; sourceTree = ""; usesTabs = 1; }; + E38C9F26244ADA2F00A6737A /* SwiftUI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = SwiftUI.swift; sourceTree = ""; usesTabs = 1; }; E3EB3E32216505920033B089 /* util.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = util.swift; sourceTree = ""; usesTabs = 1; }; E3EB3E34216507AE0033B089 /* Observation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Observation.swift; sourceTree = ""; usesTabs = 1; }; /* End PBXFileReference section */ @@ -215,6 +220,7 @@ E339B3B22449ED2000E7A40A /* Reset.swift */, E3EB3E34216507AE0033B089 /* Observation.swift */, E286D0C623B8D51100570D1E /* Observation+Combine.swift */, + E38C9F26244ADA2F00A6737A /* SwiftUI.swift */, E3EB3E32216505920033B089 /* util.swift */, ); path = Defaults; @@ -502,6 +508,7 @@ buildActionMask = 2147483647; files = ( E286D0C823B8D54C00570D1E /* Observation+Combine.swift in Sources */, + E38C9F28244ADA2F00A6737A /* SwiftUI.swift in Sources */, 8933C7851EB5B820000D00A4 /* Defaults.swift in Sources */, E339B3B92449F10D00E7A40A /* UserDefaults.swift in Sources */, E3EB3E35216507AE0033B089 /* Observation.swift in Sources */, @@ -523,6 +530,7 @@ buildActionMask = 2147483647; files = ( E286D0CA23B8D54E00570D1E /* Observation+Combine.swift in Sources */, + E38C9F2A244ADA2F00A6737A /* SwiftUI.swift in Sources */, E3EB3E3A216507C40033B089 /* util.swift in Sources */, E339B3BB2449F10D00E7A40A /* UserDefaults.swift in Sources */, E3EB3E37216507B50033B089 /* Observation.swift in Sources */, @@ -536,6 +544,7 @@ buildActionMask = 2147483647; files = ( E286D0C923B8D54D00570D1E /* Observation+Combine.swift in Sources */, + E38C9F29244ADA2F00A6737A /* SwiftUI.swift in Sources */, E3EB3E3B216507C40033B089 /* util.swift in Sources */, E339B3BA2449F10D00E7A40A /* UserDefaults.swift in Sources */, E3EB3E38216507B60033B089 /* Observation.swift in Sources */, @@ -549,6 +558,7 @@ buildActionMask = 2147483647; files = ( E286D0C723B8D51100570D1E /* Observation+Combine.swift in Sources */, + E38C9F27244ADA2F00A6737A /* SwiftUI.swift in Sources */, E3EB3E39216507C30033B089 /* util.swift in Sources */, E339B3B82449F10D00E7A40A /* UserDefaults.swift in Sources */, E3EB3E36216507B50033B089 /* Observation.swift in Sources */, diff --git a/Sources/Defaults/SwiftUI.swift b/Sources/Defaults/SwiftUI.swift new file mode 100644 index 0000000..a32dfaa --- /dev/null +++ b/Sources/Defaults/SwiftUI.swift @@ -0,0 +1,113 @@ +#if canImport(Combine) + +import SwiftUI +import Combine + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension Defaults { + final class Observable: ObservableObject { + let objectWillChange = ObservableObjectPublisher() + private var observation: DefaultsObservation? + private let key: Defaults.Key + + var value: Value { + get { Defaults[key] } + set { + objectWillChange.send() + Defaults[key] = newValue + } + } + + init(_ key: Key) { + self.key = key + + self.observation = Defaults.observe(key, options: [.prior]) { [weak self] change in + guard change.isPrior else { + return + } + + DispatchQueue.mainSafeAsync { + self?.objectWillChange.send() + } + } + } + + /// Reset the key back to its default value. + func reset() { + key.reset() + } + } +} + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +@propertyWrapper +public struct Default: DynamicProperty { + public typealias Publisher = AnyPublisher, Never> + + private let key: Defaults.Key + @ObservedObject private var observable: Defaults.Observable + + /** + Get/set a `Defaults` item and also have the view be updated when the value changes. This is similar to `@State`. + + ``` + extension Defaults.Keys { + static let hasUnicorn = Key("hasUnicorn", default: false) + } + + struct ContentView: View { + @Default(.hasUnicorn) var hasUnicorn + + var body: some View { + Text("Has Unicorn: \(hasUnicorn)") + Toggle("Toggle Unicorn", isOn: $hasUnicorn) + } + } + ``` + */ + public init(_ key: Defaults.Key) { + self.key = key + self.observable = Defaults.Observable(key) + } + + public var wrappedValue: Value { + get { observable.value } + nonmutating set { + observable.value = newValue + } + } + + public var projectedValue: Binding { $observable.value } + + /// Combine publisher that publishes values when the `Defaults` item changes. + public var publisher: Publisher { Defaults.publisher(key) } + + public mutating func update() { + _observable.update() + } + + /** + Reset the key back to its default value. + + ``` + extension Defaults.Keys { + static let opacity = Key("opacity", default: 1) + } + + struct ContentView: View { + @Default(.opacity) var opacity + + var body: some View { + Button("Reset") { + self._opacity.reset() + } + } + } + ``` + */ + public func reset() { + key.reset() + } +} + +#endif diff --git a/Sources/Defaults/util.swift b/Sources/Defaults/util.swift index 272a559..51ac149 100644 --- a/Sources/Defaults/util.swift +++ b/Sources/Defaults/util.swift @@ -132,3 +132,17 @@ extension Optional: _DefaultsOptionalType { func isOptionalType(_ type: T.Type) -> Bool { type is _DefaultsOptionalType.Type } + + +extension DispatchQueue { + /** + Performs the `execute` closure immediately if we're on the main thread or asynchronously puts it on the main thread otherwise. + */ + static func mainSafeAsync(execute work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + main.async(execute: work) + } + } +} diff --git a/readme.md b/readme.md index 54ee7b8..470ef52 100644 --- a/readme.md +++ b/readme.md @@ -13,9 +13,10 @@ It's used in production by apps like [Gifski](https://github.com/sindresorhus/Gi - **Strongly typed:** You declare the type and default value upfront. - **Codable support:** You can store any [Codable](https://developer.apple.com/documentation/swift/codable) value, like an enum. - **NSSecureCoding support:** You can store any [NSSecureCoding](https://developer.apple.com/documentation/foundation/nssecurecoding) value. -- **Debuggable:** The data is stored as JSON-serialized values. -- **Observation:** Observe changes to keys. +- **SwiftUI:** Property wrapper that updates the view when the `UserDefaults` value changes. - **Publishers:** Combine publishers built-in. +- **Observation:** Observe changes to keys. +- **Debuggable:** The data is stored as JSON-serialized values. ## Compatibility @@ -141,6 +142,29 @@ Defaults[isUnicorn] //=> true ``` +### SwiftUI support + +You can use the `@Default` property wrapper to get/set a `Defaults` item and also have the view be updated when the value changes. This is similar to `@State`. + +```swift +extension Defaults.Keys { + static let hasUnicorn = Key("hasUnicorn", default: false) +} + +struct ContentView: View { + @Default(.hasUnicorn) var hasUnicorn + + var body: some View { + Text("Has Unicorn: \(hasUnicorn)") + Toggle("Toggle Unicorn", isOn: $hasUnicorn) + } +} +``` + +Note that it's `@Default`, not `@Defaults`. + +This is only implemented for `Defaults.Key`. PR welcome for `Defaults.NSSecureCoding` if you need it. + ### Observe changes to a key ```swift @@ -449,6 +473,12 @@ Break the lifetime tie created by `tieToLifetime(of:)`, if one exists. The effects of any call to `tieToLifetime(of:)` are reversed. Note however that if the tied-to object has already died, then the observation is already invalid and this method has no logical effect. +### `@Default(_ key:)` + +Get/set a `Defaults` item and also have the view be updated when the value changes. + +This is only implemented for `Defaults.Key`. PR welcome for `Defaults.NSSecureCoding` if you need it. + ## FAQ ### How can I store a dictionary of arbitrary values?