From 7a22d378742acbef49ecff11b9de8bf8b72b34dc Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 18 Nov 2022 19:22:02 +0700 Subject: [PATCH] Add `.updates()` method Fixes #77 --- Sources/Defaults/Defaults.swift | 88 ++++++++- .../Documentation.docc/Documentation.md | 7 +- Sources/Defaults/Observation+Combine.swift | 6 +- Sources/Defaults/Observation.swift | 18 +- Sources/Defaults/SwiftUI.swift | 24 +-- Sources/Defaults/Utilities.swift | 8 + .../DefaultsCustomBridgeTests.swift | 4 +- .../DefaultsSetAlgebraTests.swift | 12 +- Tests/DefaultsTests/DefaultsTests.swift | 60 +++++++ readme.md | 170 ++---------------- 10 files changed, 191 insertions(+), 206 deletions(-) diff --git a/Sources/Defaults/Defaults.swift b/Sources/Defaults/Defaults.swift index 58e85ba..e34f959 100644 --- a/Sources/Defaults/Defaults.swift +++ b/Sources/Defaults/Defaults.swift @@ -76,7 +76,7 @@ extension Defaults { } ``` - - Warning: The key must be ASCII, not start with `@`, and cannot contain a dot (`.`). + - Warning: The `UserDefaults` name must be ASCII, not start with `@`, and cannot contain a dot (`.`). */ public final class Key: _AnyKey { /** @@ -90,7 +90,7 @@ extension Defaults { public var defaultValue: Value { defaultValueGetter() } /** - Create a defaults key. + Create a key. - Parameter key: The key must be ASCII, not start with `@`, and cannot contain a dot (`.`). @@ -118,7 +118,7 @@ extension Defaults { } /** - Create a defaults key with a dynamic default value. + Create a key with a dynamic default value. This can be useful in cases where you cannot define a static default value as it may change during the lifetime of the app. @@ -143,7 +143,7 @@ extension Defaults { } /** - Create a defaults key with an optional value. + Create a key with an optional value. - Parameter key: The key must be ASCII, not start with `@`, and cannot contain a dot (`.`). */ @@ -286,3 +286,83 @@ extension Defaults { */ typealias CodableBridge = _DefaultsCodableBridge } + +extension Defaults { + /** + Observe updates to a stored value. + + - Parameter initial: Trigger an initial event on creation. This can be useful for setting default values on controls. + + ```swift + extension Defaults.Keys { + static let isUnicornMode = Key("isUnicornMode", default: false) + } + + // … + + Task { + for await value in Defaults.updates(.isUnicornMode) { + print("Value:", value) + } + } + ``` + */ + public static func updates( + _ key: Key, + initial: Bool = true + ) -> AsyncStream { // TODO: Make this `some AsyncSequence` when Swift 6 is out. + .init { continuation in + let observation = UserDefaultsKeyObservation(object: key.suite, key: key.name) { change in + // TODO: Use the `.deserialize` method directly. + let value = KeyChange(change: change, defaultValue: key.defaultValue).newValue + continuation.yield(value) + } + + observation.start(options: initial ? [.initial] : []) + + continuation.onTermination = { _ in + observation.invalidate() + } + } + } + + // TODO: Make this include a tuple with the values when Swift supports variadic generics. I can then simply use `merge()` with the first `updates()` method. + /** + Observe updates to multiple stored values. + + - Parameter initial: Trigger an initial event on creation. This can be useful for setting default values on controls. + + ```swift + Task { + for await _ in Defaults.updates([.foo, .bar]) { + print("One of the values changed") + } + } + ``` + + - Note: This does not include which of the values changed. Use ``Defaults/updates(_:initial:)-9eh8`` if you need that. You could use [`merge`](https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/AsyncAlgorithms.docc/Guides/Merge.md) to merge them into a single sequence. + */ + public static func updates( + _ keys: [_AnyKey], + initial: Bool = true + ) -> AsyncStream { // TODO: Make this `some AsyncSequence` when Swift 6 is out. + .init { continuation in + let observations = keys.indexed().map { index, key in + let observation = UserDefaultsKeyObservation(object: key.suite, key: key.name) { _ in + continuation.yield() + } + + // Ensure we only trigger a single initial event. + observation.start(options: initial && index == 0 ? [.initial] : []) + + return observation + } + + continuation.onTermination = { _ in + for observation in observations { + observation.invalidate() + } + } + } + } +} diff --git a/Sources/Defaults/Documentation.docc/Documentation.md b/Sources/Defaults/Documentation.docc/Documentation.md index 8f8d802..8a3bf3d 100644 --- a/Sources/Defaults/Documentation.docc/Documentation.md +++ b/Sources/Defaults/Documentation.docc/Documentation.md @@ -40,6 +40,8 @@ Defaults[.quality] = 0.5 ### Methods +- ``Defaults/updates(_:initial:)-9eh8`` +- ``Defaults/updates(_:initial:)-1mqkb`` - ``Defaults/reset(_:)-7jv5v`` - ``Defaults/reset(_:)-7es1e`` - ``Defaults/removeAll(suite:)`` @@ -49,11 +51,6 @@ Defaults[.quality] = 0.5 - ``Default`` - ``Defaults/Toggle`` -### Events - -- ``Defaults/publisher(_:options:)`` -- ``Defaults/publisher(keys:options:)`` - ### Force Type Resolution - ``Defaults/PreferRawRepresentable`` diff --git a/Sources/Defaults/Observation+Combine.swift b/Sources/Defaults/Observation+Combine.swift index dcb5885..247b5cc 100644 --- a/Sources/Defaults/Observation+Combine.swift +++ b/Sources/Defaults/Observation+Combine.swift @@ -1,4 +1,3 @@ -#if canImport(Combine) import Foundation import Combine @@ -84,6 +83,8 @@ extension Defaults { //=> false } ``` + + - Warning: This method exists for backwards compatibility and will be deprecated sometime in the future. Use ``Defaults/updates(_:initial:)-9eh8`` instead. */ public static func publisher( _ key: Key, @@ -97,6 +98,8 @@ extension Defaults { /** Publisher for multiple `Key` observation, but without specific information about changes. + + - Warning: This method exists for backwards compatibility and will be deprecated sometime in the future. Use ``Defaults/updates(_:initial:)-9eh8`` instead. */ public static func publisher( keys: _AnyKey..., @@ -118,4 +121,3 @@ extension Defaults { return combinedPublisher } } -#endif diff --git a/Sources/Defaults/Observation.swift b/Sources/Defaults/Observation.swift index c3a7db6..7539fe0 100644 --- a/Sources/Defaults/Observation.swift +++ b/Sources/Defaults/Observation.swift @@ -140,7 +140,7 @@ extension Defaults { object?.addObserver(self, forKeyPath: key, options: options.toNSKeyValueObservingOptions, context: nil) } - public func invalidate() { + func invalidate() { object?.removeObserver(self, forKeyPath: key, context: nil) object = nil lifetimeAssociation?.cancel() @@ -148,7 +148,7 @@ extension Defaults { private var lifetimeAssociation: LifetimeAssociation? - public func tieToLifetime(of weaklyHeldObject: AnyObject) -> Self { + func tieToLifetime(of weaklyHeldObject: AnyObject) -> Self { // swiftlint:disable:next trailing_closure lifetimeAssociation = LifetimeAssociation(of: self, with: weaklyHeldObject, deinitHandler: { [weak self] in self?.invalidate() @@ -157,7 +157,7 @@ extension Defaults { return self } - public func removeLifetimeTie() { + func removeLifetimeTie() { lifetimeAssociation?.cancel() } @@ -216,7 +216,7 @@ extension Defaults { invalidate() } - public func start(options: ObservationOptions) { + func start(options: ObservationOptions) { for observable in observables { observable.suite?.addObserver( self, @@ -227,7 +227,7 @@ extension Defaults { } } - public func invalidate() { + func invalidate() { for observable in observables { observable.suite?.removeObserver(self, forKeyPath: observable.key, context: &Self.observationContext) observable.suite = nil @@ -236,7 +236,7 @@ extension Defaults { lifetimeAssociation?.cancel() } - public func tieToLifetime(of weaklyHeldObject: AnyObject) -> Self { + func tieToLifetime(of weaklyHeldObject: AnyObject) -> Self { // swiftlint:disable:next trailing_closure lifetimeAssociation = LifetimeAssociation(of: self, with: weaklyHeldObject, deinitHandler: { [weak self] in self?.invalidate() @@ -245,7 +245,7 @@ extension Defaults { return self } - public func removeLifetimeTie() { + func removeLifetimeTie() { lifetimeAssociation?.cancel() } @@ -293,6 +293,8 @@ extension Defaults { //=> false } ``` + + - Warning: This method exists for backwards compatibility and will be deprecated sometime in the future. Use ``Defaults/updates(_:initial:)-9eh8`` instead. */ public static func observe( _ key: Key, @@ -322,6 +324,8 @@ extension Defaults { // … } ``` + + - Warning: This method exists for backwards compatibility and will be deprecated sometime in the future. Use ``Defaults/updates(_:initial:)-9eh8`` instead. */ public static func observe( keys: _AnyKey..., diff --git a/Sources/Defaults/SwiftUI.swift b/Sources/Defaults/SwiftUI.swift index 662f3fc..c6c8fb1 100644 --- a/Sources/Defaults/SwiftUI.swift +++ b/Sources/Defaults/SwiftUI.swift @@ -25,7 +25,7 @@ extension Defaults { if #available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) { // The `@MainActor` is important as the `.send()` method doesn't inherit the `@MainActor` from the class. self.task = .detached(priority: .userInitiated) { @MainActor [weak self] in - for await _ in Defaults.events(key) { + for await _ in Defaults.updates(key) { guard let self else { return } @@ -230,28 +230,6 @@ extension Defaults.Toggle { } } -extension Defaults { - // TODO: Expose this publicly at some point. - private static func events( - _ key: Defaults.Key, - initial: Bool = true - ) -> AsyncStream { // TODO: Make this `some AsyncSequence` when Swift 6 is out. - .init { continuation in - let observation = UserDefaultsKeyObservation(object: key.suite, key: key.name) { change in - // TODO: Use the `.deserialize` method directly. - let value = KeyChange(change: change, defaultValue: key.defaultValue).newValue - continuation.yield(value) - } - - observation.start(options: initial ? [.initial] : []) - - continuation.onTermination = { _ in - observation.invalidate() - } - } - } -} - @propertyWrapper private struct ViewStorage: DynamicProperty { private final class ValueBox { diff --git a/Sources/Defaults/Utilities.swift b/Sources/Defaults/Utilities.swift index bfc6536..6d6c86d 100644 --- a/Sources/Defaults/Utilities.swift +++ b/Sources/Defaults/Utilities.swift @@ -146,6 +146,7 @@ extension Sequence { } } + extension Collection { subscript(safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil @@ -153,6 +154,13 @@ extension Collection { } +extension Collection { + func indexed() -> some Sequence<(Index, Element)> { + zip(indices, self) + } +} + + extension Defaults.Serializable { /** Cast a `Serializable` value to `Self`. diff --git a/Tests/DefaultsTests/DefaultsCustomBridgeTests.swift b/Tests/DefaultsTests/DefaultsCustomBridgeTests.swift index 6cbb26b..29bc395 100644 --- a/Tests/DefaultsTests/DefaultsCustomBridgeTests.swift +++ b/Tests/DefaultsTests/DefaultsCustomBridgeTests.swift @@ -48,7 +48,7 @@ extension PlainHourMinuteTimeRange: Defaults.Serializable { typealias Value = PlainHourMinuteTimeRange typealias Serializable = [PlainHourMinuteTime] - public func serialize(_ value: Value?) -> Serializable? { + func serialize(_ value: Value?) -> Serializable? { guard let value = value else { return nil } @@ -56,7 +56,7 @@ extension PlainHourMinuteTimeRange: Defaults.Serializable { return [value.start, value.end] } - public func deserialize(_ object: Serializable?) -> Value? { + func deserialize(_ object: Serializable?) -> Value? { guard let array = object, let start = array[safe: 0], diff --git a/Tests/DefaultsTests/DefaultsSetAlgebraTests.swift b/Tests/DefaultsTests/DefaultsSetAlgebraTests.swift index 526ce6d..b7b50aa 100644 --- a/Tests/DefaultsTests/DefaultsSetAlgebraTests.swift +++ b/Tests/DefaultsTests/DefaultsSetAlgebraTests.swift @@ -19,18 +19,18 @@ struct DefaultsSetAlgebra: SetAlgebra store.contains(member) } - func union(_ other: DefaultsSetAlgebra) -> DefaultsSetAlgebra { - DefaultsSetAlgebra(store.union(other.store)) + func union(_ other: Self) -> Self { + Self(store.union(other.store)) } - func intersection(_ other: DefaultsSetAlgebra) -> DefaultsSetAlgebra { - var defaultsSetAlgebra = DefaultsSetAlgebra() + func intersection(_ other: Self) -> Self { + var defaultsSetAlgebra = Self() defaultsSetAlgebra.store = store.intersection(other.store) return defaultsSetAlgebra } - func symmetricDifference(_ other: DefaultsSetAlgebra) -> DefaultsSetAlgebra { - var defaultedSetAlgebra = DefaultsSetAlgebra() + func symmetricDifference(_ other: Self) -> Self { + var defaultedSetAlgebra = Self() defaultedSetAlgebra.store = store.symmetricDifference(other.store) return defaultedSetAlgebra } diff --git a/Tests/DefaultsTests/DefaultsTests.swift b/Tests/DefaultsTests/DefaultsTests.swift index 611ae62..49c4d41 100644 --- a/Tests/DefaultsTests/DefaultsTests.swift +++ b/Tests/DefaultsTests/DefaultsTests.swift @@ -731,4 +731,64 @@ final class DefaultsTests: XCTestCase { func testKeyHashable() { _ = Set([Defaults.Key("hashableKeyTest", default: false)]) } + + func testUpdates() async { + let key = Defaults.Key("updatesKey", default: false) + + async let waiter = Defaults.updates(key, initial: false).first { $0 } + + try? await Task.sleep(seconds: 0.1) + + Defaults[key] = true + + guard let result = await waiter else { + XCTFail() + return + } + + XCTAssertTrue(result) + } + + func testUpdatesMultipleKeys() async { + let key1 = Defaults.Key("updatesMultipleKey1", default: false) + let key2 = Defaults.Key("updatesMultipleKey2", default: false) + let counter = Counter() + + async let waiter: Void = { + for await _ in Defaults.updates([key1, key2], initial: false) { + await counter.increment() + + if await counter.count == 2 { + break + } + } + }() + + try? await Task.sleep(seconds: 0.1) + + Defaults[key1] = true + Defaults[key2] = true + + await waiter + + let count = await counter.count + XCTAssertEqual(count, 2) + } +} + +actor Counter { + private var _count = 0 + + var count: Int { _count } + + func increment() { + _count += 1 + } +} + +// TODO: Remove when testing on macOS 13. +extension Task { + static func sleep(seconds: TimeInterval) async throws { + try await sleep(nanoseconds: UInt64(seconds * Double(NSEC_PER_SEC))) + } } diff --git a/readme.md b/readme.md index 4c25b5b..3f6d5a1 100644 --- a/readme.md +++ b/readme.md @@ -6,17 +6,14 @@ Store key-value pairs persistently across launches of your app. It uses `UserDefaults` underneath but exposes a type-safe facade with lots of nice conveniences. -It's used in production by apps like [Gifski](https://github.com/sindresorhus/Gifski), [Dato](https://sindresorhus.com/dato), [Lungo](https://sindresorhus.com/lungo), [Battery Indicator](https://sindresorhus.com/battery-indicator), and [HEIC Converter](https://sindresorhus.com/heic-converter). - -For a real-world example, see the [Plash app](https://github.com/sindresorhus/Plash/blob/533dbc888d8ba3bd9581e60320af282a22c53f85/Plash/Constants.swift#L9-L18). +It's used in production by [all my apps](https://sindresorhus.com/apps) (1 million+ users). ## Highlights - **Strongly typed:** You declare the type and default value upfront. +- **SwiftUI:** Property wrapper that updates the view when the `UserDefaults` value changes. - **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. -- **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. - **Customizable:** You can serialize and deserialize your own type in your own way. @@ -26,7 +23,7 @@ For a real-world example, see the [Plash app](https://github.com/sindresorhus/Pl - You define strongly-typed identifiers in a single place and can use them everywhere. - You also define the default values in a single place instead of having to remember what default value you used in other places. - You can use it outside of SwiftUI. -- Comes with Combine publisher. +- You can observe value updates. - Supports many more types, even `Codable`. - Easy to add support for your own custom types. - Comes with a convenience SwiftUI `Toggle` component. @@ -38,15 +35,11 @@ For a real-world example, see the [Plash app](https://github.com/sindresorhus/Pl - tvOS 13+ - watchOS 6+ -## Migration Guides - -#### [From v4 to v5](./migration.md) - ## Install Add `https://github.com/sindresorhus/Defaults` in the [“Swift Package Manager” tab in Xcode](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app). -**Requires Xcode 14 or later** +**Requires Xcode 14.1 or later** ## Support types @@ -247,71 +240,17 @@ extension Defaults.Keys { static let isUnicornMode = Key("isUnicornMode", default: false) } -let observer = Defaults.observe(.isUnicornMode) { change in - // Initial event - print(change.oldValue) - //=> false - print(change.newValue) - //=> false +// … - // First actual event - print(change.oldValue) - //=> false - print(change.newValue) - //=> true +Task { + for await value in Defaults.updates(.isUnicornMode) { + print("Value:", value) + } } - -Defaults[.isUnicornMode] = true ``` In contrast to the native `UserDefaults` key observation, here you receive a strongly-typed change object. -There is also an observation API using the [Combine](https://developer.apple.com/documentation/combine) framework, exposing a [Publisher](https://developer.apple.com/documentation/combine/publisher) for key changes: - -```swift -let publisher = Defaults.publisher(.isUnicornMode) - -let cancellable = publisher.sink { change in - // Initial event - print(change.oldValue) - //=> false - print(change.newValue) - //=> false - - // First actual event - print(change.oldValue) - //=> false - print(change.newValue) - //=> true -} - -Defaults[.isUnicornMode] = true - -// To invalidate the observation. -cancellable.cancel() -``` - -### Invalidate observations automatically - -```swift -extension Defaults.Keys { - static let isUnicornMode = Key("isUnicornMode", default: false) -} - -final class Foo { - init() { - Defaults.observe(.isUnicornMode) { change in - print(change.oldValue) - print(change.newValue) - }.tieToLifetime(of: self) - } -} - -Defaults[.isUnicornMode] = true -``` - -The observation will be valid until `self` is deinitialized. - ### Reset keys to their default values ```swift @@ -480,49 +419,6 @@ Reset the given keys back to their default values. You can also specify string keys, which can be useful if you need to store some keys in a collection, as it's not possible to store `Defaults.Key` in a collection because it's generic. -#### `Defaults.observe` - -```swift -Defaults.observe( - _ key: Defaults.Key, - options: ObservationOptions = [.initial], - handler: @escaping (KeyChange) -> Void -) -> Defaults.Observation -``` - -Type: `func` - -Observe changes to a key or an optional key. - -By default, it will also trigger an initial event on creation. This can be useful for setting default values on controls. You can override this behavior with the `options` argument. - -#### `Defaults.observe(keys: keys..., options:)` - -Type: `func` - -Observe multiple keys of any type, but without any information about the changes. - -Options are the same as in `.observe(…)` for a single key. - -#### `Defaults.publisher(_ key:, options:)` - -```swift -Defaults.publisher( - _ key: Defaults.Key, - options: ObservationOptions = [.initial] -) -> AnyPublisher, Never> -``` - -Type: `func` - -Observation API using [Publisher](https://developer.apple.com/documentation/combine/publisher) from the [Combine](https://developer.apple.com/documentation/combine) framework. - -#### `Defaults.publisher(keys: keys…, options:)` - -Type: `func` - -[Combine](https://developer.apple.com/documentation/combine) observation API for multiple key observation, but without specific information about changes. - #### `Defaults.removeAll` ```swift @@ -533,47 +429,6 @@ Type: `func` Remove all entries from the given `UserDefaults` suite. -### `Defaults.Observation` - -Type: `protocol` - -Represents an observation of a defaults key. - -#### `Defaults.Observation#invalidate` - -```swift -Defaults.Observation#invalidate() -``` - -Type: `func` - -Invalidate the observation. - -#### `Defaults.Observation#tieToLifetime` - -```swift -@discardableResult -Defaults.Observation#tieToLifetime(of weaklyHeldObject: AnyObject) -> Self -``` - -Type: `func` - -Keep the observation alive for as long as, and no longer than, another object exists. - -When `weaklyHeldObject` is deinitialized, the observation is invalidated automatically. - -#### `Defaults.Observation.removeLifetimeTie` - -```swift -Defaults.Observation#removeLifetimeTie() -``` - -Type: `func` - -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. - #### `Defaults.withoutPropagation(_ closure:)` Execute the closure without triggering change events. @@ -948,15 +803,16 @@ It's inspired by that package and other solutions. The main difference is that t ## Maintainers - [Sindre Sorhus](https://github.com/sindresorhus) -- [Kacper Rączy](https://github.com/fredyshox) - [@hank121314](https://github.com/hank121314) +**Former** + +- [Kacper Rączy](https://github.com/fredyshox) + ## Related -- [Preferences](https://github.com/sindresorhus/Preferences) - Add a preferences window to your macOS app - [KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts) - Add user-customizable global keyboard shortcuts to your macOS app - [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Add "Launch at Login" functionality to your macOS app -- [Regex](https://github.com/sindresorhus/Regex) - Swifty regular expressions - [DockProgress](https://github.com/sindresorhus/DockProgress) - Show progress in your app's Dock icon - [Gifski](https://github.com/sindresorhus/Gifski) - Convert videos to high-quality GIFs on your Mac - [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift)