From 257ce0df90f7e6336f3272dd290b0f6fceb48f2e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 17 Oct 2018 17:22:10 +0700 Subject: [PATCH] Add support for key observation and other improvements (#10) And some other improvements: - Shorter syntax for defining keys. - It now writes the default value of `Defaults.Key` back to the actual UserDefaults on creation. - Support alternative UserDefaults suites. - Improved docs and test coverage. --- .travis.yml | 4 +- Defaults.xcodeproj/project.pbxproj | 65 +++++-- .../xcschemes/Defaults-iOS.xcscheme | 5 +- .../xcschemes/Defaults-tvOS.xcscheme | 5 +- Sources/Defaults.swift | 66 ++++--- Sources/Observation.swift | 174 ++++++++++++++++++ Sources/util.swift | 19 ++ Tests/DefaultsTests/DefaultsTests.swift | 158 ++++++++++++++-- readme.md | 127 +++++++++++-- 9 files changed, 537 insertions(+), 86 deletions(-) create mode 100644 Sources/Observation.swift create mode 100644 Sources/util.swift diff --git a/.travis.yml b/.travis.yml index 9ba20fa..d4e133a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,3 @@ language: swift -osx_image: xcode9.3 -script: xcodebuild test -project Defaults.xcodeproj -scheme Defaults-macOS -quiet +osx_image: xcode10 +script: xcodebuild test -project Defaults.xcodeproj -scheme Defaults-macOS diff --git a/Defaults.xcodeproj/project.pbxproj b/Defaults.xcodeproj/project.pbxproj index 82d98f6..4a4b01f 100644 --- a/Defaults.xcodeproj/project.pbxproj +++ b/Defaults.xcodeproj/project.pbxproj @@ -17,6 +17,14 @@ 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 */; }; + 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 */; }; + E3EB3E37216507B50033B089 /* Observation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3EB3E34216507AE0033B089 /* Observation.swift */; }; + E3EB3E38216507B60033B089 /* Observation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3EB3E34216507AE0033B089 /* Observation.swift */; }; + E3EB3E39216507C30033B089 /* util.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3EB3E32216505920033B089 /* util.swift */; }; + E3EB3E3A216507C40033B089 /* util.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3EB3E32216505920033B089 /* util.swift */; }; + E3EB3E3B216507C40033B089 /* util.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3EB3E32216505920033B089 /* util.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -55,6 +63,8 @@ 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; }; + 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 */ /* Begin PBXFrameworksBuildPhase section */ @@ -151,6 +161,8 @@ isa = PBXGroup; children = ( 8933C7841EB5B820000D00A4 /* Defaults.swift */, + E3EB3E34216507AE0033B089 /* Observation.swift */, + E3EB3E32216505920033B089 /* util.swift */, ); path = Sources; sourceTree = ""; @@ -352,19 +364,19 @@ TargetAttributes = { 52D6D97B1BEFF229002C0205 = { CreatedOnToolsVersion = 7.1; - LastSwiftMigration = 0800; + LastSwiftMigration = 1000; }; 52D6D9851BEFF229002C0205 = { CreatedOnToolsVersion = 7.1; - LastSwiftMigration = 0800; + LastSwiftMigration = 1000; }; 52D6D9E11BEFFF6E002C0205 = { CreatedOnToolsVersion = 7.1; - LastSwiftMigration = 0800; + LastSwiftMigration = 1000; }; 52D6D9EF1BEFFFBE002C0205 = { CreatedOnToolsVersion = 7.1; - LastSwiftMigration = 0800; + LastSwiftMigration = 1000; }; 52D6DA0E1BF000BD002C0205 = { CreatedOnToolsVersion = 7.1; @@ -376,7 +388,7 @@ }; DD75028C1C690C7A006590AF = { CreatedOnToolsVersion = 7.2.1; - LastSwiftMigration = 0800; + LastSwiftMigration = 1000; }; }; }; @@ -384,15 +396,18 @@ compatibilityVersion = "Xcode 10.0"; developmentRegion = English; hasScannedForEncodings = 0; + knownRegions = ( + en, + ); mainGroup = 52D6D9721BEFF229002C0205; productRefGroup = 52D6D97D1BEFF229002C0205 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 52D6D97B1BEFF229002C0205 /* Defaults-iOS */, 52D6DA0E1BF000BD002C0205 /* Defaults-macOS */, - 52D6D9E11BEFFF6E002C0205 /* Defaults-watchOS */, + 52D6D97B1BEFF229002C0205 /* Defaults-iOS */, 52D6D9EF1BEFFFBE002C0205 /* Defaults-tvOS */, + 52D6D9E11BEFFF6E002C0205 /* Defaults-watchOS */, 52D6D9851BEFF229002C0205 /* Defaults-iOS Tests */, DD7502791C68FCFC006590AF /* Defaults-macOS Tests */, DD75028C1C690C7A006590AF /* Defaults-tvOS Tests */, @@ -458,6 +473,8 @@ buildActionMask = 2147483647; files = ( 8933C7851EB5B820000D00A4 /* Defaults.swift in Sources */, + E3EB3E35216507AE0033B089 /* Observation.swift in Sources */, + E3EB3E33216505920033B089 /* util.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -473,6 +490,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E3EB3E3A216507C40033B089 /* util.swift in Sources */, + E3EB3E37216507B50033B089 /* Observation.swift in Sources */, 8933C7871EB5B820000D00A4 /* Defaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -481,6 +500,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E3EB3E3B216507C40033B089 /* util.swift in Sources */, + E3EB3E38216507B60033B089 /* Observation.swift in Sources */, 8933C7881EB5B820000D00A4 /* Defaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -489,6 +510,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E3EB3E39216507C30033B089 /* util.swift in Sources */, + E3EB3E36216507B50033B089 /* Observation.swift in Sources */, 8933C7861EB5B820000D00A4 /* Defaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -668,7 +691,7 @@ SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = singlefile; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; }; name = Debug; }; @@ -695,7 +718,7 @@ SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; }; name = Release; }; @@ -716,7 +739,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_COMPILATION_MODE = singlefile; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; }; name = Debug; }; @@ -737,7 +760,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; }; name = Release; }; @@ -761,7 +784,7 @@ PRODUCT_NAME = Defaults; SDKROOT = watchos; SKIP_INSTALL = YES; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; TARGETED_DEVICE_FAMILY = 4; WATCHOS_DEPLOYMENT_TARGET = 3.0; }; @@ -789,7 +812,7 @@ SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; TARGETED_DEVICE_FAMILY = 4; WATCHOS_DEPLOYMENT_TARGET = 3.0; }; @@ -815,7 +838,7 @@ PRODUCT_NAME = Defaults; SDKROOT = appletvos; SKIP_INSTALL = YES; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; TARGETED_DEVICE_FAMILY = 3; TVOS_DEPLOYMENT_TARGET = 10.0; }; @@ -843,7 +866,7 @@ SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; TARGETED_DEVICE_FAMILY = 3; TVOS_DEPLOYMENT_TARGET = 10.0; }; @@ -908,7 +931,6 @@ DD7502831C68FCFC006590AF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_IDENTITY = "-"; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = Configs/DefaultsTests.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -927,7 +949,6 @@ DD7502841C68FCFC006590AF /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_IDENTITY = "-"; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = Configs/DefaultsTests.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -948,6 +969,8 @@ DD7502961C690C7A006590AF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Configs/DefaultsTests.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -956,8 +979,9 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.Defaults.Defaults-tvOS-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = appletvos; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; TVOS_DEPLOYMENT_TARGET = 10.0; }; name = Debug; @@ -965,6 +989,8 @@ DD7502971C690C7A006590AF /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Configs/DefaultsTests.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -973,10 +999,11 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.Defaults.Defaults-tvOS-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = appletvos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; TVOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; diff --git a/Defaults.xcodeproj/xcshareddata/xcschemes/Defaults-iOS.xcscheme b/Defaults.xcodeproj/xcshareddata/xcschemes/Defaults-iOS.xcscheme index a428b18..25e659e 100644 --- a/Defaults.xcodeproj/xcshareddata/xcschemes/Defaults-iOS.xcscheme +++ b/Defaults.xcodeproj/xcshareddata/xcschemes/Defaults-iOS.xcscheme @@ -26,11 +26,12 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - codeCoverageEnabled = "YES" shouldUseLaunchSchemeArgsEnv = "YES"> + skipped = "NO" + parallelizable = "YES" + testExecutionOrdering = "random"> + skipped = "NO" + parallelizable = "YES" + testExecutionOrdering = "random"> : Keys { - fileprivate let name: String - fileprivate let defaultValue: T + public let name: String + public let defaultValue: T + public let suite: UserDefaults - public init(_ key: String, default defaultValue: T) { + public init(_ key: String, default defaultValue: T, suite: UserDefaults = .standard) { self.name = key self.defaultValue = defaultValue + self.suite = suite + + super.init() + + // Sets the default value in the actual UserDefaults, so it can be used in other contexts, like binding. + if UserDefaults.isNativelySupportedType(T.self) { + suite.register(defaults: [key: defaultValue]) + } else if let value = suite._encode(defaultValue) { + suite.register(defaults: [key: value]) + } } } public final class OptionalKey: Keys { - fileprivate let name: String + public let name: String + public let suite: UserDefaults - public init(_ key: String) { + public init(_ key: String, suite: UserDefaults = .standard) { self.name = key + self.suite = suite } } + fileprivate init() {} + public subscript(key: Defaults.Key) -> T { get { - return UserDefaults.standard[key] + return key.suite[key] } set { - UserDefaults.standard[key] = newValue + key.suite[key] = newValue } } public subscript(key: Defaults.OptionalKey) -> T? { get { - return UserDefaults.standard[key] + return key.suite[key] } set { - UserDefaults.standard[key] = newValue + key.suite[key] = newValue } } - public func clear() { - for key in UserDefaults.standard.dictionaryRepresentation().keys { - UserDefaults.standard.removeObject(forKey: key) + public func clear(suite: UserDefaults = .standard) { + for key in suite.dictionaryRepresentation().keys { + suite.removeObject(forKey: key) } } } @@ -54,7 +72,7 @@ public let defaults = Defaults() extension UserDefaults { private func _get(_ key: String) -> T? { - if isNativelySupportedType(T.self) { + if UserDefaults.isNativelySupportedType(T.self) { return object(forKey: key) as? T } @@ -74,24 +92,28 @@ extension UserDefaults { return nil } - private func _set(_ key: String, to value: T) { - if isNativelySupportedType(T.self) { - set(value, forKey: key) - return - } - + fileprivate func _encode(_ value: T) -> String? { do { // Some codable values like URL and enum are encoded as a top-level // string which JSON can't handle, so we need to wrap it in an array // We need this: https://forums.swift.org/t/allowing-top-level-fragments-in-jsondecoder/11750 let data = try JSONEncoder().encode([value]) - let string = String(data: data, encoding: .utf8)?.dropFirst().dropLast() - set(string, forKey: key) + return String(String(data: data, encoding: .utf8)!.dropFirst().dropLast()) } catch { print(error) + return nil } } + private func _set(_ key: String, to value: T) { + if UserDefaults.isNativelySupportedType(T.self) { + set(value, forKey: key) + return + } + + set(_encode(value), forKey: key) + } + public subscript(key: Defaults.Key) -> T { get { return _get(key.name) ?? key.defaultValue @@ -115,7 +137,7 @@ extension UserDefaults { } } - private func isNativelySupportedType(_ type: T.Type) -> Bool { + fileprivate static func isNativelySupportedType(_ type: T.Type) -> Bool { switch type { case is Bool.Type, is String.Type, diff --git a/Sources/Observation.swift b/Sources/Observation.swift new file mode 100644 index 0000000..2f6c82e --- /dev/null +++ b/Sources/Observation.swift @@ -0,0 +1,174 @@ +import Foundation + +/// TODO: Nest this inside `Defaults` if Swift ever supported nested protocols. +public protocol DefaultsObservation { + func invalidate() +} + +extension Defaults { + private static func deserialize(_ value: Any?, to type: T.Type) -> T? { + guard + let value = value, + !(value is NSNull) + else { + return nil + } + + // This handles the case where the value was a plist value using `isNativelySupportedType` + if let value = value as? T { + return value + } + + // Using the array trick as done below in `UserDefaults#_set()` + return [T].init(jsonString: "\([value])")?.first + } + + fileprivate final class BaseChange { + fileprivate let kind: NSKeyValueChange + fileprivate let indexes: IndexSet? + fileprivate let isPrior: Bool + fileprivate let newValue: Any? + fileprivate let oldValue: Any? + + fileprivate init(change: [NSKeyValueChangeKey: Any]) { + kind = NSKeyValueChange(rawValue: change[.kindKey] as! UInt)! + indexes = change[.indexesKey] as? IndexSet + isPrior = change[.notificationIsPriorKey] as? Bool ?? false + oldValue = change[.oldKey] + newValue = change[.newKey] + } + } + + public struct KeyChange { + public let kind: NSKeyValueChange + public let indexes: IndexSet? + public let isPrior: Bool + public let newValue: T + public let oldValue: T + + fileprivate init(change: BaseChange, defaultValue: T) { + self.kind = change.kind + self.indexes = change.indexes + self.isPrior = change.isPrior + self.oldValue = deserialize(change.oldValue, to: T.self) ?? defaultValue + self.newValue = deserialize(change.newValue, to: T.self) ?? defaultValue + } + } + + public struct OptionalKeyChange { + public let kind: NSKeyValueChange + public let indexes: IndexSet? + public let isPrior: Bool + public let newValue: T? + public let oldValue: T? + + fileprivate init(change: BaseChange) { + self.kind = change.kind + self.indexes = change.indexes + self.isPrior = change.isPrior + self.oldValue = deserialize(change.oldValue, to: T.self) + self.newValue = deserialize(change.newValue, to: T.self) + } + } + + private final class UserDefaultsKeyObservation: NSObject, DefaultsObservation { + fileprivate 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) { + self.object = object + self.key = key + self.callback = callback + } + + deinit { + invalidate() + } + + fileprivate func start(options: NSKeyValueObservingOptions) { + object?.addObserver(self, forKeyPath: key, options: options, context: nil) + } + + public func invalidate() { + object?.removeObserver(self, forKeyPath: key, context: nil) + object = nil + } + + // swiftlint:disable:next block_based_kvo + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, // swiftlint:disable:this discouraged_optional_collection + context: UnsafeMutableRawPointer? + ) { + guard + let selfObject = self.object, + selfObject == object as? NSObject, + let change = change + else { + return + } + + callback(BaseChange(change: change)) + } + } + + /** + Observe a defaults key + + ``` + extension Defaults.Keys { + static let isUnicornMode = Key("isUnicornMode", default: false) + } + + let observer = defaults.observe(.isUnicornMode) { change in + print(change.newValue) + //=> false + } + ``` + */ + public func observe( + _ key: Defaults.Key, + options: NSKeyValueObservingOptions = [.initial, .old, .new], + handler: @escaping (KeyChange) -> Void + ) -> DefaultsObservation { + let observation = UserDefaultsKeyObservation(object: key.suite, key: key.name) { change in + handler( + KeyChange(change: change, defaultValue: key.defaultValue) + ) + } + observation.start(options: options) + return observation + } + + /** + Observe an optional defaults key + + ``` + extension Defaults.Keys { + static let isUnicornMode = OptionalKey("isUnicornMode") + } + + let observer = defaults.observe(.isUnicornMode) { change in + print(change.newValue) + //=> Optional(nil) + } + ``` + */ + public func observe( + _ key: Defaults.OptionalKey, + options: NSKeyValueObservingOptions = [.initial, .old, .new], + handler: @escaping (OptionalKeyChange) -> Void + ) -> DefaultsObservation { + let observation = UserDefaultsKeyObservation(object: key.suite, key: key.name) { change in + handler( + OptionalKeyChange(change: change) + ) + } + observation.start(options: options) + return observation + } +} diff --git a/Sources/util.swift b/Sources/util.swift new file mode 100644 index 0000000..1cf1626 --- /dev/null +++ b/Sources/util.swift @@ -0,0 +1,19 @@ +import Foundation + +extension Decodable { + init?(jsonData: Data) { + guard let value = try? JSONDecoder().decode(Self.self, from: jsonData) else { + return nil + } + + self = value + } + + init?(jsonString: String) { + guard let data = jsonString.data(using: .utf8) else { + return nil + } + + self.init(jsonData: data) + } +} diff --git a/Tests/DefaultsTests/DefaultsTests.swift b/Tests/DefaultsTests/DefaultsTests.swift index dcf04ea..d0e4ee9 100644 --- a/Tests/DefaultsTests/DefaultsTests.swift +++ b/Tests/DefaultsTests/DefaultsTests.swift @@ -2,7 +2,8 @@ import Foundation import XCTest import Defaults -let fixtureUrl = URL(string: "https://sindresorhus.com")! +let fixtureURL = URL(string: "https://sindresorhus.com")! +let fixtureURL2 = URL(string: "https://example.com")! enum FixtureEnum: String, Codable { case tenMinutes = "10 Minutes" @@ -10,11 +11,14 @@ enum FixtureEnum: String, Codable { case oneHour = "1 Hour" } +let fixtureDate = Date() + extension Defaults.Keys { - static let key = Defaults.Key("key", default: false) - static let url = Defaults.Key("url", default: fixtureUrl) - static let `enum` = Defaults.Key("enum", default: .oneHour) - static let data = Defaults.Key("data", default: Data(bytes: [])) + static let key = Key("key", default: false) + static let url = Key("url", default: fixtureURL) + static let `enum` = Key("enum", default: .oneHour) + static let data = Key("data", default: Data(bytes: [])) + static let date = Key("date", default: fixtureDate) } final class DefaultsTests: XCTestCase { @@ -23,22 +27,46 @@ final class DefaultsTests: XCTestCase { defaults.clear() } + override func tearDown() { + super.setUp() + defaults.clear() + } + func testKey() { - let key = Defaults.Key("key", default: false) - XCTAssertFalse(UserDefaults.standard[key]) - UserDefaults.standard[key] = true - XCTAssertTrue(UserDefaults.standard[key]) + let key = Defaults.Key("independentKey", default: false) + XCTAssertFalse(defaults[key]) + defaults[key] = true + XCTAssertTrue(defaults[key]) } func testOptionalKey() { - let key = Defaults.OptionalKey("key") - XCTAssertNil(UserDefaults.standard[key]) + let key = Defaults.OptionalKey("independentOptionalKey") + XCTAssertNil(defaults[key]) + defaults[key] = true + XCTAssertTrue(defaults[key]!) + defaults[key] = nil + XCTAssertNil(defaults[key]) + defaults[key] = false + XCTAssertFalse(defaults[key]!) + } + + func testKeyRegistersDefault() { + let keyName = "registersDefault" + XCTAssertEqual(UserDefaults.standard.bool(forKey: keyName), false) + _ = Defaults.Key(keyName, default: true) + XCTAssertEqual(UserDefaults.standard.bool(forKey: keyName), true) + + // Test that it works with multiple keys with defaults + let keyName2 = "registersDefault2" + _ = Defaults.Key(keyName2, default: keyName2) + XCTAssertEqual(UserDefaults.standard.string(forKey: keyName2), keyName2) + } + + func testKeyWithUserDefaultSubscript() { + let key = Defaults.Key("keyWithUserDeaultSubscript", default: false) + XCTAssertFalse(UserDefaults.standard[key]) UserDefaults.standard[key] = true - XCTAssertTrue(UserDefaults.standard[key]!) - UserDefaults.standard[key] = nil - XCTAssertNil(UserDefaults.standard[key]) - UserDefaults.standard[key] = false - XCTAssertFalse(UserDefaults.standard[key]!) + XCTAssertTrue(UserDefaults.standard[key]) } func testKeys() { @@ -48,7 +76,7 @@ final class DefaultsTests: XCTestCase { } func testUrlType() { - XCTAssertEqual(defaults[.url], fixtureUrl) + XCTAssertEqual(defaults[.url], fixtureURL) let newUrl = URL(string: "https://twitter.com")! defaults[.url] = newUrl @@ -67,10 +95,100 @@ final class DefaultsTests: XCTestCase { XCTAssertEqual(defaults[.data], newData) } + func testDateType() { + XCTAssertEqual(defaults[.date], fixtureDate) + + let newDate = Date() + defaults[.date] = newDate + XCTAssertEqual(defaults[.date], newDate) + } + func testClear() { - defaults[.key] = true - XCTAssertTrue(defaults[.key]) + let key = Defaults.Key("clear", default: false) + defaults[key] = true + XCTAssertTrue(defaults[key]) defaults.clear() - XCTAssertFalse(defaults[.key]) + XCTAssertFalse(defaults[key]) + } + + func testCustomSuite() { + let customSuite = UserDefaults(suiteName: "com.sindresorhus.customSuite")! + let key = Defaults.Key("customSuite", default: false, suite: customSuite) + XCTAssertFalse(customSuite[key]) + XCTAssertFalse(defaults[key]) + defaults[key] = true + XCTAssertTrue(customSuite[key]) + XCTAssertTrue(defaults[key]) + defaults.clear(suite: customSuite) + } + + func testObserveKey() { + let key = Defaults.Key("observeKey", default: false) + let expect = expectation(description: "Observation closure being called") + + var observation: DefaultsObservation! + observation = defaults.observe(key, options: [.old, .new]) { change in + XCTAssertFalse(change.oldValue) + XCTAssertTrue(change.newValue) + observation.invalidate() + expect.fulfill() + } + + defaults[key] = true + + waitForExpectations(timeout: 10) + } + + func testObserveOptionalKey() { + let key = Defaults.OptionalKey("observeOptionalKey") + let expect = expectation(description: "Observation closure being called") + + var observation: DefaultsObservation! + observation = defaults.observe(key, options: [.old, .new]) { change in + XCTAssertNil(change.oldValue) + XCTAssertTrue(change.newValue!) + observation.invalidate() + expect.fulfill() + } + + defaults[key] = true + + waitForExpectations(timeout: 10) + } + + func testObserveKeyURL() { + let fixtureURL = URL(string: "https://sindresorhus.com")! + let fixtureURL2 = URL(string: "https://example.com")! + let key = Defaults.Key("observeKeyURL", default: fixtureURL) + let expect = expectation(description: "Observation closure being called") + + var observation: DefaultsObservation! + observation = defaults.observe(key, options: [.old, .new]) { change in + XCTAssertEqual(change.oldValue, fixtureURL) + XCTAssertEqual(change.newValue, fixtureURL2) + observation.invalidate() + expect.fulfill() + } + + defaults[key] = fixtureURL2 + + waitForExpectations(timeout: 10) + } + + func testObserveKeyEnum() { + let key = Defaults.Key("observeKeyEnum", default: .oneHour) + let expect = expectation(description: "Observation closure being called") + + var observation: DefaultsObservation! + observation = defaults.observe(key, options: [.old, .new]) { change in + XCTAssertEqual(change.oldValue, .oneHour) + XCTAssertEqual(change.newValue, .tenMinutes) + observation.invalidate() + expect.fulfill() + } + + defaults[key] = .tenMinutes + + waitForExpectations(timeout: 10) } } diff --git a/readme.md b/readme.md index ef55f57..89a997b 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,8 @@ - **Strongly typed:** You declare the type and default value upfront. - **Codable support:** You can store any [Codable](https://developer.apple.com/documentation/swift/codable) value, like an enum. - **Debuggable:** The data is stored as JSON-serialized values. -- **Lightweight:** It's only ~100 lines of code. +- **Observation:** Observe changes to keys. +- **Lightweight:** It's only ~300 lines of code. ## Compatibility @@ -53,9 +54,9 @@ import Cocoa import Defaults extension Defaults.Keys { - static let quality = Defaults.Key("quality", default: 0.8) - // ^ ^ ^ ^ - // Key Type UserDefaults name Default value + static let quality = Key("quality", default: 0.8) + // ^ ^ ^ ^ + // Key Type UserDefaults name Default value } ``` @@ -79,7 +80,7 @@ You can also declare optional keys for when you don't want to declare a default ```swift extension Defaults.Keys { - static let name = Defaults.OptionalKey("name") + static let name = OptionalKey("name") } if let name = defaults[.name] { @@ -87,7 +88,6 @@ if let name = defaults[.name] { } ``` - ### Enum example ```swift @@ -98,76 +98,165 @@ enum DurationKeys: String, Codable { } extension Defaults.Keys { - static let defaultDuration = Defaults.Key("defaultDuration", default: .oneHour) + static let defaultDuration = Key("defaultDuration", default: .oneHour) } defaults[.defaultDuration].rawValue //=> "1 Hour" ``` - ### It's just UserDefaults with sugar This works too: ```swift extension Defaults.Keys { - static let isUnicorn = Defaults.Key("isUnicorn", default: true) + static let isUnicorn = Key("isUnicorn", default: true) } UserDefaults.standard[.isUnicorn] //=> true ``` - ### Shared UserDefaults ```swift -extension Defaults.Keys { - static let isUnicorn = Defaults.Key("isUnicorn", default: true) -} - let extensionDefaults = UserDefaults(suiteName: "com.unicorn.app")! +extension Defaults.Keys { + static let isUnicorn = Key("isUnicorn", default: true, suite: extensionDefaults) +} + +defaults[.isUnicorn] +//=> true + +// Or + extensionDefaults[.isUnicorn] //=> true ``` +### Use keys directly + +You are not required to attach keys to `Defaults.Keys`. + +```swift +let isUnicorn = Defaults.Key("isUnicorn", default: true) + +defaults[isUnicorn] +//=> true +``` + +### Observe changes to a key + +```swift +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 +} + +defaults[.isUnicornMode] = true +``` + +### Default values are registered with UserDefaults + +When you create a `Defaults.Key`, it automatically registers the `default` value with normal UserDefaults. This means you can make use of the default value in, for example, bindings in Interface Builder. + +```swift +extension Defaults.Keys { + static let isUnicornMode = Key("isUnicornMode", default: true) +} + +print(UserDefaults.standard.bool(forKey: isUnicornMode.name)) +//=> true +``` + ## API ### `let defaults = Defaults()` -#### Defaults.Keys +#### `Defaults.Keys` Type: `class` Stores the keys. -#### Defaults.Key +#### `Defaults.Key` *(alias `Defaults.Keys.Key`)* + +```swift +Defaults.Key(_ key: String, default: T, suite: UserDefaults = .standard) +``` Type: `class` Create a key with a default value. -#### Defaults.OptionalKey +The default value is written to the actual `UserDefaults` and can be used elsewhere. For example, with Interface Builder binding. + +#### `Defaults.OptionalKey` *(alias `Defaults.Keys.OptionalKey`)* + +```swift +Defaults.OptionalKey(_ key: String, suite: UserDefaults = .standard) +``` Type: `class` Create a key with an optional value. -##### defaults.clear() +#### `Defaults#clear` + +```swift +clear(suite: UserDefaults = .standard) +``` Type: `func` Clear the user defaults. +#### `Defaults#observe` + +```swift +observe( + _ key: Defaults.Key, + options: NSKeyValueObservingOptions = [.initial, .old, .new], + handler: @escaping (KeyChange) -> Void +) -> DefaultsObservation +``` + +```swift +observe( + _ key: Defaults.OptionalKey, + options: NSKeyValueObservingOptions = [.initial, .old, .new], + handler: @escaping (OptionalKeyChange) -> Void +) -> DefaultsObservation +``` + +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. + ## FAQ ### How is this different from [`SwiftyUserDefaults`](https://github.com/radex/SwiftyUserDefaults)? -It's inspired by it and other solutions. The main difference is that this module doesn't hardcode the default values and comes with Codable support. +It's inspired by that package and other solutions. The main difference is that this module doesn't hardcode the default values and comes with Codable support. ## Related