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:
parent
1912c5b20a
commit
257ce0df90
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
123
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,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
|
||||
|
|
Loading…
Reference in New Issue