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.
This commit is contained in:
Sindre Sorhus 2018-10-17 17:22:10 +07:00 committed by GitHub
parent 1912c5b20a
commit 257ce0df90
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 537 additions and 86 deletions

View File

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

View File

@ -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 = "<group>"; };
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 = "<group>"; usesTabs = 1; };
E3EB3E34216507AE0033B089 /* Observation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Observation.swift; sourceTree = "<group>"; 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 = "<group>";
@ -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;

View File

@ -26,11 +26,12 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
codeCoverageEnabled = "YES"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "52D6D9851BEFF229002C0205"

View File

@ -26,11 +26,12 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
codeCoverageEnabled = "YES"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DD75028C1C690C7A006590AF"

View File

@ -3,48 +3,66 @@ import Foundation
public final class Defaults {
public class Keys {
public typealias Key = Defaults.Key
public typealias OptionalKey = Defaults.OptionalKey
fileprivate init() {}
}
public final class Key<T: Codable>: 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<T: Codable>: 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<T: Codable>(key: Defaults.Key<T>) -> T {
get {
return UserDefaults.standard[key]
return key.suite[key]
}
set {
UserDefaults.standard[key] = newValue
key.suite[key] = newValue
}
}
public subscript<T: Codable>(key: Defaults.OptionalKey<T>) -> 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<T: Codable>(_ 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<T: Codable>(_ key: String, to value: T) {
if isNativelySupportedType(T.self) {
set(value, forKey: key)
return
}
fileprivate func _encode<T: Codable>(_ 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<T: Codable>(_ key: String, to value: T) {
if UserDefaults.isNativelySupportedType(T.self) {
set(value, forKey: key)
return
}
set(_encode(value), forKey: key)
}
public subscript<T: Codable>(key: Defaults.Key<T>) -> T {
get {
return _get(key.name) ?? key.defaultValue
@ -115,7 +137,7 @@ extension UserDefaults {
}
}
private func isNativelySupportedType<T>(_ type: T.Type) -> Bool {
fileprivate static func isNativelySupportedType<T>(_ type: T.Type) -> Bool {
switch type {
case is Bool.Type,
is String.Type,

174
Sources/Observation.swift Normal file
View File

@ -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<T: Decodable>(_ 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<T: Codable> {
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<T: Codable> {
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<Bool>("isUnicornMode", default: false)
}
let observer = defaults.observe(.isUnicornMode) { change in
print(change.newValue)
//=> false
}
```
*/
public func observe<T: Codable>(
_ key: Defaults.Key<T>,
options: NSKeyValueObservingOptions = [.initial, .old, .new],
handler: @escaping (KeyChange<T>) -> Void
) -> DefaultsObservation {
let observation = UserDefaultsKeyObservation(object: key.suite, key: key.name) { change in
handler(
KeyChange<T>(change: change, defaultValue: key.defaultValue)
)
}
observation.start(options: options)
return observation
}
/**
Observe an optional defaults key
```
extension Defaults.Keys {
static let isUnicornMode = OptionalKey<Bool>("isUnicornMode")
}
let observer = defaults.observe(.isUnicornMode) { change in
print(change.newValue)
//=> Optional(nil)
}
```
*/
public func observe<T: Codable>(
_ key: Defaults.OptionalKey<T>,
options: NSKeyValueObservingOptions = [.initial, .old, .new],
handler: @escaping (OptionalKeyChange<T>) -> Void
) -> DefaultsObservation {
let observation = UserDefaultsKeyObservation(object: key.suite, key: key.name) { change in
handler(
OptionalKeyChange<T>(change: change)
)
}
observation.start(options: options)
return observation
}
}

19
Sources/util.swift Normal file
View File

@ -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)
}
}

View File

@ -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<Bool>("key", default: false)
static let url = Defaults.Key<URL>("url", default: fixtureUrl)
static let `enum` = Defaults.Key<FixtureEnum>("enum", default: .oneHour)
static let data = Defaults.Key<Data>("data", default: Data(bytes: []))
static let key = Key<Bool>("key", default: false)
static let url = Key<URL>("url", default: fixtureURL)
static let `enum` = Key<FixtureEnum>("enum", default: .oneHour)
static let data = Key<Data>("data", default: Data(bytes: []))
static let date = Key<Date>("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<Bool>("key", default: false)
XCTAssertFalse(UserDefaults.standard[key])
UserDefaults.standard[key] = true
XCTAssertTrue(UserDefaults.standard[key])
let key = Defaults.Key<Bool>("independentKey", default: false)
XCTAssertFalse(defaults[key])
defaults[key] = true
XCTAssertTrue(defaults[key])
}
func testOptionalKey() {
let key = Defaults.OptionalKey<Bool>("key")
XCTAssertNil(UserDefaults.standard[key])
let key = Defaults.OptionalKey<Bool>("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<Bool>(keyName, default: true)
XCTAssertEqual(UserDefaults.standard.bool(forKey: keyName), true)
// Test that it works with multiple keys with defaults
let keyName2 = "registersDefault2"
_ = Defaults.Key<String>(keyName2, default: keyName2)
XCTAssertEqual(UserDefaults.standard.string(forKey: keyName2), keyName2)
}
func testKeyWithUserDefaultSubscript() {
let key = Defaults.Key<Bool>("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<Bool>("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<Bool>("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<Bool>("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<Bool>("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<URL>("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<FixtureEnum>("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)
}
}

123
readme.md
View File

@ -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,7 +54,7 @@ import Cocoa
import Defaults
extension Defaults.Keys {
static let quality = Defaults.Key<Double>("quality", default: 0.8)
static let quality = Key<Double>("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<Double>("name")
static let name = OptionalKey<Double>("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<DurationKeys>("defaultDuration", default: .oneHour)
static let defaultDuration = Key<DurationKeys>("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<Bool>("isUnicorn", default: true)
static let isUnicorn = Key<Bool>("isUnicorn", default: true)
}
UserDefaults.standard[.isUnicorn]
//=> true
```
### Shared UserDefaults
```swift
extension Defaults.Keys {
static let isUnicorn = Defaults.Key<Bool>("isUnicorn", default: true)
}
let extensionDefaults = UserDefaults(suiteName: "com.unicorn.app")!
extension Defaults.Keys {
static let isUnicorn = Key<Bool>("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<Bool>("isUnicorn", default: true)
defaults[isUnicorn]
//=> true
```
### Observe changes to a key
```swift
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
}
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<Bool>("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<T>(_ 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<T>(_ 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<T: Codable>(
_ key: Defaults.Key<T>,
options: NSKeyValueObservingOptions = [.initial, .old, .new],
handler: @escaping (KeyChange<T>) -> Void
) -> DefaultsObservation
```
```swift
observe<T: Codable>(
_ key: Defaults.OptionalKey<T>,
options: NSKeyValueObservingOptions = [.initial, .old, .new],
handler: @escaping (OptionalKeyChange<T>) -> 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