Add `.updates()` method

Fixes #77
This commit is contained in:
Sindre Sorhus 2022-11-18 19:22:02 +07:00
parent ea11b7ac4f
commit 7a22d37874
10 changed files with 191 additions and 206 deletions

View File

@ -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<Value: Serializable>: _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<Bool>("isUnicornMode", default: false)
}
//
Task {
for await value in Defaults.updates(.isUnicornMode) {
print("Value:", value)
}
}
```
*/
public static func updates<Value: Serializable>(
_ key: Key<Value>,
initial: Bool = true
) -> AsyncStream<Value> { // TODO: Make this `some AsyncSequence<Value>` 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<Void> { // TODO: Make this `some AsyncSequence<Value>` 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()
}
}
}
}
}

View File

@ -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``

View File

@ -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<Value: Serializable>(
_ key: Key<Value>,
@ -97,6 +98,8 @@ extension Defaults {
/**
Publisher for multiple `Key<T>` 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

View File

@ -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<Value: Serializable>(
_ key: Key<Value>,
@ -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...,

View File

@ -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<Value: Serializable>(
_ key: Defaults.Key<Value>,
initial: Bool = true
) -> AsyncStream<Value> { // TODO: Make this `some AsyncSequence<Value>` 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<Value>: DynamicProperty {
private final class ValueBox {

View File

@ -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`.

View File

@ -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],

View File

@ -19,18 +19,18 @@ struct DefaultsSetAlgebra<Element: Defaults.Serializable & Hashable>: 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
}

View File

@ -731,4 +731,64 @@ final class DefaultsTests: XCTestCase {
func testKeyHashable() {
_ = Set([Defaults.Key<Bool>("hashableKeyTest", default: false)])
}
func testUpdates() async {
let key = Defaults.Key<Bool>("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<Bool>("updatesMultipleKey1", default: false)
let key2 = Defaults.Key<Bool>("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<Never, Never> {
static func sleep(seconds: TimeInterval) async throws {
try await sleep(nanoseconds: UInt64(seconds * Double(NSEC_PER_SEC)))
}
}

170
readme.md
View File

@ -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<Bool>("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<Bool>("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<T: Codable>(
_ key: Defaults.Key<T>,
options: ObservationOptions = [.initial],
handler: @escaping (KeyChange<T>) -> 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<T: Codable>(
_ key: Defaults.Key<T>,
options: ObservationOptions = [.initial]
) -> AnyPublisher<KeyChange<T>, 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)