Skip to content

Commit 8c3843d

Browse files
authored
Send on projectedValue publisher for any change (#61)
Previously, only when using the property wrapper's setter would the projected publisher send a new value. This ignored changes from: - Other instances of the property wrapper. - Other libraries that persist to UserDefaults. - Direct callers of UserDefaults. - Other processes. Now the publisher sends the new value no matter where the change occurs. Assert observe key path will actually send changes. UserDefaults will silently ignore observation requests for keys that start with `@`. Keys that contain a `.` will be treated as key paths and cannot be directly observed using KVO.
1 parent b7ef491 commit 8c3843d

6 files changed

+95
-3
lines changed

CHANGELOG.md

+16
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ NEXT
77

88
- TBA
99

10+
4.0.0
11+
-----
12+
13+
### New
14+
15+
- The publisher projected by the property wrapper now sends values when user defaults changes from anywhere. Previously, only when using the property wrapper's setter would the projected publisher send a new value. ([#61](https://github.com/jessesquires/Foil/pull/61), [@nolanw](https://github.com/nolanw))
16+
17+
### Breaking
18+
19+
- Due to [#61](https://github.com/jessesquires/Foil/pull/61) (see above), there are some (potentially) breaking changes with key names. If any of your keys are named like the following examples and you need to observe changes, you will need to migrate your key names.
20+
- Key names starting with an `@` character **do not** notify observers on updates.
21+
- Example: `@my-key-name`
22+
- Key names containing a `.` character _anywhere_ in the name **do not** notify observers on updates. (This is a side-effect of `KeyPaths` which include periods.)
23+
- Example: `com.myApp.my-key-name`
24+
25+
1026
3.0.0
1127
-----
1228

Foil.xcodeproj/project.pbxproj

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
0BF54A9D25BF58B1008484F8 /* UserDefaults+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF54A9C25BF58B1008484F8 /* UserDefaults+Extensions.swift */; };
1818
0BF5FD9025C14A7D0003B078 /* WrappedDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF5FD8F25C14A7D0003B078 /* WrappedDefault.swift */; };
1919
0BF5FD9625C14A960003B078 /* UserDefaultsSerializable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF5FD9525C14A960003B078 /* UserDefaultsSerializable.swift */; };
20+
1CC00F2428DAB1FC00EC2C63 /* ObserverTrampoline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC00F2328DAB1FC00EC2C63 /* ObserverTrampoline.swift */; };
2021
/* End PBXBuildFile section */
2122

2223
/* Begin PBXContainerItemProxy section */
@@ -41,6 +42,7 @@
4142
0BF54A9C25BF58B1008484F8 /* UserDefaults+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Extensions.swift"; sourceTree = "<group>"; };
4243
0BF5FD8F25C14A7D0003B078 /* WrappedDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedDefault.swift; sourceTree = "<group>"; };
4344
0BF5FD9525C14A960003B078 /* UserDefaultsSerializable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsSerializable.swift; sourceTree = "<group>"; };
45+
1CC00F2328DAB1FC00EC2C63 /* ObserverTrampoline.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObserverTrampoline.swift; sourceTree = "<group>"; };
4446
/* End PBXFileReference section */
4547

4648
/* Begin PBXFrameworksBuildPhase section */
@@ -83,6 +85,7 @@
8385
0BF54A8225BF589E008484F8 /* Sources */ = {
8486
isa = PBXGroup;
8587
children = (
88+
1CC00F2328DAB1FC00EC2C63 /* ObserverTrampoline.swift */,
8689
0BF54A9C25BF58B1008484F8 /* UserDefaults+Extensions.swift */,
8790
0BF5FD9525C14A960003B078 /* UserDefaultsSerializable.swift */,
8891
0BF5FD8F25C14A7D0003B078 /* WrappedDefault.swift */,
@@ -236,6 +239,7 @@
236239
buildActionMask = 2147483647;
237240
files = (
238241
0BF5FD9625C14A960003B078 /* UserDefaultsSerializable.swift in Sources */,
242+
1CC00F2428DAB1FC00EC2C63 /* ObserverTrampoline.swift in Sources */,
239243
0BEEA2B22603F8390035387F /* WrappedDefaultOptional.swift in Sources */,
240244
0BF5FD9025C14A7D0003B078 /* WrappedDefault.swift in Sources */,
241245
0BF54A9D25BF58B1008484F8 /* UserDefaults+Extensions.swift in Sources */,

Sources/ObserverTrampoline.swift

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// Created by Jesse Squires
3+
// https://www.jessesquires.com
4+
//
5+
// Documentation
6+
// https://jessesquires.github.io/Foil
7+
//
8+
// GitHub
9+
// https://github.com/jessesquires/Foil
10+
//
11+
// Copyright © 2021-present Jesse Squires
12+
//
13+
14+
import Foundation
15+
16+
/// Watches for changes to UserDefaults using old-school Key-Value Observing.
17+
///
18+
/// KVO allows us to be notified of changes from anywhere (including other processes), not just via the property wrapper's setter.
19+
///
20+
/// We can't use Swift's block-based KVO because that requires a KeyPath, which we cannot create from a String.
21+
final class ObserverTrampoline: NSObject {
22+
private let userDefaults: UserDefaults
23+
private let key: String
24+
private let block: () -> Void
25+
26+
init(userDefaults: UserDefaults, key: String, block: @escaping () -> Void) {
27+
assert(!key.hasPrefix("@"), "Cannot observe a user default key starting with '@'")
28+
assert(!key.contains("."), "Cannot observe a user default key containing '.'")
29+
self.userDefaults = userDefaults
30+
self.key = key
31+
self.block = block
32+
super.init()
33+
34+
userDefaults.addObserver(self, forKeyPath: key, context: nil)
35+
}
36+
37+
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
38+
block()
39+
}
40+
41+
deinit {
42+
userDefaults.removeObserver(self, forKeyPath: key, context: nil)
43+
}
44+
}

Sources/WrappedDefault.swift

+5-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import Foundation
2020
public struct WrappedDefault<T: UserDefaultsSerializable> {
2121
private let _userDefaults: UserDefaults
2222
private let _publisher: CurrentValueSubject<T, Never>
23+
private let _observer: ObserverTrampoline
2324

2425
/// The key for the value in `UserDefaults`.
2526
public let key: String
@@ -31,7 +32,6 @@ public struct WrappedDefault<T: UserDefaultsSerializable> {
3132
}
3233
set {
3334
self._userDefaults.save(newValue, for: self.key)
34-
self._publisher.send(newValue)
3535
}
3636
}
3737

@@ -54,5 +54,9 @@ public struct WrappedDefault<T: UserDefaultsSerializable> {
5454
// because `fetch` assumes that `registerDefault` has been called before
5555
// and uses force unwrap
5656
self._publisher = CurrentValueSubject<T, Never>(userDefaults.fetch(keyName))
57+
58+
self._observer = ObserverTrampoline(userDefaults: userDefaults, key: keyName) { [_publisher] in
59+
_publisher.send(userDefaults.fetch(keyName))
60+
}
5761
}
5862
}

Sources/WrappedDefaultOptional.swift

+4-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import Foundation
2020
public struct WrappedDefaultOptional<T: UserDefaultsSerializable> {
2121
private let _userDefaults: UserDefaults
2222
private let _publisher: CurrentValueSubject<T?, Never>
23+
private let _observer: ObserverTrampoline
2324

2425
/// The key for the value in `UserDefaults`.
2526
public let key: String
@@ -32,10 +33,8 @@ public struct WrappedDefaultOptional<T: UserDefaultsSerializable> {
3233
set {
3334
if let newValue {
3435
self._userDefaults.save(newValue, for: self.key)
35-
self._publisher.send(newValue)
3636
} else {
3737
self._userDefaults.delete(for: self.key)
38-
self._publisher.send(nil)
3938
}
4039
}
4140
}
@@ -53,5 +52,8 @@ public struct WrappedDefaultOptional<T: UserDefaultsSerializable> {
5352
self.key = keyName
5453
self._userDefaults = userDefaults
5554
self._publisher = CurrentValueSubject<T?, Never>(userDefaults.fetchOptional(keyName))
55+
self._observer = ObserverTrampoline(userDefaults: userDefaults, key: keyName) { [_publisher] in
56+
_publisher.send(userDefaults.fetchOptional(keyName))
57+
}
5658
}
5759
}

Tests/ObservationTests.swift

+22
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
// Copyright © 2021-present Jesse Squires
1212
//
1313

14+
@testable import Foil
1415
import Combine
1516
import XCTest
1617

@@ -53,6 +54,27 @@ final class ObservationTests: XCTestCase {
5354
XCTAssertEqual(self.settings.average, publishedValue)
5455
}
5556

57+
func test_Integration_ProjectedValue_ExternalChange() {
58+
let expectation = self.expectation(description: #function)
59+
let expectedValue = 1_000.0
60+
var publishedValue: Double?
61+
62+
self.settings.$average
63+
.sink { newValue in
64+
publishedValue = newValue
65+
66+
if newValue == expectedValue {
67+
expectation.fulfill()
68+
}
69+
}
70+
.store(in: &self.cancellable)
71+
72+
type(of: self.settings).store.set(expectedValue, forKey: "average")
73+
self.wait(for: [expectation], timeout: timeout)
74+
75+
XCTAssertEqual(self.settings.average, publishedValue)
76+
}
77+
5678
func test_Integration_Publisher() {
5779
let expectation = self.expectation(description: #function)
5880
var publishedValue: String?

0 commit comments

Comments
 (0)