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 language: swift
osx_image: xcode9.3 osx_image: xcode10
script: xcodebuild test -project Defaults.xcodeproj -scheme Defaults-macOS -quiet 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 */; }; 8933C7901EB5B82D000D00A4 /* DefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8933C7891EB5B82A000D00A4 /* DefaultsTests.swift */; };
DD7502881C68FEDE006590AF /* Defaults.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52D6DA0F1BF000BD002C0205 /* Defaults.framework */; }; DD7502881C68FEDE006590AF /* Defaults.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52D6DA0F1BF000BD002C0205 /* Defaults.framework */; };
DD7502921C690C7A006590AF /* Defaults.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52D6D9F01BEFFFBE002C0205 /* 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -55,6 +63,8 @@
AD2FAA281CD0B6E100659CF4 /* DefaultsTests.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = DefaultsTests.plist; sourceTree = "<group>"; }; 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; }; 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; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -151,6 +161,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
8933C7841EB5B820000D00A4 /* Defaults.swift */, 8933C7841EB5B820000D00A4 /* Defaults.swift */,
E3EB3E34216507AE0033B089 /* Observation.swift */,
E3EB3E32216505920033B089 /* util.swift */,
); );
path = Sources; path = Sources;
sourceTree = "<group>"; sourceTree = "<group>";
@ -352,19 +364,19 @@
TargetAttributes = { TargetAttributes = {
52D6D97B1BEFF229002C0205 = { 52D6D97B1BEFF229002C0205 = {
CreatedOnToolsVersion = 7.1; CreatedOnToolsVersion = 7.1;
LastSwiftMigration = 0800; LastSwiftMigration = 1000;
}; };
52D6D9851BEFF229002C0205 = { 52D6D9851BEFF229002C0205 = {
CreatedOnToolsVersion = 7.1; CreatedOnToolsVersion = 7.1;
LastSwiftMigration = 0800; LastSwiftMigration = 1000;
}; };
52D6D9E11BEFFF6E002C0205 = { 52D6D9E11BEFFF6E002C0205 = {
CreatedOnToolsVersion = 7.1; CreatedOnToolsVersion = 7.1;
LastSwiftMigration = 0800; LastSwiftMigration = 1000;
}; };
52D6D9EF1BEFFFBE002C0205 = { 52D6D9EF1BEFFFBE002C0205 = {
CreatedOnToolsVersion = 7.1; CreatedOnToolsVersion = 7.1;
LastSwiftMigration = 0800; LastSwiftMigration = 1000;
}; };
52D6DA0E1BF000BD002C0205 = { 52D6DA0E1BF000BD002C0205 = {
CreatedOnToolsVersion = 7.1; CreatedOnToolsVersion = 7.1;
@ -376,7 +388,7 @@
}; };
DD75028C1C690C7A006590AF = { DD75028C1C690C7A006590AF = {
CreatedOnToolsVersion = 7.2.1; CreatedOnToolsVersion = 7.2.1;
LastSwiftMigration = 0800; LastSwiftMigration = 1000;
}; };
}; };
}; };
@ -384,15 +396,18 @@
compatibilityVersion = "Xcode 10.0"; compatibilityVersion = "Xcode 10.0";
developmentRegion = English; developmentRegion = English;
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
knownRegions = (
en,
);
mainGroup = 52D6D9721BEFF229002C0205; mainGroup = 52D6D9721BEFF229002C0205;
productRefGroup = 52D6D97D1BEFF229002C0205 /* Products */; productRefGroup = 52D6D97D1BEFF229002C0205 /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
52D6D97B1BEFF229002C0205 /* Defaults-iOS */,
52D6DA0E1BF000BD002C0205 /* Defaults-macOS */, 52D6DA0E1BF000BD002C0205 /* Defaults-macOS */,
52D6D9E11BEFFF6E002C0205 /* Defaults-watchOS */, 52D6D97B1BEFF229002C0205 /* Defaults-iOS */,
52D6D9EF1BEFFFBE002C0205 /* Defaults-tvOS */, 52D6D9EF1BEFFFBE002C0205 /* Defaults-tvOS */,
52D6D9E11BEFFF6E002C0205 /* Defaults-watchOS */,
52D6D9851BEFF229002C0205 /* Defaults-iOS Tests */, 52D6D9851BEFF229002C0205 /* Defaults-iOS Tests */,
DD7502791C68FCFC006590AF /* Defaults-macOS Tests */, DD7502791C68FCFC006590AF /* Defaults-macOS Tests */,
DD75028C1C690C7A006590AF /* Defaults-tvOS Tests */, DD75028C1C690C7A006590AF /* Defaults-tvOS Tests */,
@ -458,6 +473,8 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
8933C7851EB5B820000D00A4 /* Defaults.swift in Sources */, 8933C7851EB5B820000D00A4 /* Defaults.swift in Sources */,
E3EB3E35216507AE0033B089 /* Observation.swift in Sources */,
E3EB3E33216505920033B089 /* util.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -473,6 +490,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E3EB3E3A216507C40033B089 /* util.swift in Sources */,
E3EB3E37216507B50033B089 /* Observation.swift in Sources */,
8933C7871EB5B820000D00A4 /* Defaults.swift in Sources */, 8933C7871EB5B820000D00A4 /* Defaults.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -481,6 +500,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E3EB3E3B216507C40033B089 /* util.swift in Sources */,
E3EB3E38216507B60033B089 /* Observation.swift in Sources */,
8933C7881EB5B820000D00A4 /* Defaults.swift in Sources */, 8933C7881EB5B820000D00A4 /* Defaults.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -489,6 +510,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E3EB3E39216507C30033B089 /* util.swift in Sources */,
E3EB3E36216507B50033B089 /* Observation.swift in Sources */,
8933C7861EB5B820000D00A4 /* Defaults.swift in Sources */, 8933C7861EB5B820000D00A4 /* Defaults.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -668,7 +691,7 @@
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = singlefile; SWIFT_COMPILATION_MODE = singlefile;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 4.0; SWIFT_VERSION = 4.2;
}; };
name = Debug; name = Debug;
}; };
@ -695,7 +718,7 @@
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 4.0; SWIFT_VERSION = 4.2;
}; };
name = Release; name = Release;
}; };
@ -716,7 +739,7 @@
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_COMPILATION_MODE = singlefile; SWIFT_COMPILATION_MODE = singlefile;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 4.0; SWIFT_VERSION = 4.2;
}; };
name = Debug; name = Debug;
}; };
@ -737,7 +760,7 @@
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 4.0; SWIFT_VERSION = 4.2;
}; };
name = Release; name = Release;
}; };
@ -761,7 +784,7 @@
PRODUCT_NAME = Defaults; PRODUCT_NAME = Defaults;
SDKROOT = watchos; SDKROOT = watchos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_VERSION = 4.0; SWIFT_VERSION = 4.2;
TARGETED_DEVICE_FAMILY = 4; TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 3.0; WATCHOS_DEPLOYMENT_TARGET = 3.0;
}; };
@ -789,7 +812,7 @@
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 4.0; SWIFT_VERSION = 4.2;
TARGETED_DEVICE_FAMILY = 4; TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 3.0; WATCHOS_DEPLOYMENT_TARGET = 3.0;
}; };
@ -815,7 +838,7 @@
PRODUCT_NAME = Defaults; PRODUCT_NAME = Defaults;
SDKROOT = appletvos; SDKROOT = appletvos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_VERSION = 4.0; SWIFT_VERSION = 4.2;
TARGETED_DEVICE_FAMILY = 3; TARGETED_DEVICE_FAMILY = 3;
TVOS_DEPLOYMENT_TARGET = 10.0; TVOS_DEPLOYMENT_TARGET = 10.0;
}; };
@ -843,7 +866,7 @@
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 4.0; SWIFT_VERSION = 4.2;
TARGETED_DEVICE_FAMILY = 3; TARGETED_DEVICE_FAMILY = 3;
TVOS_DEPLOYMENT_TARGET = 10.0; TVOS_DEPLOYMENT_TARGET = 10.0;
}; };
@ -908,7 +931,6 @@
DD7502831C68FCFC006590AF /* Debug */ = { DD7502831C68FCFC006590AF /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_IDENTITY = "-";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Configs/DefaultsTests.plist; INFOPLIST_FILE = Configs/DefaultsTests.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -927,7 +949,6 @@
DD7502841C68FCFC006590AF /* Release */ = { DD7502841C68FCFC006590AF /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_IDENTITY = "-";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Configs/DefaultsTests.plist; INFOPLIST_FILE = Configs/DefaultsTests.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -948,6 +969,8 @@
DD7502961C690C7A006590AF /* Debug */ = { DD7502961C690C7A006590AF /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = Configs/DefaultsTests.plist; INFOPLIST_FILE = Configs/DefaultsTests.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -956,8 +979,9 @@
); );
PRODUCT_BUNDLE_IDENTIFIER = "com.Defaults.Defaults-tvOS-Tests"; PRODUCT_BUNDLE_IDENTIFIER = "com.Defaults.Defaults-tvOS-Tests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = appletvos; SDKROOT = appletvos;
SWIFT_VERSION = 4.0; SWIFT_VERSION = 4.2;
TVOS_DEPLOYMENT_TARGET = 10.0; TVOS_DEPLOYMENT_TARGET = 10.0;
}; };
name = Debug; name = Debug;
@ -965,6 +989,8 @@
DD7502971C690C7A006590AF /* Release */ = { DD7502971C690C7A006590AF /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = Configs/DefaultsTests.plist; INFOPLIST_FILE = Configs/DefaultsTests.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -973,10 +999,11 @@
); );
PRODUCT_BUNDLE_IDENTIFIER = "com.Defaults.Defaults-tvOS-Tests"; PRODUCT_BUNDLE_IDENTIFIER = "com.Defaults.Defaults-tvOS-Tests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = appletvos; SDKROOT = appletvos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 4.0; SWIFT_VERSION = 4.2;
TVOS_DEPLOYMENT_TARGET = 10.0; TVOS_DEPLOYMENT_TARGET = 10.0;
}; };
name = Release; name = Release;

View File

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

View File

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

View File

@ -3,48 +3,66 @@ import Foundation
public final class Defaults { public final class Defaults {
public class Keys { public class Keys {
public typealias Key = Defaults.Key
public typealias OptionalKey = Defaults.OptionalKey
fileprivate init() {} fileprivate init() {}
} }
public final class Key<T: Codable>: Keys { public final class Key<T: Codable>: Keys {
fileprivate let name: String public let name: String
fileprivate let defaultValue: T 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.name = key
self.defaultValue = defaultValue 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 { 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.name = key
self.suite = suite
} }
} }
fileprivate init() {}
public subscript<T: Codable>(key: Defaults.Key<T>) -> T { public subscript<T: Codable>(key: Defaults.Key<T>) -> T {
get { get {
return UserDefaults.standard[key] return key.suite[key]
} }
set { set {
UserDefaults.standard[key] = newValue key.suite[key] = newValue
} }
} }
public subscript<T: Codable>(key: Defaults.OptionalKey<T>) -> T? { public subscript<T: Codable>(key: Defaults.OptionalKey<T>) -> T? {
get { get {
return UserDefaults.standard[key] return key.suite[key]
} }
set { set {
UserDefaults.standard[key] = newValue key.suite[key] = newValue
} }
} }
public func clear() { public func clear(suite: UserDefaults = .standard) {
for key in UserDefaults.standard.dictionaryRepresentation().keys { for key in suite.dictionaryRepresentation().keys {
UserDefaults.standard.removeObject(forKey: key) suite.removeObject(forKey: key)
} }
} }
} }
@ -54,7 +72,7 @@ public let defaults = Defaults()
extension UserDefaults { extension UserDefaults {
private func _get<T: Codable>(_ key: String) -> T? { private func _get<T: Codable>(_ key: String) -> T? {
if isNativelySupportedType(T.self) { if UserDefaults.isNativelySupportedType(T.self) {
return object(forKey: key) as? T return object(forKey: key) as? T
} }
@ -74,24 +92,28 @@ extension UserDefaults {
return nil return nil
} }
private func _set<T: Codable>(_ key: String, to value: T) { fileprivate func _encode<T: Codable>(_ value: T) -> String? {
if isNativelySupportedType(T.self) {
set(value, forKey: key)
return
}
do { do {
// Some codable values like URL and enum are encoded as a top-level // 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 // 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 // We need this: https://forums.swift.org/t/allowing-top-level-fragments-in-jsondecoder/11750
let data = try JSONEncoder().encode([value]) let data = try JSONEncoder().encode([value])
let string = String(data: data, encoding: .utf8)?.dropFirst().dropLast() return String(String(data: data, encoding: .utf8)!.dropFirst().dropLast())
set(string, forKey: key)
} catch { } catch {
print(error) 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 { public subscript<T: Codable>(key: Defaults.Key<T>) -> T {
get { get {
return _get(key.name) ?? key.defaultValue 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 { switch type {
case is Bool.Type, case is Bool.Type,
is String.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 XCTest
import Defaults 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 { enum FixtureEnum: String, Codable {
case tenMinutes = "10 Minutes" case tenMinutes = "10 Minutes"
@ -10,11 +11,14 @@ enum FixtureEnum: String, Codable {
case oneHour = "1 Hour" case oneHour = "1 Hour"
} }
let fixtureDate = Date()
extension Defaults.Keys { extension Defaults.Keys {
static let key = Defaults.Key<Bool>("key", default: false) static let key = Key<Bool>("key", default: false)
static let url = Defaults.Key<URL>("url", default: fixtureUrl) static let url = Key<URL>("url", default: fixtureURL)
static let `enum` = Defaults.Key<FixtureEnum>("enum", default: .oneHour) static let `enum` = Key<FixtureEnum>("enum", default: .oneHour)
static let data = Defaults.Key<Data>("data", default: Data(bytes: [])) static let data = Key<Data>("data", default: Data(bytes: []))
static let date = Key<Date>("date", default: fixtureDate)
} }
final class DefaultsTests: XCTestCase { final class DefaultsTests: XCTestCase {
@ -23,22 +27,46 @@ final class DefaultsTests: XCTestCase {
defaults.clear() defaults.clear()
} }
override func tearDown() {
super.setUp()
defaults.clear()
}
func testKey() { func testKey() {
let key = Defaults.Key<Bool>("key", default: false) let key = Defaults.Key<Bool>("independentKey", default: false)
XCTAssertFalse(UserDefaults.standard[key]) XCTAssertFalse(defaults[key])
UserDefaults.standard[key] = true defaults[key] = true
XCTAssertTrue(UserDefaults.standard[key]) XCTAssertTrue(defaults[key])
} }
func testOptionalKey() { func testOptionalKey() {
let key = Defaults.OptionalKey<Bool>("key") let key = Defaults.OptionalKey<Bool>("independentOptionalKey")
XCTAssertNil(UserDefaults.standard[key]) 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 UserDefaults.standard[key] = true
XCTAssertTrue(UserDefaults.standard[key]!) XCTAssertTrue(UserDefaults.standard[key])
UserDefaults.standard[key] = nil
XCTAssertNil(UserDefaults.standard[key])
UserDefaults.standard[key] = false
XCTAssertFalse(UserDefaults.standard[key]!)
} }
func testKeys() { func testKeys() {
@ -48,7 +76,7 @@ final class DefaultsTests: XCTestCase {
} }
func testUrlType() { func testUrlType() {
XCTAssertEqual(defaults[.url], fixtureUrl) XCTAssertEqual(defaults[.url], fixtureURL)
let newUrl = URL(string: "https://twitter.com")! let newUrl = URL(string: "https://twitter.com")!
defaults[.url] = newUrl defaults[.url] = newUrl
@ -67,10 +95,100 @@ final class DefaultsTests: XCTestCase {
XCTAssertEqual(defaults[.data], newData) XCTAssertEqual(defaults[.data], newData)
} }
func testDateType() {
XCTAssertEqual(defaults[.date], fixtureDate)
let newDate = Date()
defaults[.date] = newDate
XCTAssertEqual(defaults[.date], newDate)
}
func testClear() { func testClear() {
defaults[.key] = true let key = Defaults.Key<Bool>("clear", default: false)
XCTAssertTrue(defaults[.key]) defaults[key] = true
XCTAssertTrue(defaults[key])
defaults.clear() 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)
} }
} }

127
readme.md
View File

@ -8,7 +8,8 @@
- **Strongly typed:** You declare the type and default value upfront. - **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. - **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. - **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 ## Compatibility
@ -53,9 +54,9 @@ import Cocoa
import Defaults import Defaults
extension Defaults.Keys { 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 // 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 ```swift
extension Defaults.Keys { extension Defaults.Keys {
static let name = Defaults.OptionalKey<Double>("name") static let name = OptionalKey<Double>("name")
} }
if let name = defaults[.name] { if let name = defaults[.name] {
@ -87,7 +88,6 @@ if let name = defaults[.name] {
} }
``` ```
### Enum example ### Enum example
```swift ```swift
@ -98,76 +98,165 @@ enum DurationKeys: String, Codable {
} }
extension Defaults.Keys { extension Defaults.Keys {
static let defaultDuration = Defaults.Key<DurationKeys>("defaultDuration", default: .oneHour) static let defaultDuration = Key<DurationKeys>("defaultDuration", default: .oneHour)
} }
defaults[.defaultDuration].rawValue defaults[.defaultDuration].rawValue
//=> "1 Hour" //=> "1 Hour"
``` ```
### It's just UserDefaults with sugar ### It's just UserDefaults with sugar
This works too: This works too:
```swift ```swift
extension Defaults.Keys { extension Defaults.Keys {
static let isUnicorn = Defaults.Key<Bool>("isUnicorn", default: true) static let isUnicorn = Key<Bool>("isUnicorn", default: true)
} }
UserDefaults.standard[.isUnicorn] UserDefaults.standard[.isUnicorn]
//=> true //=> true
``` ```
### Shared UserDefaults ### Shared UserDefaults
```swift ```swift
extension Defaults.Keys {
static let isUnicorn = Defaults.Key<Bool>("isUnicorn", default: true)
}
let extensionDefaults = UserDefaults(suiteName: "com.unicorn.app")! 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] extensionDefaults[.isUnicorn]
//=> true //=> 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 ## API
### `let defaults = Defaults()` ### `let defaults = Defaults()`
#### Defaults.Keys #### `Defaults.Keys`
Type: `class` Type: `class`
Stores the keys. Stores the keys.
#### Defaults.Key #### `Defaults.Key` *(alias `Defaults.Keys.Key`)*
```swift
Defaults.Key<T>(_ key: String, default: T, suite: UserDefaults = .standard)
```
Type: `class` Type: `class`
Create a key with a default value. 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` Type: `class`
Create a key with an optional value. Create a key with an optional value.
##### defaults.clear() #### `Defaults#clear`
```swift
clear(suite: UserDefaults = .standard)
```
Type: `func` Type: `func`
Clear the user defaults. 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 ## FAQ
### How is this different from [`SwiftyUserDefaults`](https://github.com/radex/SwiftyUserDefaults)? ### 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 ## Related