From 6029ac796b7bc75edf54acc7adf42e169eadbe9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20R=C4=85czy?= Date: Mon, 20 Jan 2020 17:41:13 +0100 Subject: [PATCH] Combine support (#31) Co-authored-by: Sindre Sorhus --- Defaults.xcodeproj/project.pbxproj | 10 + Sources/Defaults/Observation+Combine.swift | 245 +++++++++++++++++++++ Sources/Defaults/Observation.swift | 30 +-- Tests/DefaultsTests/DefaultsTests.swift | 219 ++++++++++++++++++ readme.md | 114 +++++++++- 5 files changed, 601 insertions(+), 17 deletions(-) create mode 100644 Sources/Defaults/Observation+Combine.swift diff --git a/Defaults.xcodeproj/project.pbxproj b/Defaults.xcodeproj/project.pbxproj index 3433246..245f27f 100644 --- a/Defaults.xcodeproj/project.pbxproj +++ b/Defaults.xcodeproj/project.pbxproj @@ -17,6 +17,10 @@ 8933C7901EB5B82D000D00A4 /* DefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8933C7891EB5B82A000D00A4 /* DefaultsTests.swift */; }; DD7502881C68FEDE006590AF /* Defaults.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52D6DA0F1BF000BD002C0205 /* Defaults.framework */; }; DD7502921C690C7A006590AF /* Defaults.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52D6D9F01BEFFFBE002C0205 /* Defaults.framework */; }; + E286D0C723B8D51100570D1E /* Observation+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E286D0C623B8D51100570D1E /* Observation+Combine.swift */; }; + E286D0C823B8D54C00570D1E /* Observation+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E286D0C623B8D51100570D1E /* Observation+Combine.swift */; }; + E286D0C923B8D54D00570D1E /* Observation+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E286D0C623B8D51100570D1E /* Observation+Combine.swift */; }; + E286D0CA23B8D54E00570D1E /* Observation+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E286D0C623B8D51100570D1E /* Observation+Combine.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 */; }; @@ -64,6 +68,7 @@ AD2FAA281CD0B6E100659CF4 /* DefaultsTests.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = DefaultsTests.plist; sourceTree = ""; }; DD75027A1C68FCFC006590AF /* Defaults-macOS Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Defaults-macOS Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; DD75028D1C690C7A006590AF /* Defaults-tvOS Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Defaults-tvOS Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + E286D0C623B8D51100570D1E /* Observation+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Observation+Combine.swift"; sourceTree = ""; }; 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 */ @@ -197,6 +202,7 @@ children = ( 8933C7841EB5B820000D00A4 /* Defaults.swift */, E3EB3E34216507AE0033B089 /* Observation.swift */, + E286D0C623B8D51100570D1E /* Observation+Combine.swift */, E3EB3E32216505920033B089 /* util.swift */, ); path = Defaults; @@ -483,6 +489,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E286D0C823B8D54C00570D1E /* Observation+Combine.swift in Sources */, 8933C7851EB5B820000D00A4 /* Defaults.swift in Sources */, E3EB3E35216507AE0033B089 /* Observation.swift in Sources */, E3EB3E33216505920033B089 /* util.swift in Sources */, @@ -501,6 +508,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E286D0CA23B8D54E00570D1E /* Observation+Combine.swift in Sources */, E3EB3E3A216507C40033B089 /* util.swift in Sources */, E3EB3E37216507B50033B089 /* Observation.swift in Sources */, 8933C7871EB5B820000D00A4 /* Defaults.swift in Sources */, @@ -511,6 +519,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E286D0C923B8D54D00570D1E /* Observation+Combine.swift in Sources */, E3EB3E3B216507C40033B089 /* util.swift in Sources */, E3EB3E38216507B60033B089 /* Observation.swift in Sources */, 8933C7881EB5B820000D00A4 /* Defaults.swift in Sources */, @@ -521,6 +530,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E286D0C723B8D51100570D1E /* Observation+Combine.swift in Sources */, E3EB3E39216507C30033B089 /* util.swift in Sources */, E3EB3E36216507B50033B089 /* Observation.swift in Sources */, 8933C7861EB5B820000D00A4 /* Defaults.swift in Sources */, diff --git a/Sources/Defaults/Observation+Combine.swift b/Sources/Defaults/Observation+Combine.swift new file mode 100644 index 0000000..fceed78 --- /dev/null +++ b/Sources/Defaults/Observation+Combine.swift @@ -0,0 +1,245 @@ +import Foundation +import Combine + +extension Defaults { + /** + Custom `Subscription` for `UserDefaults` key observation. + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + final class DefaultsSubscription: Subscription where SubscriberType.Input == BaseChange { + private var subscriber: SubscriberType? + private var observation: UserDefaultsKeyObservation? + + init(subscriber: SubscriberType, suite: UserDefaults, key: String, options: NSKeyValueObservingOptions) { + self.subscriber = subscriber + self.observation = UserDefaultsKeyObservation( + object: suite, + key: key, + callback: observationCallback(_:) + ) + self.observation?.start(options: options) + } + + func request(_ demand: Subscribers.Demand) { + // Nothing as we send events only when they occur. + } + + func cancel() { + observation?.invalidate() + observation = nil + subscriber = nil + } + + private func observationCallback(_ change: BaseChange) { + _ = subscriber?.receive(change) + } + } + + /** + Custom Publisher, which is using DefaultsSubscription. + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + struct DefaultsPublisher: Publisher { + typealias Output = BaseChange + typealias Failure = Never + + private let suite: UserDefaults + private let key: String + private let options: NSKeyValueObservingOptions + + init(suite: UserDefaults, key: String, options: NSKeyValueObservingOptions) { + self.suite = suite + self.key = key + self.options = options + } + + func receive(subscriber: S) where S : Subscriber, DefaultsPublisher.Failure == S.Failure, DefaultsPublisher.Output == S.Input { + let subscription = DefaultsSubscription( + subscriber: subscriber, + suite: suite, + key: key, + options: options + ) + + subscriber.receive(subscription: subscription) + } + } + + /** + Returns a type-erased `Publisher` that publishes changes related to the given key. + + ``` + extension Defaults.Keys { + static let isUnicornMode = Key("isUnicornMode", default: false) + } + + let publisher = Defaults.publisher(.isUnicornMode).map { $0.newValue } + + let cancellable = publisher.sink { value in + print(value) + //=> false + } + ``` + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + public static func publisher( + _ key: Defaults.Key, + options: NSKeyValueObservingOptions = [.initial, .old, .new] + ) -> AnyPublisher, Never> { + let publisher = DefaultsPublisher(suite: key.suite, key: key.name, options: options) + .map { KeyChange(change: $0, defaultValue: key.defaultValue) } + + return AnyPublisher(publisher) + } + + /** + Returns a type-erased `Publisher` that publishes changes related to the given key. + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + public static func publisher( + _ key: Defaults.NSSecureCodingKey, + options: NSKeyValueObservingOptions = [.initial, .old, .new] + ) -> AnyPublisher, Never> { + let publisher = DefaultsPublisher(suite: key.suite, key: key.name, options: options) + .map { NSSecureCodingKeyChange(change: $0, defaultValue: key.defaultValue) } + + return AnyPublisher(publisher) + } + + /** + Returns a type-erased `Publisher` that publishes changes related to the given optional key. + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + public static func publisher( + _ key: Defaults.OptionalKey, + options: NSKeyValueObservingOptions = [.initial, .old, .new] + ) -> AnyPublisher, Never> { + let publisher = DefaultsPublisher(suite: key.suite, key: key.name, options: options) + .map { OptionalKeyChange(change: $0) } + + return AnyPublisher(publisher) + } + + /** + Returns a type-erased `Publisher` that publishes changes related to the given optional key. + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + public static func publisher( + _ key: Defaults.NSSecureCodingOptionalKey, + options: NSKeyValueObservingOptions = [.initial, .old, .new] + ) -> AnyPublisher, Never> { + let publisher = DefaultsPublisher(suite: key.suite, key: key.name, options: options) + .map { NSSecureCodingOptionalKeyChange(change: $0) } + + return AnyPublisher(publisher) + } + + /** + Publisher for multiple `Key` observation, but without specific information about changes. + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + public static func publisher( + keys: Defaults.Key..., + options: NSKeyValueObservingOptions = [.initial, .old, .new] + ) -> AnyPublisher { + let initial = Empty(completeImmediately: false).eraseToAnyPublisher() + + let combinedPublisher = + keys.map { key in + return Defaults.publisher(key, options: options) + .map { _ in () } + .eraseToAnyPublisher() + }.reduce(initial) { (combined, keyPublisher) in + combined.merge(with: keyPublisher).eraseToAnyPublisher() + } + + return combinedPublisher + } + + /** + Publisher for multiple `OptionalKey` observation, but without specific information about changes. + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + public static func publisher( + keys: Defaults.OptionalKey..., + options: NSKeyValueObservingOptions = [.initial, .old, .new] + ) -> AnyPublisher { + let initial = Empty(completeImmediately: false).eraseToAnyPublisher() + + let combinedPublisher = + keys.map { key in + return Defaults.publisher(key, options: options) + .map { _ in () } + .eraseToAnyPublisher() + }.reduce(initial) { (combined, keyPublisher) in + combined.merge(with: keyPublisher).eraseToAnyPublisher() + } + + return combinedPublisher + } + + /** + Publisher for multiple `NSSecureCodingKey` observation, but without specific information about changes. + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + public static func publisher( + keys: Defaults.NSSecureCodingKey..., + options: NSKeyValueObservingOptions = [.initial, .old, .new] + ) -> AnyPublisher { + let initial = Empty(completeImmediately: false).eraseToAnyPublisher() + + let combinedPublisher = + keys.map { key in + return Defaults.publisher(key, options: options) + .map { _ in () } + .eraseToAnyPublisher() + }.reduce(initial) { (combined, keyPublisher) in + combined.merge(with: keyPublisher).eraseToAnyPublisher() + } + + return combinedPublisher + } + + /** + Publisher for multiple `NSSecureCodingOptionalKey` observation, but without specific information about changes. + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + public static func publisher( + keys: Defaults.NSSecureCodingOptionalKey..., + options: NSKeyValueObservingOptions = [.initial, .old, .new] + ) -> AnyPublisher { + let initial = Empty(completeImmediately: false).eraseToAnyPublisher() + + let combinedPublisher = + keys.map { key in + return Defaults.publisher(key, options: options) + .map { _ in () } + .eraseToAnyPublisher() + }.reduce(initial) { (combined, keyPublisher) in + combined.merge(with: keyPublisher).eraseToAnyPublisher() + } + + return combinedPublisher + } + + /** + Convenience `Publisher` for all `UserDefaults` key change events. A wrapper around the `UserDefaults.didChangeNotification`. + + - Parameter initialEvent: Trigger an initial event immediately. + */ + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + public static func publisherAll(initialEvent: Bool = true) -> AnyPublisher { + let publisher = + NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) + .map { _ in () } + + if initialEvent { + return publisher + .prepend(()) + .eraseToAnyPublisher() + } else { + return publisher + .eraseToAnyPublisher() + } + } +} diff --git a/Sources/Defaults/Observation.swift b/Sources/Defaults/Observation.swift index bb653b1..8bbfb52 100644 --- a/Sources/Defaults/Observation.swift +++ b/Sources/Defaults/Observation.swift @@ -64,14 +64,14 @@ extension Defaults { return try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(dataValue) as? T } - fileprivate final class BaseChange { - fileprivate let kind: NSKeyValueChange - fileprivate let indexes: IndexSet? - fileprivate let isPrior: Bool - fileprivate let newValue: Any? - fileprivate let oldValue: Any? + final class BaseChange { + let kind: NSKeyValueChange + let indexes: IndexSet? + let isPrior: Bool + let newValue: Any? + let oldValue: Any? - fileprivate init(change: [NSKeyValueChangeKey: Any]) { + init(change: [NSKeyValueChangeKey: Any]) { kind = NSKeyValueChange(rawValue: change[.kindKey] as! UInt)! indexes = change[.indexesKey] as? IndexSet isPrior = change[.notificationIsPriorKey] as? Bool ?? false @@ -87,7 +87,7 @@ extension Defaults { public let newValue: T public let oldValue: T - fileprivate init(change: BaseChange, defaultValue: T) { + init(change: BaseChange, defaultValue: T) { self.kind = change.kind self.indexes = change.indexes self.isPrior = change.isPrior @@ -104,7 +104,7 @@ extension Defaults { public let newValue: T public let oldValue: T - fileprivate init(change: BaseChange, defaultValue: T) { + init(change: BaseChange, defaultValue: T) { self.kind = change.kind self.indexes = change.indexes self.isPrior = change.isPrior @@ -120,7 +120,7 @@ extension Defaults { public let newValue: T? public let oldValue: T? - fileprivate init(change: BaseChange) { + init(change: BaseChange) { self.kind = change.kind self.indexes = change.indexes self.isPrior = change.isPrior @@ -137,7 +137,7 @@ extension Defaults { public let newValue: T? public let oldValue: T? - fileprivate init(change: BaseChange) { + init(change: BaseChange) { self.kind = change.kind self.indexes = change.indexes self.isPrior = change.isPrior @@ -146,14 +146,14 @@ extension Defaults { } } - private final class UserDefaultsKeyObservation: NSObject, DefaultsObservation { - fileprivate typealias Callback = (BaseChange) -> Void + final class UserDefaultsKeyObservation: NSObject, DefaultsObservation { + typealias Callback = (BaseChange) -> Void private weak var object: UserDefaults? private let key: String private let callback: Callback - fileprivate init(object: UserDefaults, key: String, callback: @escaping Callback) { + init(object: UserDefaults, key: String, callback: @escaping Callback) { self.object = object self.key = key self.callback = callback @@ -163,7 +163,7 @@ extension Defaults { invalidate() } - fileprivate func start(options: NSKeyValueObservingOptions) { + func start(options: NSKeyValueObservingOptions) { object?.addObserver(self, forKeyPath: key, options: options, context: nil) } diff --git a/Tests/DefaultsTests/DefaultsTests.swift b/Tests/DefaultsTests/DefaultsTests.swift index 4638653..0fce6d0 100644 --- a/Tests/DefaultsTests/DefaultsTests.swift +++ b/Tests/DefaultsTests/DefaultsTests.swift @@ -2,6 +2,7 @@ import Foundation import XCTest import Defaults import CoreData +import Combine let fixtureURL = URL(string: "https://sindresorhus.com")! let fixtureURL2 = URL(string: "https://example.com")! @@ -165,6 +166,224 @@ final class DefaultsTests: XCTestCase { Defaults.removeAll(suite: customSuite) } + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObserveKeyCombine() { + let key = Defaults.Key("observeKey", default: false) + let expect = expectation(description: "Observation closure being called") + + let publisher = Defaults + .publisher(key, options: [.old, .new]) + .map { ($0.oldValue, $0.newValue) } + .collect(2) + + let cancellable = publisher.sink { tuples in + for (i, expected) in [(false, true), (true, false)].enumerated() { + XCTAssertEqual(expected.0, tuples[i].0) + XCTAssertEqual(expected.1, tuples[i].1) + } + + expect.fulfill() + } + + Defaults[key] = true + Defaults.reset(key) + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObserveNSSecureCodingKeyCombine() { + let key = Defaults.NSSecureCodingKey("observeNSSecureCodingKey", default: ExamplePersistentHistory(value: "TestValue")) + let expect = expectation(description: "Observation closure being called") + + let publisher = Defaults + .publisher(key, options: [.old, .new]) + .map { ($0.oldValue.value, $0.newValue.value) } + .collect(3) + + let expectedValues = [ + ("TestValue", "NewTestValue"), + ("NewTestValue", "NewTestValue2"), + ("NewTestValue2", "TestValue") + ] + + let cancellable = publisher.sink { actualValues in + for (expected, actual) in zip(expectedValues, actualValues) { + XCTAssertEqual(expected.0, actual.0) + XCTAssertEqual(expected.1, actual.1) + } + + expect.fulfill() + } + + Defaults[key] = ExamplePersistentHistory(value: "NewTestValue") + Defaults[key] = ExamplePersistentHistory(value: "NewTestValue2") + Defaults.reset(key) + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObserveOptionalKeyCombine() { + let key = Defaults.OptionalKey("observeOptionalKey") + let expect = expectation(description: "Observation closure being called") + + let publisher = Defaults + .publisher(key, options: [.old, .new]) + .map { ($0.oldValue, $0.newValue) } + .collect(3) + + let expectedValues: [(Bool?, Bool?)] = [(nil, true), (true, false), (false, nil)] + + let cancellable = publisher.sink { actualValues in + for (expected, actual) in zip(expectedValues, actualValues) { + XCTAssertEqual(expected.0, actual.0) + XCTAssertEqual(expected.1, actual.1) + } + + expect.fulfill() + } + + Defaults[key] = true + Defaults[key] = false + Defaults.reset(key) + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObserveNSSecureCodingOptionalKeyCombine() { + let key = Defaults.NSSecureCodingOptionalKey("observeNSSecureCodingOptionalKey") + let expect = expectation(description: "Observation closure being called") + + let publisher = Defaults + .publisher(key, options: [.old, .new]) + .map { ($0.oldValue?.value, $0.newValue?.value) } + .collect(3) + + let expectedValues: [(String?, String?)] = [ + (nil, "NewTestValue"), + ("NewTestValue", "NewTestValue2"), + ("NewTestValue2", nil) + ] + + let cancellable = publisher.sink { actualValues in + for (expected, actual) in zip(expectedValues, actualValues) { + XCTAssertEqual(expected.0, actual.0) + XCTAssertEqual(expected.1, actual.1) + } + + expect.fulfill() + } + + XCTAssertNil(Defaults[key]) + Defaults[key] = ExamplePersistentHistory(value: "NewTestValue") + Defaults[key] = ExamplePersistentHistory(value: "NewTestValue2") + Defaults.reset(key) + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObserveAllCombine() { + let key = Defaults.Key("observeAllKey", default: false) + let expect = expectation(description: "Observation closure being called") + let publisher = Defaults.publisherAll().collect(3) + + let cancellable = publisher.sink { actualValues in + XCTAssertEqual(3, actualValues.count) + expect.fulfill() + } + + Defaults[key] = true + Defaults[key] = false + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObserveMultipleKeysCombine() { + let key1 = Defaults.Key("observeKey1", default: false) + let key2 = Defaults.Key("observeKey2", default: true) + let expect = expectation(description: "Observation closure being called") + + let publisher = Defaults.publisher(keys: key1, key2, options: [.old, .new]).collect(2) + + let cancellable = publisher.sink { _ in + expect.fulfill() + } + + Defaults[key1] = true + Defaults[key2] = false + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObserveMultipleNSSecureKeysCombine() { + let key1 = Defaults.NSSecureCodingKey("observeNSSecureCodingKey1", default: ExamplePersistentHistory(value: "TestValue")) + let key2 = Defaults.NSSecureCodingKey("observeNSSecureCodingKey2", default: ExamplePersistentHistory(value: "TestValue")) + let expect = expectation(description: "Observation closure being called") + + let publisher = Defaults.publisher(keys: key1, key2, options: [.old, .new]).collect(2) + + let cancellable = publisher.sink { _ in + expect.fulfill() + } + + Defaults[key1] = ExamplePersistentHistory(value: "NewTestValue1") + Defaults[key2] = ExamplePersistentHistory(value: "NewTestValue2") + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObserveMultipleOptionalKeysCombine() { + let key1 = Defaults.OptionalKey("observeOptionalKey1") + let key2 = Defaults.OptionalKey("observeOptionalKey2") + let expect = expectation(description: "Observation closure being called") + + let publisher = Defaults.publisher(keys: key1, key2, options: [.old, .new]).collect(2) + + let cancellable = publisher.sink { _ in + expect.fulfill() + } + + Defaults[key1] = true + Defaults[key2] = false + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, iOSApplicationExtension 13.0, macOSApplicationExtension 10.15, tvOSApplicationExtension 13.0, watchOSApplicationExtension 6.0, *) + func testObserveMultipleNSSecureOptionalKeysCombine() { + let key1 = Defaults.NSSecureCodingOptionalKey("observeNSSecureCodingKey1") + let key2 = Defaults.NSSecureCodingOptionalKey("observeNSSecureCodingKey2") + let expect = expectation(description: "Observation closure being called") + + let publisher = Defaults.publisher(keys: key1, key2, options: [.old, .new]).collect(2) + + let cancellable = publisher.sink { _ in + expect.fulfill() + } + + Defaults[key1] = ExamplePersistentHistory(value: "NewTestValue1") + Defaults[key2] = ExamplePersistentHistory(value: "NewTestValue2") + cancellable.cancel() + + waitForExpectations(timeout: 10) + } + func testObserveKey() { let key = Defaults.Key("observeKey", default: false) let expect = expectation(description: "Observation closure being called") diff --git a/readme.md b/readme.md index 551ad95..903b1fc 100644 --- a/readme.md +++ b/readme.md @@ -13,6 +13,7 @@ It's used in production by apps like [Gifski](https://github.com/sindresorhus/Gi - **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. +- **Publishers:** Combine publishers built-in. - **Lightweight:** It's only some hundred lines of code. ## Compatibility @@ -163,6 +164,31 @@ 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 @@ -329,7 +355,7 @@ Defaults.observe( ``` ```swift -Defaults.observe( +Defaults.observe( _ key: Defaults.NSSecureCodingKey, options: NSKeyValueObservingOptions = [.initial, .old, .new], handler: @escaping (NSSecureCodingKeyChange) -> Void @@ -345,7 +371,7 @@ Defaults.observe( ``` ```swift -Defaults.observe( +Defaults.observe( _ key: Defaults.NSSecureCodingOptionalKey, options: NSKeyValueObservingOptions = [.initial, .old, .new], handler: @escaping (NSSecureCodingOptionalKeyChange) -> Void @@ -358,6 +384,90 @@ 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.publisher` + +```swift +Defaults.publisher( + _ key: Defaults.Key, + options: NSKeyValueObservingOptions = [.initial, .old, .new] +) -> AnyPublisher, Never> +``` + +```swift +Defaults.publisher( + _ key: Defaults.NSSecureCodingKey, + options: NSKeyValueObservingOptions = [.initial, .old, .new] +) -> AnyPublisher, Never> +``` + +```swift +Defaults.publisher( + _ key: Defaults.OptionalKey, + options: NSKeyValueObservingOptions = [.initial, .old, .new] +) -> AnyPublisher, Never> +``` + +```swift +Defaults.publisher( + _ key: Defaults.NSSecureCodingOptionalKey, + options: NSKeyValueObservingOptions = [.initial, .old, .new] +) -> 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. + +Available on macOS 10.15+, iOS 13.0+, tvOS 13.0+, and watchOS 6.0+. + +#### `Defaults.publisher(keys:)` + +```swift +Defaults.publisher( + keys: Defaults.Key..., + options: NSKeyValueObservingOptions = [.initial, .old, .new] +) -> AnyPublisher { +``` + +```swift +Defaults.publisher( + keys: Defaults.NSSecureCodingKey..., + options: NSKeyValueObservingOptions = [.initial, .old, .new] +) -> AnyPublisher { +``` + +```swift +Defaults.publisher( + keys: Defaults.OptionalKey..., + options: NSKeyValueObservingOptions = [.initial, .old, .new] +) -> AnyPublisher { +``` + +```swift +Defaults.publisher( + keys: Defaults.NSSecureCodingOptionalKey..., + options: NSKeyValueObservingOptions = [.initial, .old, .new] +) -> AnyPublisher { +``` + +Type: `func` + +[Combine](https://developer.apple.com/documentation/combine) observation API for multiple key observation, but without specific information about changes. + +Available on macOS 10.15+, iOS 13.0+, tvOS 13.0+, and watchOS 6.0+. + +#### `Defaults.publisherAll` + +```swift +Defaults.publisherAll(initialEvent: Bool = true) -> AnyPublisher +``` + +Convenience [Publisher](https://developer.apple.com/documentation/combine/publisher) for all `UserDefaults` key change events. A wrapper around the [`UserDefaults.didChangeNotification` notification](https://developer.apple.com/documentation/foundation/userdefaults/1408206-didchangenotification). + +- Parameter `initialEvent`: Trigger an initial event immediately. + +Available on macOS 10.15+, iOS 13.0+, tvOS 13.0+, and watchOS 6.0+. + #### `Defaults.removeAll` ```swift