Skip to content

Commit b77cea5

Browse files
committed
Add Combine observation publishers
1 parent 882b81c commit b77cea5

7 files changed

Lines changed: 1222 additions & 165 deletions

File tree

README.md

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ PersistentKeyValueKit is backed by a robust test suite.
3333
- [x] Persistence for any type that conforms to `KeyValuePersistible`.
3434
- [x] Universal interface for `UserDefaults` and `NSUbiquitousKeyValueStore`.
3535
- [x] Type-safe property wrapper and view modifier for SwiftUI.
36-
- [x] AsyncSequence for observing key changes in any context.
36+
- [x] AsyncSequence and Combine publishers for observing key changes in any context.
3737
- [x] Built-in support for all primitive (i.e. property list) types.
3838
- [x] Built-in representations for all common ways to persist values.
3939
- [x] Keys that are only mutable in Debug builds.
@@ -126,6 +126,15 @@ for await runtimeColorScheme in userDefaults.values(for: .runtimeColorScheme) {
126126
}
127127
```
128128

129+
Observe it from Combine.
130+
131+
```swift
132+
let cancellable = userDefaults.publisher(for: .runtimeColorScheme)
133+
.sink { runtimeColorScheme in
134+
apply(runtimeColorScheme)
135+
}
136+
```
137+
129138
## Usage
130139

131140
### Keys
@@ -384,9 +393,12 @@ extension PersistentKeyProtocol where Self == PersistentKey<Date?> {
384393
}
385394
```
386395

387-
### SwiftUI
396+
### Observation
397+
398+
PersistentKeyValueKit exposes store observation in three forms: a SwiftUI property wrapper, an `AsyncSequence`, and a
399+
Combine publisher. Pick the one that matches the code that owns cancellation.
388400

389-
#### Property Wrapper
401+
#### SwiftUI State
390402

391403
`PersistentValue` is a property wrapper that provides a type-safe way to access and modify values from `UserDefaults` or
392404
`NSUbiquitousKeyValueStore` in SwiftUI views. It supports automatic observation and updates whenever the value changes
@@ -407,10 +419,10 @@ var isAppStoreRatingEnabled: Bool
407419
var isAppStoreRatingEnabled: Bool
408420
```
409421

410-
##### View Modifier
422+
##### Default Store
411423

412-
A view modifier is provided to set the default store used by any `@PersistentValue` property wrapper in the view (or
413-
its descendants). The default store can be overridden by supplying one directly in the `@PersistentValue` declaration.
424+
Use `.defaultPersistentKeyValueStore(_:)` to set the default store for any `@PersistentValue` property wrapper in the
425+
view or its descendants. Passing a store directly to `@PersistentValue` still takes precedence.
414426

415427
e.g.
416428

@@ -423,10 +435,9 @@ extension App: SwiftUI.App {
423435
}
424436
```
425437

426-
### `AsyncSequence`
438+
#### Async Tasks
427439

428-
`PersistentKeyValues` observes a persistent key as an `AsyncSequence`. Use it outside SwiftUI when you want key changes
429-
without writing KVO or `NotificationCenter` code.
440+
`PersistentKeyValues` is an `AsyncSequence` for one persistent key. Use it when an async task owns cancellation.
430441

431442
The same sequence is available from the store.
432443

@@ -468,6 +479,48 @@ for await username in UserDefaults.standard.changes(for: .username, bufferingPol
468479
Each iterator registers with the store. It deregisters when iteration ends, the iterator is released, or the task is
469480
cancelled. Iterate from a cancellable task and cancel it when the owner no longer needs updates.
470481

482+
#### Combine Pipelines
483+
484+
`PersistentKeyValuePublisher` is a Combine `Publisher` for one persistent key. Use it when a Combine subscription owns
485+
cancellation.
486+
487+
The same publisher is available from the store.
488+
489+
```swift
490+
let cancellable = UserDefaults.standard.publisher(for: .username)
491+
.sink { username in
492+
usernameLabel.text = username
493+
}
494+
```
495+
496+
It is also available from the key.
497+
498+
```swift
499+
let usernameKey: PersistentKey<String> = .username
500+
501+
let cancellable = usernameKey.publisher(in: UserDefaults.standard)
502+
.sink { username in
503+
usernameLabel.text = username
504+
}
505+
```
506+
507+
By default, `publisher(for:)` and `publisher(in:)` emit the current value when demand is requested, then later changes.
508+
509+
Use `changesPublisher(for:)` or `changesPublisher(in:)` to skip the current value and observe only later changes.
510+
511+
```swift
512+
let cancellable = UserDefaults.standard.changesPublisher(for: .username)
513+
.sink { username in
514+
handleChange(username: username)
515+
}
516+
```
517+
518+
When downstream demand is exhausted, later changes are coalesced. The next demand receives the latest current value
519+
instead of an unbounded backlog of intermediate values.
520+
521+
Each subscription registers with the store. It deregisters when the subscription is cancelled or released. Use Combine
522+
operators such as `receive(on:)` when a subscriber needs delivery on a specific scheduler.
523+
471524
### `UserDefaults` Registration
472525

473526
PersistentKeyValueKit supports traditional `UserDefaults` registration. The default value of the key will be registered
@@ -563,12 +616,11 @@ affordances for property list safety or proxy representations—but it is availa
563616

564617
### Limited `NSUbiquitousKeyValueStore` Observability
565618

566-
There is no platform support for observing changes to keys in `NSUbiquitousKeyValueStore`. The only affordance is
567-
listening for external changes from other devices. PersistentKeyValueKit implements observability for all mutations
568-
made through the framework: any `@PersistentValue` or `AsyncSequence` using `NSUbiquitousKeyValueStore` will
569-
automatically update with any changes made by PersistentKeyValueKit anywhere, on any device. However, any changes to
570-
`NSUbiquitousKeyValueStore` made outside of the framework will not be automatically reflected in `@PersistentValue`
571-
properties or `AsyncSequence` iterations.
619+
There is no platform support for observing individual keys in `NSUbiquitousKeyValueStore`. The only affordance is
620+
listening for external changes from other devices. PersistentKeyValueKit observes mutations made through the package:
621+
`@PersistentValue`, `PersistentKeyValues`, and `PersistentKeyValuePublisher` receive changes made by
622+
PersistentKeyValueKit on any device. They do not receive mutations written directly to `NSUbiquitousKeyValueStore`
623+
outside this package.
572624

573625
## Contributions
574626

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
//
2+
// PersistentKeyProtocol+PersistentKeyValuePublisher.swift
3+
// PersistentKeyValueKit
4+
//
5+
// Created by Kyle Hughes on 5/2/26.
6+
//
7+
8+
import Combine
9+
10+
extension PersistentKeyProtocol {
11+
// MARK: Public Instance Interface
12+
13+
/// Returns a Combine `Publisher` that produces subsequent values for this key as changes occur in the store.
14+
///
15+
/// The publisher does not emit the current value when demand is first requested. Use
16+
/// ``publisher(in:emitsInitialValue:)`` when the subscriber also needs the current value.
17+
///
18+
/// e.g.
19+
///
20+
/// ```swift
21+
/// let cancellable = PersistentKey.username.changesPublisher(in: UserDefaults.standard)
22+
/// .sink { username in
23+
/// handleChange(username: username)
24+
/// }
25+
/// ```
26+
///
27+
/// - Parameter store: The ``PersistentKeyValueStore`` to observe.
28+
/// - Returns: A ``PersistentKeyValuePublisher`` for this key that skips its initial value.
29+
@inlinable
30+
public func changesPublisher(
31+
in store: any PersistentKeyValueStore
32+
) -> PersistentKeyValuePublisher<Self> {
33+
publisher(
34+
in: store,
35+
emitsInitialValue: false
36+
)
37+
}
38+
39+
/// Returns a Combine `Publisher` that produces values for this key as changes occur in the store.
40+
///
41+
/// By default, each subscription emits the current value when demand is first requested, followed by subsequent
42+
/// changes. If downstream demand is exhausted, later changes are coalesced and the latest current value is emitted
43+
/// when more demand is requested.
44+
///
45+
/// e.g.
46+
///
47+
/// ```swift
48+
/// let cancellable = PersistentKey.username.publisher(in: UserDefaults.standard)
49+
/// .sink { username in
50+
/// usernameLabel.text = username
51+
/// }
52+
/// ```
53+
///
54+
/// - Parameter store: The ``PersistentKeyValueStore`` to observe.
55+
/// - Parameter emitsInitialValue: Whether each subscription emits the current value first. Defaults to `true`.
56+
/// - Returns: A ``PersistentKeyValuePublisher`` for this key.
57+
@inlinable
58+
public func publisher(
59+
in store: any PersistentKeyValueStore,
60+
emitsInitialValue: Bool = true
61+
) -> PersistentKeyValuePublisher<Self> {
62+
PersistentKeyValuePublisher(
63+
self,
64+
store: store,
65+
emitsInitialValue: emitsInitialValue
66+
)
67+
}
68+
}

0 commit comments

Comments
 (0)