├── .github
├── dependabot.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .ruby-version
├── .swiftpm
└── xcode
│ └── xcshareddata
│ └── xcschemes
│ └── swift-user-defaults.xcscheme
├── .vscode
└── settings.json
├── Example
├── Example.xcodeproj
│ ├── project.pbxproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ │ └── xcschemes
│ │ └── Example.xcscheme
├── Example.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
├── Example
│ ├── Assets.xcassets
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ └── Contents.json
│ │ └── Contents.json
│ ├── ContentView.swift
│ ├── ContentViewModel.swift
│ ├── ExampleApp.swift
│ └── Info.plist
├── ExampleKit
│ ├── .gitignore
│ ├── Package.swift
│ ├── README.md
│ └── Sources
│ │ └── ExampleKit
│ │ └── ExamplePreferences.swift
└── ExampleUITests
│ ├── ExampleUITests.swift
│ └── Info.plist
├── Gemfile
├── Gemfile.lock
├── LICENSE.txt
├── Makefile
├── Package.swift
├── README.md
├── Sources
└── SwiftUserDefaults
│ ├── AppStorage+Key.swift
│ ├── LaunchArgumentEncodable.swift
│ ├── PrivacyInfo.xcprivacy
│ ├── UserDefault.swift
│ ├── UserDefaultOverride.swift
│ ├── UserDefaults+CodingStrategy.swift
│ ├── UserDefaults+Key.swift
│ ├── UserDefaults+Observation.swift
│ ├── UserDefaults+ValueContainer.swift
│ ├── UserDefaults+X.swift
│ └── UserDefaultsStorable.swift
├── Tests
└── SwiftUserDefaultsTests
│ ├── LaunchArgumentEncodableTests.swift
│ ├── StorableValueTests.swift
│ ├── StorableXMLValueTests.swift
│ ├── Types
│ ├── RawSubject.swift
│ └── Subject.swift
│ ├── UserDefaultTests.swift
│ ├── UserDefaultsKeyTests.swift
│ ├── UserDefaultsObservationTests.swift
│ ├── UserDefaultsValueContainerTests.swift
│ └── UserDefaultsXTests.swift
└── swift-user-defaults.podspec
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: github-actions
4 | directory: "/"
5 | schedule:
6 | interval: monthly
7 | - package-ecosystem: bundler
8 | directory: "/"
9 | schedule:
10 | interval: monthly
11 | versioning-strategy: lockfile-only
12 | insecure-external-code-execution: allow
13 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | env:
10 | SCHEME: "swift-user-defaults"
11 | XCODEBUILD: set -o pipefail && env NSUnbufferedIO=YES xcodebuild
12 |
13 | jobs:
14 | test-macos:
15 | name: Test (macOS, Xcode ${{ matrix.xcode }})
16 | runs-on: ${{ matrix.macos }}
17 | env:
18 | DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
19 | strategy:
20 | matrix:
21 | xcode: [ 14.3.1, 15.2 ]
22 | include:
23 | - xcode: 14.3.1
24 | macos: macos-14
25 | - xcode: 15.2
26 | macos: macos-14
27 | steps:
28 | - name: Checkout Repo
29 | uses: actions/checkout@v4
30 | - name: Test
31 | run: ${{ env.XCODEBUILD }} -scheme "${{ env.SCHEME }}" -destination "platform=macOS" clean test | xcbeautify
32 |
33 | test-ios:
34 | name: Test (iOS, Xcode ${{ matrix.xcode }})
35 | runs-on: ${{ matrix.macos }}
36 | env:
37 | DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
38 | strategy:
39 | matrix:
40 | xcode: [ 14.3.1, 15.2 ]
41 | include:
42 | - xcode: 14.3.1
43 | macos: macos-14
44 | destination: "platform=iOS Simulator,name=iPhone 14,OS=16.4"
45 | - xcode: 15.2
46 | macos: macos-14
47 | destination: "platform=iOS Simulator,name=iPhone 14,OS=17.2"
48 | steps:
49 | - name: Checkout Repo
50 | uses: actions/checkout@v4
51 | - name: Test
52 | run: ${{ env.XCODEBUILD }} -scheme "${{ env.SCHEME }}" -destination "${{ matrix.destination }}" clean test | xcbeautify
53 |
54 | test-tvos:
55 | name: Test (tvOS, Xcode ${{ matrix.xcode }})
56 | runs-on: ${{ matrix.macos }}
57 | strategy:
58 | matrix:
59 | xcode: [ 14.3.1, 15.2 ]
60 | include:
61 | - xcode: 14.3.1
62 | macos: macos-14
63 | destination: "platform=tvOS Simulator,name=Apple TV,OS=16.4"
64 | - xcode: 15.2
65 | macos: macos-14
66 | destination: "platform=tvOS Simulator,name=Apple TV,OS=17.2"
67 | env:
68 | DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
69 | steps:
70 | - name: Checkout Repo
71 | uses: actions/checkout@v4
72 | - name: Test
73 | run: ${{ env.XCODEBUILD }} -scheme "${{ env.SCHEME }}" -destination "${{ matrix.destination }}" clean test | xcbeautify
74 |
75 | test-watchos:
76 | name: Test (watchOS, Xcode ${{ matrix.xcode }})
77 | runs-on: ${{ matrix.macos }}
78 | strategy:
79 | matrix:
80 | xcode: [ 14.3.1, 15.2 ]
81 | include:
82 | - xcode: 14.3.1
83 | macos: macos-14
84 | destination: "platform=watchOS Simulator,name=Apple Watch Series 8 (41mm),OS=9.4"
85 | - xcode: 15.2
86 | macos: macos-14
87 | destination: "platform=watchOS Simulator,name=Apple Watch Series 9 (41mm),OS=10.2"
88 | env:
89 | DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
90 | steps:
91 | - name: Checkout Repo
92 | uses: actions/checkout@v4
93 | - name: Test
94 | run: ${{ env.XCODEBUILD }} -scheme "${{ env.SCHEME }}" -destination "${{ matrix.destination }}" clean test | xcbeautify
95 |
96 | example:
97 | name: Example Project
98 | runs-on: macos-14
99 | env:
100 | DEVELOPER_DIR: '/Applications/Xcode_15.2.app/Contents/Developer'
101 | steps:
102 | - name: Checkout Repo
103 | uses: actions/checkout@v4
104 | - name: UI Test
105 | run: ${{ env.XCODEBUILD }} -workspace "Example/Example.xcworkspace" -scheme "Example" -destination "platform=iOS Simulator,name=iPhone 14,OS=17.2" clean test | xcbeautify
106 |
107 | cocoapods:
108 | name: CocoaPods
109 | runs-on: macos-14
110 | steps:
111 | - name: Checkout Repo
112 | uses: actions/checkout@v4
113 | - name: Setup Ruby
114 | uses: ruby/setup-ruby@v1
115 | with:
116 | bundler-cache: true
117 | - name: Lint
118 | run: make lint
119 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.0.2
2 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/swift-user-defaults.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
48 |
54 |
55 |
56 |
57 |
58 |
68 |
69 |
75 |
76 |
82 |
83 |
84 |
85 |
87 |
88 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.insertFinalNewline": true,
3 | "files.trimTrailingWhitespace": true
4 | }
5 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 52;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 5356EF16266D4CD8006EEFCF /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5356EF15266D4CD8006EEFCF /* ExampleApp.swift */; };
11 | 5356EF18266D4CD8006EEFCF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5356EF17266D4CD8006EEFCF /* ContentView.swift */; };
12 | 5356EF1A266D4CD9006EEFCF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5356EF19266D4CD9006EEFCF /* Assets.xcassets */; };
13 | 5356EF26266D4E04006EEFCF /* ExampleKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5356EF25266D4E04006EEFCF /* ExampleKit */; };
14 | 53BCC8B4266D586D00F9574D /* ExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53BCC8B3266D586D00F9574D /* ExampleUITests.swift */; };
15 | 53BCC8BC266D587A00F9574D /* ExampleKit in Frameworks */ = {isa = PBXBuildFile; productRef = 53BCC8BB266D587A00F9574D /* ExampleKit */; };
16 | 53BCC8C0266D6F1700F9574D /* ContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53BCC8BF266D6F1700F9574D /* ContentViewModel.swift */; };
17 | /* End PBXBuildFile section */
18 |
19 | /* Begin PBXContainerItemProxy section */
20 | 53BCC8B6266D586D00F9574D /* PBXContainerItemProxy */ = {
21 | isa = PBXContainerItemProxy;
22 | containerPortal = 5356EF0A266D4CD8006EEFCF /* Project object */;
23 | proxyType = 1;
24 | remoteGlobalIDString = 5356EF11266D4CD8006EEFCF;
25 | remoteInfo = Example;
26 | };
27 | /* End PBXContainerItemProxy section */
28 |
29 | /* Begin PBXFileReference section */
30 | 5356EF12266D4CD8006EEFCF /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
31 | 5356EF15266D4CD8006EEFCF /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = ""; };
32 | 5356EF17266D4CD8006EEFCF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
33 | 5356EF19266D4CD9006EEFCF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
34 | 5356EF1E266D4CD9006EEFCF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
35 | 53BCC8B1266D586D00F9574D /* ExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
36 | 53BCC8B3266D586D00F9574D /* ExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleUITests.swift; sourceTree = ""; };
37 | 53BCC8B5266D586D00F9574D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
38 | 53BCC8BF266D6F1700F9574D /* ContentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewModel.swift; sourceTree = ""; };
39 | /* End PBXFileReference section */
40 |
41 | /* Begin PBXFrameworksBuildPhase section */
42 | 5356EF0F266D4CD8006EEFCF /* Frameworks */ = {
43 | isa = PBXFrameworksBuildPhase;
44 | buildActionMask = 2147483647;
45 | files = (
46 | 5356EF26266D4E04006EEFCF /* ExampleKit in Frameworks */,
47 | );
48 | runOnlyForDeploymentPostprocessing = 0;
49 | };
50 | 53BCC8AE266D586D00F9574D /* Frameworks */ = {
51 | isa = PBXFrameworksBuildPhase;
52 | buildActionMask = 2147483647;
53 | files = (
54 | 53BCC8BC266D587A00F9574D /* ExampleKit in Frameworks */,
55 | );
56 | runOnlyForDeploymentPostprocessing = 0;
57 | };
58 | /* End PBXFrameworksBuildPhase section */
59 |
60 | /* Begin PBXGroup section */
61 | 5356EF09266D4CD8006EEFCF = {
62 | isa = PBXGroup;
63 | children = (
64 | 5356EF14266D4CD8006EEFCF /* Example */,
65 | 53BCC8B2266D586D00F9574D /* ExampleUITests */,
66 | 5356EF13266D4CD8006EEFCF /* Products */,
67 | 5356EF24266D4E04006EEFCF /* Frameworks */,
68 | );
69 | sourceTree = "";
70 | };
71 | 5356EF13266D4CD8006EEFCF /* Products */ = {
72 | isa = PBXGroup;
73 | children = (
74 | 5356EF12266D4CD8006EEFCF /* Example.app */,
75 | 53BCC8B1266D586D00F9574D /* ExampleUITests.xctest */,
76 | );
77 | name = Products;
78 | sourceTree = "";
79 | };
80 | 5356EF14266D4CD8006EEFCF /* Example */ = {
81 | isa = PBXGroup;
82 | children = (
83 | 5356EF15266D4CD8006EEFCF /* ExampleApp.swift */,
84 | 53BCC8BF266D6F1700F9574D /* ContentViewModel.swift */,
85 | 5356EF17266D4CD8006EEFCF /* ContentView.swift */,
86 | 5356EF19266D4CD9006EEFCF /* Assets.xcassets */,
87 | 5356EF1E266D4CD9006EEFCF /* Info.plist */,
88 | );
89 | path = Example;
90 | sourceTree = "";
91 | };
92 | 5356EF24266D4E04006EEFCF /* Frameworks */ = {
93 | isa = PBXGroup;
94 | children = (
95 | );
96 | name = Frameworks;
97 | sourceTree = "";
98 | };
99 | 53BCC8B2266D586D00F9574D /* ExampleUITests */ = {
100 | isa = PBXGroup;
101 | children = (
102 | 53BCC8B3266D586D00F9574D /* ExampleUITests.swift */,
103 | 53BCC8B5266D586D00F9574D /* Info.plist */,
104 | );
105 | path = ExampleUITests;
106 | sourceTree = "";
107 | };
108 | /* End PBXGroup section */
109 |
110 | /* Begin PBXNativeTarget section */
111 | 5356EF11266D4CD8006EEFCF /* Example */ = {
112 | isa = PBXNativeTarget;
113 | buildConfigurationList = 5356EF21266D4CD9006EEFCF /* Build configuration list for PBXNativeTarget "Example" */;
114 | buildPhases = (
115 | 5356EF0E266D4CD8006EEFCF /* Sources */,
116 | 5356EF0F266D4CD8006EEFCF /* Frameworks */,
117 | 5356EF10266D4CD8006EEFCF /* Resources */,
118 | );
119 | buildRules = (
120 | );
121 | dependencies = (
122 | );
123 | name = Example;
124 | packageProductDependencies = (
125 | 5356EF25266D4E04006EEFCF /* ExampleKit */,
126 | );
127 | productName = Example;
128 | productReference = 5356EF12266D4CD8006EEFCF /* Example.app */;
129 | productType = "com.apple.product-type.application";
130 | };
131 | 53BCC8B0266D586D00F9574D /* ExampleUITests */ = {
132 | isa = PBXNativeTarget;
133 | buildConfigurationList = 53BCC8BA266D586D00F9574D /* Build configuration list for PBXNativeTarget "ExampleUITests" */;
134 | buildPhases = (
135 | 53BCC8AD266D586D00F9574D /* Sources */,
136 | 53BCC8AE266D586D00F9574D /* Frameworks */,
137 | 53BCC8AF266D586D00F9574D /* Resources */,
138 | );
139 | buildRules = (
140 | );
141 | dependencies = (
142 | 53BCC8B7266D586D00F9574D /* PBXTargetDependency */,
143 | );
144 | name = ExampleUITests;
145 | packageProductDependencies = (
146 | 53BCC8BB266D587A00F9574D /* ExampleKit */,
147 | );
148 | productName = ExampleUITests;
149 | productReference = 53BCC8B1266D586D00F9574D /* ExampleUITests.xctest */;
150 | productType = "com.apple.product-type.bundle.ui-testing";
151 | };
152 | /* End PBXNativeTarget section */
153 |
154 | /* Begin PBXProject section */
155 | 5356EF0A266D4CD8006EEFCF /* Project object */ = {
156 | isa = PBXProject;
157 | attributes = {
158 | LastSwiftUpdateCheck = 1250;
159 | LastUpgradeCheck = 1250;
160 | TargetAttributes = {
161 | 5356EF11266D4CD8006EEFCF = {
162 | CreatedOnToolsVersion = 12.5;
163 | };
164 | 53BCC8B0266D586D00F9574D = {
165 | CreatedOnToolsVersion = 12.5;
166 | TestTargetID = 5356EF11266D4CD8006EEFCF;
167 | };
168 | };
169 | };
170 | buildConfigurationList = 5356EF0D266D4CD8006EEFCF /* Build configuration list for PBXProject "Example" */;
171 | compatibilityVersion = "Xcode 9.3";
172 | developmentRegion = en;
173 | hasScannedForEncodings = 0;
174 | knownRegions = (
175 | en,
176 | Base,
177 | );
178 | mainGroup = 5356EF09266D4CD8006EEFCF;
179 | productRefGroup = 5356EF13266D4CD8006EEFCF /* Products */;
180 | projectDirPath = "";
181 | projectRoot = "";
182 | targets = (
183 | 5356EF11266D4CD8006EEFCF /* Example */,
184 | 53BCC8B0266D586D00F9574D /* ExampleUITests */,
185 | );
186 | };
187 | /* End PBXProject section */
188 |
189 | /* Begin PBXResourcesBuildPhase section */
190 | 5356EF10266D4CD8006EEFCF /* Resources */ = {
191 | isa = PBXResourcesBuildPhase;
192 | buildActionMask = 2147483647;
193 | files = (
194 | 5356EF1A266D4CD9006EEFCF /* Assets.xcassets in Resources */,
195 | );
196 | runOnlyForDeploymentPostprocessing = 0;
197 | };
198 | 53BCC8AF266D586D00F9574D /* Resources */ = {
199 | isa = PBXResourcesBuildPhase;
200 | buildActionMask = 2147483647;
201 | files = (
202 | );
203 | runOnlyForDeploymentPostprocessing = 0;
204 | };
205 | /* End PBXResourcesBuildPhase section */
206 |
207 | /* Begin PBXSourcesBuildPhase section */
208 | 5356EF0E266D4CD8006EEFCF /* Sources */ = {
209 | isa = PBXSourcesBuildPhase;
210 | buildActionMask = 2147483647;
211 | files = (
212 | 5356EF18266D4CD8006EEFCF /* ContentView.swift in Sources */,
213 | 5356EF16266D4CD8006EEFCF /* ExampleApp.swift in Sources */,
214 | 53BCC8C0266D6F1700F9574D /* ContentViewModel.swift in Sources */,
215 | );
216 | runOnlyForDeploymentPostprocessing = 0;
217 | };
218 | 53BCC8AD266D586D00F9574D /* Sources */ = {
219 | isa = PBXSourcesBuildPhase;
220 | buildActionMask = 2147483647;
221 | files = (
222 | 53BCC8B4266D586D00F9574D /* ExampleUITests.swift in Sources */,
223 | );
224 | runOnlyForDeploymentPostprocessing = 0;
225 | };
226 | /* End PBXSourcesBuildPhase section */
227 |
228 | /* Begin PBXTargetDependency section */
229 | 53BCC8B7266D586D00F9574D /* PBXTargetDependency */ = {
230 | isa = PBXTargetDependency;
231 | target = 5356EF11266D4CD8006EEFCF /* Example */;
232 | targetProxy = 53BCC8B6266D586D00F9574D /* PBXContainerItemProxy */;
233 | };
234 | /* End PBXTargetDependency section */
235 |
236 | /* Begin XCBuildConfiguration section */
237 | 5356EF1F266D4CD9006EEFCF /* Debug */ = {
238 | isa = XCBuildConfiguration;
239 | buildSettings = {
240 | ALWAYS_SEARCH_USER_PATHS = NO;
241 | CLANG_ANALYZER_NONNULL = YES;
242 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
243 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
244 | CLANG_CXX_LIBRARY = "libc++";
245 | CLANG_ENABLE_MODULES = YES;
246 | CLANG_ENABLE_OBJC_ARC = YES;
247 | CLANG_ENABLE_OBJC_WEAK = YES;
248 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
249 | CLANG_WARN_BOOL_CONVERSION = YES;
250 | CLANG_WARN_COMMA = YES;
251 | CLANG_WARN_CONSTANT_CONVERSION = YES;
252 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
253 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
254 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
255 | CLANG_WARN_EMPTY_BODY = YES;
256 | CLANG_WARN_ENUM_CONVERSION = YES;
257 | CLANG_WARN_INFINITE_RECURSION = YES;
258 | CLANG_WARN_INT_CONVERSION = YES;
259 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
260 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
261 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
262 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
263 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
264 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
265 | CLANG_WARN_STRICT_PROTOTYPES = YES;
266 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
267 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
268 | CLANG_WARN_UNREACHABLE_CODE = YES;
269 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
270 | COPY_PHASE_STRIP = NO;
271 | DEBUG_INFORMATION_FORMAT = dwarf;
272 | ENABLE_STRICT_OBJC_MSGSEND = YES;
273 | ENABLE_TESTABILITY = YES;
274 | GCC_C_LANGUAGE_STANDARD = gnu11;
275 | GCC_DYNAMIC_NO_PIC = NO;
276 | GCC_NO_COMMON_BLOCKS = YES;
277 | GCC_OPTIMIZATION_LEVEL = 0;
278 | GCC_PREPROCESSOR_DEFINITIONS = (
279 | "DEBUG=1",
280 | "$(inherited)",
281 | );
282 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
283 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
284 | GCC_WARN_UNDECLARED_SELECTOR = YES;
285 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
286 | GCC_WARN_UNUSED_FUNCTION = YES;
287 | GCC_WARN_UNUSED_VARIABLE = YES;
288 | IPHONEOS_DEPLOYMENT_TARGET = 14.5;
289 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
290 | MTL_FAST_MATH = YES;
291 | ONLY_ACTIVE_ARCH = YES;
292 | SDKROOT = iphoneos;
293 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
294 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
295 | };
296 | name = Debug;
297 | };
298 | 5356EF20266D4CD9006EEFCF /* Release */ = {
299 | isa = XCBuildConfiguration;
300 | buildSettings = {
301 | ALWAYS_SEARCH_USER_PATHS = NO;
302 | CLANG_ANALYZER_NONNULL = YES;
303 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
304 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
305 | CLANG_CXX_LIBRARY = "libc++";
306 | CLANG_ENABLE_MODULES = YES;
307 | CLANG_ENABLE_OBJC_ARC = YES;
308 | CLANG_ENABLE_OBJC_WEAK = YES;
309 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
310 | CLANG_WARN_BOOL_CONVERSION = YES;
311 | CLANG_WARN_COMMA = YES;
312 | CLANG_WARN_CONSTANT_CONVERSION = YES;
313 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
314 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
315 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
316 | CLANG_WARN_EMPTY_BODY = YES;
317 | CLANG_WARN_ENUM_CONVERSION = YES;
318 | CLANG_WARN_INFINITE_RECURSION = YES;
319 | CLANG_WARN_INT_CONVERSION = YES;
320 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
321 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
322 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
323 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
324 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
325 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
326 | CLANG_WARN_STRICT_PROTOTYPES = YES;
327 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
328 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
329 | CLANG_WARN_UNREACHABLE_CODE = YES;
330 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
331 | COPY_PHASE_STRIP = NO;
332 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
333 | ENABLE_NS_ASSERTIONS = NO;
334 | ENABLE_STRICT_OBJC_MSGSEND = YES;
335 | GCC_C_LANGUAGE_STANDARD = gnu11;
336 | GCC_NO_COMMON_BLOCKS = YES;
337 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
338 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
339 | GCC_WARN_UNDECLARED_SELECTOR = YES;
340 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
341 | GCC_WARN_UNUSED_FUNCTION = YES;
342 | GCC_WARN_UNUSED_VARIABLE = YES;
343 | IPHONEOS_DEPLOYMENT_TARGET = 14.5;
344 | MTL_ENABLE_DEBUG_INFO = NO;
345 | MTL_FAST_MATH = YES;
346 | SDKROOT = iphoneos;
347 | SWIFT_COMPILATION_MODE = wholemodule;
348 | SWIFT_OPTIMIZATION_LEVEL = "-O";
349 | VALIDATE_PRODUCT = YES;
350 | };
351 | name = Release;
352 | };
353 | 5356EF22266D4CD9006EEFCF /* Debug */ = {
354 | isa = XCBuildConfiguration;
355 | buildSettings = {
356 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
357 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
358 | CODE_SIGN_STYLE = Automatic;
359 | DEVELOPMENT_ASSET_PATHS = "";
360 | ENABLE_PREVIEWS = YES;
361 | INFOPLIST_FILE = Example/Info.plist;
362 | IPHONEOS_DEPLOYMENT_TARGET = 14.0;
363 | LD_RUNPATH_SEARCH_PATHS = (
364 | "$(inherited)",
365 | "@executable_path/Frameworks",
366 | );
367 | PRODUCT_BUNDLE_IDENTIFIER = com.cookpad.Example;
368 | PRODUCT_NAME = "$(TARGET_NAME)";
369 | SWIFT_VERSION = 5.0;
370 | TARGETED_DEVICE_FAMILY = "1,2";
371 | };
372 | name = Debug;
373 | };
374 | 5356EF23266D4CD9006EEFCF /* Release */ = {
375 | isa = XCBuildConfiguration;
376 | buildSettings = {
377 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
378 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
379 | CODE_SIGN_STYLE = Automatic;
380 | DEVELOPMENT_ASSET_PATHS = "";
381 | ENABLE_PREVIEWS = YES;
382 | INFOPLIST_FILE = Example/Info.plist;
383 | IPHONEOS_DEPLOYMENT_TARGET = 14.0;
384 | LD_RUNPATH_SEARCH_PATHS = (
385 | "$(inherited)",
386 | "@executable_path/Frameworks",
387 | );
388 | PRODUCT_BUNDLE_IDENTIFIER = com.cookpad.Example;
389 | PRODUCT_NAME = "$(TARGET_NAME)";
390 | SWIFT_VERSION = 5.0;
391 | TARGETED_DEVICE_FAMILY = "1,2";
392 | };
393 | name = Release;
394 | };
395 | 53BCC8B8266D586D00F9574D /* Debug */ = {
396 | isa = XCBuildConfiguration;
397 | buildSettings = {
398 | CODE_SIGN_STYLE = Automatic;
399 | INFOPLIST_FILE = ExampleUITests/Info.plist;
400 | LD_RUNPATH_SEARCH_PATHS = (
401 | "$(inherited)",
402 | "@executable_path/Frameworks",
403 | "@loader_path/Frameworks",
404 | );
405 | PRODUCT_BUNDLE_IDENTIFIER = com.cookpad.ExampleUITests;
406 | PRODUCT_NAME = "$(TARGET_NAME)";
407 | SWIFT_VERSION = 5.0;
408 | TARGETED_DEVICE_FAMILY = "1,2";
409 | TEST_TARGET_NAME = Example;
410 | };
411 | name = Debug;
412 | };
413 | 53BCC8B9266D586D00F9574D /* Release */ = {
414 | isa = XCBuildConfiguration;
415 | buildSettings = {
416 | CODE_SIGN_STYLE = Automatic;
417 | INFOPLIST_FILE = ExampleUITests/Info.plist;
418 | LD_RUNPATH_SEARCH_PATHS = (
419 | "$(inherited)",
420 | "@executable_path/Frameworks",
421 | "@loader_path/Frameworks",
422 | );
423 | PRODUCT_BUNDLE_IDENTIFIER = com.cookpad.ExampleUITests;
424 | PRODUCT_NAME = "$(TARGET_NAME)";
425 | SWIFT_VERSION = 5.0;
426 | TARGETED_DEVICE_FAMILY = "1,2";
427 | TEST_TARGET_NAME = Example;
428 | };
429 | name = Release;
430 | };
431 | /* End XCBuildConfiguration section */
432 |
433 | /* Begin XCConfigurationList section */
434 | 5356EF0D266D4CD8006EEFCF /* Build configuration list for PBXProject "Example" */ = {
435 | isa = XCConfigurationList;
436 | buildConfigurations = (
437 | 5356EF1F266D4CD9006EEFCF /* Debug */,
438 | 5356EF20266D4CD9006EEFCF /* Release */,
439 | );
440 | defaultConfigurationIsVisible = 0;
441 | defaultConfigurationName = Release;
442 | };
443 | 5356EF21266D4CD9006EEFCF /* Build configuration list for PBXNativeTarget "Example" */ = {
444 | isa = XCConfigurationList;
445 | buildConfigurations = (
446 | 5356EF22266D4CD9006EEFCF /* Debug */,
447 | 5356EF23266D4CD9006EEFCF /* Release */,
448 | );
449 | defaultConfigurationIsVisible = 0;
450 | defaultConfigurationName = Release;
451 | };
452 | 53BCC8BA266D586D00F9574D /* Build configuration list for PBXNativeTarget "ExampleUITests" */ = {
453 | isa = XCConfigurationList;
454 | buildConfigurations = (
455 | 53BCC8B8266D586D00F9574D /* Debug */,
456 | 53BCC8B9266D586D00F9574D /* Release */,
457 | );
458 | defaultConfigurationIsVisible = 0;
459 | defaultConfigurationName = Release;
460 | };
461 | /* End XCConfigurationList section */
462 |
463 | /* Begin XCSwiftPackageProductDependency section */
464 | 5356EF25266D4E04006EEFCF /* ExampleKit */ = {
465 | isa = XCSwiftPackageProductDependency;
466 | productName = ExampleKit;
467 | };
468 | 53BCC8BB266D587A00F9574D /* ExampleKit */ = {
469 | isa = XCSwiftPackageProductDependency;
470 | productName = ExampleKit;
471 | };
472 | /* End XCSwiftPackageProductDependency section */
473 | };
474 | rootObject = 5356EF0A266D4CD8006EEFCF /* Project object */;
475 | }
476 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
42 |
43 |
53 |
55 |
61 |
62 |
63 |
64 |
70 |
72 |
78 |
79 |
80 |
81 |
83 |
84 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/Example/Example.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Example/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "scale" : "1x",
46 | "size" : "20x20"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "scale" : "2x",
51 | "size" : "20x20"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "scale" : "1x",
56 | "size" : "29x29"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "scale" : "2x",
61 | "size" : "29x29"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "scale" : "1x",
66 | "size" : "40x40"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "scale" : "2x",
71 | "size" : "40x40"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "scale" : "1x",
76 | "size" : "76x76"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "scale" : "2x",
81 | "size" : "76x76"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "scale" : "2x",
86 | "size" : "83.5x83.5"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "scale" : "1x",
91 | "size" : "1024x1024"
92 | }
93 | ],
94 | "info" : {
95 | "author" : "xcode",
96 | "version" : 1
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Example/Example/ContentView.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import ExampleKit
24 | import SwiftUI
25 |
26 | struct ContentView: View {
27 | // AppStorage is backed by UserDefaults so this works too!
28 | @AppStorage(.contentTitle) var title: String?
29 | @AppStorage(.contentSortOrder) var sortOrder: ContentSortOrder = .descending
30 | @ObservedObject var viewModel = ContentViewModel()
31 |
32 | var body: some View {
33 | NavigationView {
34 | Group {
35 | if viewModel.items.isEmpty {
36 | Text("No Items")
37 | .foregroundColor(Color(.secondaryLabel))
38 | .frame(maxWidth: .infinity)
39 | } else {
40 | List {
41 | ForEach(viewModel.items.sorted(by: sortOrder.compare(lhs:rhs:)), id: \.self) { item in
42 | Text("\(item, formatter: viewModel.dateFormatter)")
43 | }
44 | .onDelete(perform: { viewModel.items.remove(atOffsets: $0) })
45 | }
46 | .listStyle(InsetGroupedListStyle())
47 | }
48 | }
49 | .navigationTitle(title ?? "Untitled")
50 | .frame(maxHeight: .infinity)
51 | .background(Color(.systemGroupedBackground).edgesIgnoringSafeArea(.all))
52 | .toolbar {
53 | ToolbarItem(placement: .navigationBarTrailing) {
54 | Button(action: { viewModel.addItem() }) {
55 | Image(systemName: "plus")
56 | }
57 | }
58 | }
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Example/Example/ContentViewModel.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import ExampleKit
24 | import Foundation
25 | import SwiftUserDefaults
26 |
27 | class ContentViewModel: ObservableObject {
28 | let dateFormatter: DateFormatter = {
29 | let formatter = DateFormatter()
30 | formatter.dateStyle = .long
31 | formatter.timeStyle = .short
32 | return formatter
33 | }()
34 |
35 | let userDefaults: UserDefaults = .standard
36 |
37 | @Published var items: [Date] = [] {
38 | didSet {
39 | userDefaults.x.set(items, forKey: .contentItems)
40 | }
41 | }
42 |
43 | init() {
44 | items = userDefaults.x.object(forKey: .contentItems) ?? []
45 | }
46 |
47 | func addItem() {
48 | items.append(Date())
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Example/Example/ExampleApp.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import SwiftUI
24 |
25 | @main
26 | struct ExampleApp: App {
27 | var body: some Scene {
28 | WindowGroup {
29 | ContentView()
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Example/Example/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 |
28 | UIApplicationSupportsIndirectInputEvents
29 |
30 | UILaunchScreen
31 |
32 | UIRequiredDeviceCapabilities
33 |
34 | armv7
35 |
36 | UISupportedInterfaceOrientations
37 |
38 | UIInterfaceOrientationPortrait
39 | UIInterfaceOrientationLandscapeLeft
40 | UIInterfaceOrientationLandscapeRight
41 |
42 | UISupportedInterfaceOrientations~ipad
43 |
44 | UIInterfaceOrientationPortrait
45 | UIInterfaceOrientationPortraitUpsideDown
46 | UIInterfaceOrientationLandscapeLeft
47 | UIInterfaceOrientationLandscapeRight
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/Example/ExampleKit/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/Example/ExampleKit/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "ExampleKit",
8 | platforms: [
9 | .iOS(.v14)
10 | ],
11 | products: [
12 | .library(name: "ExampleKit", targets: ["ExampleKit"])
13 | ],
14 | dependencies: [
15 | .package(path: "../../")
16 | ],
17 | targets: [
18 | .target(name: "ExampleKit", dependencies: [.product(name: "SwiftUserDefaults", package: "swift-user-defaults")])
19 | ]
20 | )
21 |
--------------------------------------------------------------------------------
/Example/ExampleKit/README.md:
--------------------------------------------------------------------------------
1 | # ExampleKit
2 |
3 | A description of this package.
4 |
--------------------------------------------------------------------------------
/Example/ExampleKit/Sources/ExampleKit/ExamplePreferences.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import Foundation
24 | import SwiftUserDefaults
25 |
26 | // Defines UserDefault preference keys used by the Example app.
27 | // These constants live in ExampleKit so that they can be shared between Example and ExampleUITests.
28 | public extension UserDefaults.Key {
29 | /// User defaults key representing the custom title shown in `ContentView`
30 | static let contentTitle = Self("ContentTitle")
31 |
32 | /// User defaults key representing the items displayed in the list of `ContentView`
33 | static let contentItems = Self("ContentItems")
34 |
35 | /// The order used to sort the items that are displayed in `ContentView`
36 | static let contentSortOrder = Self("ContentSortOrder")
37 | }
38 |
39 | public enum ContentSortOrder: String {
40 | case ascending, descending
41 |
42 | public func compare(lhs: T, rhs: T) -> Bool {
43 | switch self {
44 | case .ascending:
45 | return lhs < rhs
46 | case .descending:
47 | return lhs > rhs
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Example/ExampleUITests/ExampleUITests.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import ExampleKit
24 | import SwiftUserDefaults
25 | import XCTest
26 |
27 | class ExampleUITests: XCTestCase {
28 | struct Configuration: LaunchArgumentEncodable {
29 | @UserDefaultOverride(.contentTitle)
30 | var title: String = "Example App (Test)"
31 |
32 | @UserDefaultOverride(.contentItems)
33 | var items: [Date] = []
34 |
35 | @UserDefaultOverride(.contentSortOrder)
36 | var sortOrder: ContentSortOrder = .descending
37 |
38 | var deviceLocale: Locale = Locale(identifier: "en_US")
39 |
40 | var additionalLaunchArguments: [String] {
41 | // Type `Locale` doesn't match how we want to represent the `AppleLocale` UserDefault so we'll encode it manually
42 | var container = UserDefaults.ValueContainer()
43 | container.set(deviceLocale.identifier, forKey: UserDefaults.Key("AppleLocale"))
44 | container.set(deviceLocale.identifier, forKey: UserDefaults.Key("AppleLanguages"))
45 |
46 | return container.launchArguments
47 | }
48 | }
49 |
50 | func testNoItemsPlaceholder() throws {
51 | // Configure UserDefaults to ensure that there are no items
52 | // The default definition of `Configuration` sets sensible defaults to ensure a consistent (empty) state.
53 | let configuration = Configuration()
54 |
55 | // Launch the app with the user defaults
56 | let app = XCUIApplication()
57 | app.launchArguments = try configuration.encodeLaunchArguments()
58 | app.launch()
59 |
60 | // Ensure the placeholder is set properly
61 | XCTAssertTrue(app.navigationBars["Example App (Test)"].exists)
62 | XCTAssertTrue(app.staticTexts["No Items"].exists)
63 | }
64 |
65 | func testDeleteItem() throws {
66 | let calendar = Calendar.current
67 | let startDate = calendar.date(from: DateComponents(year: 2021, month: 6, day: 1, hour: 9, minute: 10))!
68 |
69 | // Configure a more complex scenario to test by overriding various values
70 | var configuration = Configuration()
71 | configuration.deviceLocale = Locale(identifier: "fr_FR")
72 | configuration.sortOrder = .ascending
73 | configuration.title = "Example App"
74 | configuration.items = [
75 | startDate,
76 | calendar.date(byAdding: .day, value: 1, to: startDate)!,
77 | calendar.date(byAdding: .day, value: 2, to: startDate)!,
78 | calendar.date(byAdding: .day, value: 3, to: startDate)!,
79 | calendar.date(byAdding: .day, value: 4, to: startDate)!,
80 | calendar.date(byAdding: .day, value: 5, to: startDate)!
81 | ]
82 |
83 | // Launch the app with the user default overrides
84 | let app = XCUIApplication()
85 | app.launchArguments = try configuration.encodeLaunchArguments()
86 | app.launch()
87 |
88 | // Find a known cell, ensure it exists
89 | let fourthJune = app.staticTexts["4 juin 2021 à 09:10"]
90 | XCTAssertTrue(fourthJune.exists)
91 |
92 | // Swipe to delete
93 | fourthJune.swipeLeft()
94 | app.buttons["Delete"].tap()
95 |
96 | // Confirm deletion
97 | XCTAssertFalse(fourthJune.exists)
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/Example/ExampleUITests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | # Cocoapods for iOS dependency management
4 | gem 'cocoapods'
5 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | CFPropertyList (3.0.7)
5 | base64
6 | nkf
7 | rexml
8 | activesupport (7.1.3.2)
9 | base64
10 | bigdecimal
11 | concurrent-ruby (~> 1.0, >= 1.0.2)
12 | connection_pool (>= 2.2.5)
13 | drb
14 | i18n (>= 1.6, < 2)
15 | minitest (>= 5.1)
16 | mutex_m
17 | tzinfo (~> 2.0)
18 | addressable (2.8.6)
19 | public_suffix (>= 2.0.2, < 6.0)
20 | algoliasearch (1.27.5)
21 | httpclient (~> 2.8, >= 2.8.3)
22 | json (>= 1.5.1)
23 | atomos (0.1.3)
24 | base64 (0.2.0)
25 | bigdecimal (3.1.7)
26 | claide (1.1.0)
27 | cocoapods (1.15.2)
28 | addressable (~> 2.8)
29 | claide (>= 1.0.2, < 2.0)
30 | cocoapods-core (= 1.15.2)
31 | cocoapods-deintegrate (>= 1.0.3, < 2.0)
32 | cocoapods-downloader (>= 2.1, < 3.0)
33 | cocoapods-plugins (>= 1.0.0, < 2.0)
34 | cocoapods-search (>= 1.0.0, < 2.0)
35 | cocoapods-trunk (>= 1.6.0, < 2.0)
36 | cocoapods-try (>= 1.1.0, < 2.0)
37 | colored2 (~> 3.1)
38 | escape (~> 0.0.4)
39 | fourflusher (>= 2.3.0, < 3.0)
40 | gh_inspector (~> 1.0)
41 | molinillo (~> 0.8.0)
42 | nap (~> 1.0)
43 | ruby-macho (>= 2.3.0, < 3.0)
44 | xcodeproj (>= 1.23.0, < 2.0)
45 | cocoapods-core (1.15.2)
46 | activesupport (>= 5.0, < 8)
47 | addressable (~> 2.8)
48 | algoliasearch (~> 1.0)
49 | concurrent-ruby (~> 1.1)
50 | fuzzy_match (~> 2.0.4)
51 | nap (~> 1.0)
52 | netrc (~> 0.11)
53 | public_suffix (~> 4.0)
54 | typhoeus (~> 1.0)
55 | cocoapods-deintegrate (1.0.5)
56 | cocoapods-downloader (2.1)
57 | cocoapods-plugins (1.0.0)
58 | nap
59 | cocoapods-search (1.0.1)
60 | cocoapods-trunk (1.6.0)
61 | nap (>= 0.8, < 2.0)
62 | netrc (~> 0.11)
63 | cocoapods-try (1.2.0)
64 | colored2 (3.1.2)
65 | concurrent-ruby (1.2.3)
66 | connection_pool (2.4.1)
67 | drb (2.2.1)
68 | escape (0.0.4)
69 | ethon (0.16.0)
70 | ffi (>= 1.15.0)
71 | ffi (1.16.3)
72 | fourflusher (2.3.1)
73 | fuzzy_match (2.0.4)
74 | gh_inspector (1.1.3)
75 | httpclient (2.8.3)
76 | i18n (1.14.4)
77 | concurrent-ruby (~> 1.0)
78 | json (2.7.1)
79 | minitest (5.22.3)
80 | molinillo (0.8.0)
81 | mutex_m (0.2.0)
82 | nanaimo (0.3.0)
83 | nap (1.1.0)
84 | netrc (0.11.0)
85 | nkf (0.2.0)
86 | public_suffix (4.0.7)
87 | rexml (3.2.6)
88 | ruby-macho (2.5.1)
89 | typhoeus (1.4.1)
90 | ethon (>= 0.9.0)
91 | tzinfo (2.0.6)
92 | concurrent-ruby (~> 1.0)
93 | xcodeproj (1.24.0)
94 | CFPropertyList (>= 2.3.3, < 4.0)
95 | atomos (~> 0.1.3)
96 | claide (>= 1.0.2, < 2.0)
97 | colored2 (~> 3.1)
98 | nanaimo (~> 0.3.0)
99 | rexml (~> 3.2.4)
100 |
101 | PLATFORMS
102 | arm64-darwin
103 | x86_64-darwin
104 | x86_64-linux
105 |
106 | DEPENDENCIES
107 | cocoapods
108 |
109 | BUNDLED WITH
110 | 2.2.22
111 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Cookpad Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | setup:
2 | bundle check || bundle install
3 |
4 | lint: setup
5 | bundle exec pod lib lint --allow-warnings
6 |
7 | release: lint
8 | bundle exec pod trunk push
9 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "swift-user-defaults",
8 | platforms: [
9 | .macOS(.v10_13),
10 | .iOS(.v12),
11 | .watchOS(.v7),
12 | .tvOS(.v12)
13 | ],
14 | products: [
15 | .library(name: "SwiftUserDefaults", targets: ["SwiftUserDefaults"]),
16 | ],
17 | targets: [
18 | .target(name: "SwiftUserDefaults", dependencies: [], resources: [.copy("PrivacyInfo.xcprivacy")]),
19 | .testTarget(name: "SwiftUserDefaultsTests", dependencies: ["SwiftUserDefaults"])
20 | ]
21 | )
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Swift User Defaults
2 |
3 | A series of Swift friendly utilities for Foundation's `UserDefaults` class.
4 |
5 | # Features
6 |
7 | - 🔑 [**Constant Keys**](#-constant-keys) - Manage default keys using a specialized type to help prevent bugs and keep your project organized.
8 | - 🦺 [**Type Safety**](#-type-safety) - Automatically cast to the right types and forget about `Any?`.
9 | - 🔍 [**Observations**](#-observations) - Effortless observations in Swift.
10 | - 👩💻 [**Codable and RawRepresentable Support**](#-codable-and-rawrepresentable-support) - Consistently encode and decode `Codable` and `RawRepresentable` types with no additional effort.
11 | - 🧪 [**Mocking in UI Tests**](#-mocking-in-ui-tests) - Inject default values from your UI test suite directly into your application.
12 | - 🎁 [**Property Wrappers**](#-property-wrappers) - Bringing the power of SwiftUI's `@AppStorage` wrapper to Swift with `@UserDefault`.
13 |
14 | ## 🔑 Constant Keys
15 |
16 | With `UserDefaults` today, you store values against a given 'key'. This key is a `String` and over time using string's can lead to easy to avoid bugs unless you are defining your own constants somewhere.
17 |
18 | You likely have to do something like the following in a project today:
19 |
20 | ```swift
21 | let userDefaults = UserDefaults.standard
22 | var value = (userDefaults.object(forKey: "UserCount") as? Int) ?? 0
23 | value += 1
24 | userDefaults.set(value, forKey: "UserCoumt")
25 | ```
26 |
27 | As you can see from the example above, reusing strings can lead to bugs through typos so a common way to guard against this is to define constants:
28 |
29 |
30 | ```swift
31 | struct Constants {
32 | static let userCountDefaultsKey = "UserCount"
33 | }
34 |
35 | // ...
36 |
37 | let userDefaults = UserDefaults.standard
38 | var value = (userDefaults.object(forKey: Constants.userCountDefaultsKey) as? Int) ?? 0
39 | value += 1
40 | userDefaults.set(value, forKey: Constants.userCountDefaultsKey)
41 | ```
42 |
43 | This is much better because you can be safe knowing that you're using the correct key, but we can do better.
44 |
45 | Similar to Foundation's `Notification.Name`, SwiftUserDefaults provides a new `UserDefaults.Key` type that acts as a namespace for you to provide your own constants that can be conveniently used around your app without having to worry about typos or other issues that might occur during refactoring.
46 |
47 | ```swift
48 | import Foundation
49 | import SwiftUserDefaults
50 |
51 | extension UserDefaults.Key {
52 | /// The number of users interacted with.
53 | static let userCount = Self("UserCount")
54 |
55 | /// The name of the user.
56 | static let userName = Self("UserName")
57 |
58 | /// The last visit.
59 | static let lastVisit = Self("LastVisit")
60 | }
61 | ```
62 |
63 | SwiftUserDefaults then provides a series of additional APIs built on top of this type. Continue reading to learn how to use them.
64 |
65 | ## 🦺 Type Safety
66 |
67 | When using `UserDefaults`, you must only attempt to set booleans, data, dates, numbers or strings, as well as dictionaries or arrays consisting of those types otherwise you'll experience a runtime crash with no protections from the Compiler.
68 |
69 | SwiftUserDefaults provides safer APIs that combined with `UserDefaults.Key` offer a much safer experience with `UserDefaults`:
70 |
71 | ```swift
72 | let userDefaults = UserDefaults.standard
73 | var value = userDefaults.x.object(Int.self, forKey: .userCount) ?? 0
74 | value += 1
75 | userDefaults.x.set(value, forKey: .userCount)
76 | ```
77 |
78 | In the above example, the `key` argument uses `UserDefaults.Key` constants and the value is automatically cast to a known type all by accessing the safer API via the `x` extension.
79 |
80 | Additionally, the compiler can help to catch mistakes when passing unsupported types into `set(_:forKey:)`.
81 |
82 |
83 | ```swift
84 | struct User {
85 | let id: UUID
86 | }
87 |
88 | func updateCurrentUser(_ user: User) {
89 | // ❌ Runtime Crash
90 | userDefaults.set(user.id, forKey: "UserId")
91 | // SIGABRT
92 | //
93 | // Attempt to insert non-property list object
94 | // DAE8F83E-5760-475D-B28D-D493F695E765 for key UserId
95 |
96 | // ✅ Compile Time Error
97 | userDefaults.x.set(user.id, forKey: .userId)
98 | // Instance method 'set(_:forKey:)' requires that 'UUID' conform to 'UserDefaultsStorable'
99 | }
100 | ```
101 |
102 | ## 🔍 Observations
103 |
104 | `UserDefaults` is key-value observing compliant however you can't use Swift's key-path based overlay since the stored defaults don't associate to actual properties. SwiftUserDefaults helps solve this problem by providing a wrapper around the Objective C based KVO methods:
105 |
106 | ```swift
107 | import Foundation
108 | import SwiftUserDefaults
109 |
110 | class MyViewController: UIViewController {
111 | let store = UserDefaults.standard
112 | var observation: UserDefaults.Observation?
113 |
114 | // ...
115 |
116 | override func viewDidLoad() {
117 | super.viewDidLoad()
118 |
119 | // ...
120 |
121 | observation = store.x.observeObject(String.self, forKey: .userName) { change in
122 | self.nameLabel.text = change.value
123 | }
124 | }
125 |
126 | deinit {
127 | observation?.invalidate()
128 | }
129 | }
130 | ```
131 |
132 | The `change` property is the `UserDefaults.Change` enum which consists of two cases to represent both the `.initial` value and any subsequent `.update`'s. If you don't care about this, you can access the underlying value via the `value` property.
133 |
134 | ## 👩💻 Codable and RawRepresentable Support
135 |
136 | In addition to supporting the default value types for `UserDefaults`, convenience methods have also been provided to facilitate the use of `Codable` and `RawRepresentable` types (including enums).
137 |
138 | For `RawRepresentable` types, you can use them exactly like `String` and `Int` values and SwiftUserDefaults will automatically read and write the `rawValue` to the underlying store:
139 |
140 | ```swift
141 | enum Tab: String { // String and Int backed enum's are `RawRepresentable`.
142 | case home, search, create
143 | }
144 |
145 | let initialTab = userDefaults.x.object(Tab.self, forKey: .lastTab) ?? .home
146 | showTab(initialTab)
147 |
148 | // ...
149 |
150 | func tabDidChange(_ tab: Tab) {
151 | userDefaults.x.set(tab, forKey: .lastTab)
152 | }
153 | ```
154 |
155 | For `Codable` types, you pass an additional `CodingStrategy` parameter (`.json` or `.plist`) to dictate the format of encoding to use when reading and writing the value:
156 |
157 | ```swift
158 | struct Activity: Codable {
159 | let id: UUID
160 | let name: String
161 | }
162 |
163 | let restoredActivity = userDefaults.x.object(Activity.self, forKey: .currentActivity, strategy: .json)
164 |
165 | func showActivity(_ activity: Activity) {
166 | userDefaults.x.set(activity, forKey: .currentActivity, strategy: .json)
167 | }
168 | ```
169 |
170 | > ⚠️ **Warning:** While these APIs can make it tempting to encode large models to `UserDefaults`, you should continue to remember that some platforms have strict limits for the size of the `UserDefaults` store.
171 | >
172 | > For more information, see the official [Apple Developer Documentation](https://developer.apple.com/documentation/foundation/userdefaults/1617187-sizelimitexceedednotification).
173 |
174 | ## 🧪 Mocking in UI Tests
175 |
176 | SwiftUserDefaults provides a structured way to inject values into `UserDefaults` of your App target from the UI Testing target. This works by formatting a payload of launch arguments that `UserDefaults` will read into the [`NSArgumentDomain`](https://developer.apple.com/documentation/foundation/nsargumentdomain).
177 |
178 | ### MyAppCommon Target
179 |
180 | ```swift
181 | import SwiftUserDefaults
182 |
183 | extension UserDefaults.Key {
184 | /// The current level of the user
185 | public static let currentLevel = Self("CurrentLevel")
186 | /// The name of the user using the app
187 | public static let userName = Self("UserName")
188 | /// The unique identifier assigned to this user
189 | public static let userGUID = Self("UserGUID")
190 | }
191 |
192 | ```
193 |
194 | ### MyAppUITests Target
195 |
196 | ```swift
197 | import MyAppCommon
198 | import SwiftUserDefaults
199 | import XCTest
200 |
201 | struct MyAppConfiguration: LaunchArgumentEncodable {
202 | @UserDefaultOverride(.currentLevel)
203 | var currentLevel: Int?
204 |
205 | @UserDefaultOverride(.userName)
206 | var userName: String?
207 |
208 | @UserDefaultOverride(.userGUID)
209 | var userGUID = "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"
210 | }
211 |
212 | final class MyAppTests: XCTestCase {
213 | func testMyApp() throws {
214 | var configuration = MyAppConfiguration()
215 | container.currentLevel = 8
216 | container.userName = "John Doe"
217 |
218 | let app = XCUIApplication()
219 | app.launchArguments = try configuration.encodeLaunchArguments()
220 | app.launch()
221 |
222 | // ...
223 | }
224 | }
225 | ```
226 |
227 | ### MyApp Target
228 |
229 | ```swift
230 | import SwiftUserDefaults
231 | import UIKit
232 |
233 | class ViewController: UIViewController {
234 | // ...
235 |
236 | override func viewDidLoad() {
237 | super.viewDidLoad()
238 |
239 | let store = UserDefaults.standard
240 | store.x.object(Int.self, for: .currentLevel) // 8
241 | store.x.object(String.self, for: .userName) // "John Doe"
242 | store.x.object(String.self, for: .userGUID) // "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"
243 | }
244 | }
245 | ```
246 |
247 | ## 🎁 Property Wrappers
248 |
249 | SwiftUserDefaults brings `UserDefaults.Key` to SwiftUI's `@AppStorage` property wrapper, and in addition, it introduces an `@UserDefault` property wrapper with similar behavior that is suitable outside of SwiftUI.
250 |
251 | The simplest way to use the property wrapper is as follows:
252 |
253 | ```swift
254 | import SwiftUserDefaults
255 |
256 | class MyStore {
257 | @UserDefault(.userName)
258 | var userName: String?
259 |
260 | @UserDefault(.currentLevel)
261 | var currentLevel: Int = 1
262 |
263 | @UserDefault(.difficulty)
264 | var difficulty: Difficulty = .medium
265 | }
266 | ```
267 |
268 | If you need to be able to inject dependencies into `MyStore`, you can also do so as follows:
269 |
270 | ```swift
271 | import SwiftUserDefaults
272 |
273 | class MyStore {
274 | @UserDefault var userName: String?
275 | @UserDefault var currentLevel: Int
276 | @UserDefault var difficulty: Difficulty
277 |
278 | init(userDefaults store: UserDefaults) {
279 | _userName = UserDefault(.userName, store: store)
280 | _currentLevel = UserDefault(.currentLevel, store: store, defaultValue: 1)
281 | _difficulty = UserDefault(.difficulty, store: store, defaultValue: .medium)
282 | }
283 | }
284 | ```
285 |
286 | Finally, through the projected value, `@UserDefault` allows you to reset and observe the stored value:
287 |
288 | ```swift
289 | let store = MyStore(userDefaults: .standard)
290 |
291 | // Removes the value from user defaults
292 | store.$userName.reset()
293 |
294 | // Observes the user default, respecting the default value
295 | let observer = store.$currentLevel.addObserver { change in
296 | change.value // Int, 1
297 | }
298 | ```
299 |
300 | As with the `UserDefault.X` APIs, the property wrapper supports primitive, `RawRepresentable` and `Codable` types.
301 |
302 | # Installation
303 |
304 | ## CocoaPods
305 |
306 | [CocoaPods](https://cocoapods.org) is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate SwiftUserDefaults into your Xcode project using CocoaPods, specify it in your `Podfile`:
307 |
308 | ```ruby
309 | pod 'swift-user-defaults'
310 | ```
311 |
312 | ## Swift Package Manager
313 |
314 | Add the following to your **Package.swift**
315 |
316 | ```swift
317 | dependencies: [
318 | .package(url: "https://github.com/cookpad/swift-user-defaults.git", .upToNextMajor(from: "0.1.0"))
319 | ]
320 | ```
321 |
322 | Or use the https://github.com/cookpad/swift-user-defaults.git repository link in Xcode.
323 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/AppStorage+Key.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | #if canImport(SwiftUI)
24 | import SwiftUI
25 |
26 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
27 | public extension AppStorage {
28 | /// Creates a property that can read and write to a boolean user default.
29 | ///
30 | /// - Parameters:
31 | /// - wrappedValue: The default value if a boolean value is not specifier for the given key.
32 | /// - key: The key to read and write the value to in the user defaults store.
33 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
34 | init(wrappedValue: Value, _ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == Bool {
35 | self.init(wrappedValue: wrappedValue, key.rawValue, store: store)
36 | }
37 |
38 | /// Creates a property that can read and write to an integer user default.
39 | ///
40 | /// - Parameters:
41 | /// - wrappedValue: The default value if an integer value is not specified for the given key.
42 | /// - key: The key to read and write the value to in the user defaults store.
43 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
44 | init(wrappedValue: Value, _ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == Int {
45 | self.init(wrappedValue: wrappedValue, key.rawValue, store: store)
46 | }
47 |
48 | /// Creates a property that can read and write to a double user default.
49 | ///
50 | /// - Parameters:
51 | /// - wrappedValue: The default value if a double value is not specified for the given key.
52 | /// - key: The key to read and write the value to in the user defaults store.
53 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
54 | init(wrappedValue: Value, _ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == Double {
55 | self.init(wrappedValue: wrappedValue, key.rawValue, store: store)
56 | }
57 |
58 | /// Creates a property that can read and write to a string user default.
59 | ///
60 | /// - Parameters:
61 | /// - wrappedValue: The default value if a string value is not specified for the given key.
62 | /// - key: The key to read and write the value to in the user defaults store.
63 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
64 | init(wrappedValue: Value, _ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == String {
65 | self.init(wrappedValue: wrappedValue, key.rawValue, store: store)
66 | }
67 |
68 | /// Creates a property that can read and write to a url user default.
69 | ///
70 | /// - Parameters:
71 | /// - wrappedValue: The default value if a url value is not specified for the given key.
72 | /// - key: The key to read and write the value to in the user defaults store.
73 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
74 | init(wrappedValue: Value, _ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == URL {
75 | self.init(wrappedValue: wrappedValue, key.rawValue, store: store)
76 | }
77 |
78 | /// Creates a property that can read and write to a user default as data.
79 | ///
80 | /// Avoid storing large data blobs in user defaults, such as image data, as it can negatively affect performance of your app. On tvOS, a `NSUserDefaultsSizeLimitExceededNotification` notification is posted if the total user default size reaches 512kB.
81 | ///
82 | /// - Parameters:
83 | /// - wrappedValue: The default value if a data value is not specified for the given key.
84 | /// - key: The key to read and write the value to in the user defaults store.
85 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
86 | init(wrappedValue: Value, _ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == Data {
87 | self.init(wrappedValue: wrappedValue, key.rawValue, store: store)
88 | }
89 |
90 | /// Creates a property that can read and write to an integer user default, transforming that to `RawRepresentable` data type.
91 | ///
92 | /// A common usage is with enumerations:
93 | ///
94 | /// ```swift
95 | /// enum MyEnum: Int {
96 | /// case a
97 | /// case b
98 | /// case c
99 | /// }
100 | ///
101 | /// struct MyView: View {
102 | /// @AppStorage("MyEnumValue") private var value = MyEnum.a
103 | /// var body: some View { ... }
104 | /// }
105 | /// ```
106 | ///
107 | /// - Parameters:
108 | /// - wrappedValue: The default value if an integer value is not specified for the given key.
109 | /// - key: The key to read and write the value to in the user defaults store.
110 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
111 | init(wrappedValue: Value, _ key: UserDefaults.Key, store: UserDefaults? = nil) where Value : RawRepresentable, Value.RawValue == Int {
112 | self.init(wrappedValue: wrappedValue, key.rawValue, store: store)
113 | }
114 |
115 | /// Creates a property that can read and write to a string user default, transforming that to `RawRepresentable` data type.
116 | ///
117 | /// A common usage is with enumerations:
118 | ///
119 | /// ```swift
120 | /// enum MyEnum: String {
121 | /// case a
122 | /// case b
123 | /// case c
124 | /// }
125 | ///
126 | /// struct MyView: View {
127 | /// @AppStorage("MyEnumValue") private var value = MyEnum.a
128 | /// var body: some View { ... }
129 | /// }
130 | /// ```
131 | ///
132 | /// - Parameters:
133 | /// - wrappedValue: The default value if a string value is not specified for the given key.
134 | /// - key: The key to read and write the value to in the user defaults store.
135 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
136 | init(wrappedValue: Value, _ key: UserDefaults.Key, store: UserDefaults? = nil) where Value : RawRepresentable, Value.RawValue == String {
137 | self.init(wrappedValue: wrappedValue, key.rawValue, store: store)
138 | }
139 | }
140 |
141 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
142 | public extension AppStorage where Value : ExpressibleByNilLiteral {
143 | /// Creates a property that can read and write an Optional boolean user default.
144 | ///
145 | /// Defaults to nil if there is no restored value.
146 | ///
147 | /// - Parameters:
148 | /// - key: The key to read and write the value to in the user defaults store.
149 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
150 | init(_ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == Bool? {
151 | self.init(key.rawValue, store: store)
152 | }
153 |
154 | /// Creates a property that can read and write an Optional integer user default.
155 | ///
156 | /// Defaults to nil if there is no restored value.
157 | ///
158 | /// - Parameters:
159 | /// - key: The key to read and write the value to in the user defaults store.
160 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
161 | init(_ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == Int? {
162 | self.init(key.rawValue, store: store)
163 | }
164 |
165 | /// Creates a property that can read and write an Optional double user default.
166 | ///
167 | /// Defaults to nil if there is no restored value.
168 | ///
169 | /// - Parameters:
170 | /// - key: The key to read and write the value to in the user defaults store.
171 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
172 | init(_ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == Double? {
173 | self.init(key.rawValue, store: store)
174 | }
175 |
176 | /// Creates a property that can read and write an Optional string user default.
177 | ///
178 | /// Defaults to nil if there is no restored value.
179 | ///
180 | /// - Parameters:
181 | /// - key: The key to read and write the value to in the user defaults store.
182 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
183 | init(_ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == String? {
184 | self.init(key.rawValue, store: store)
185 | }
186 |
187 | /// Creates a property that can read and write an Optional URL user
188 | /// default.
189 | ///
190 | /// Defaults to nil if there is no restored value.
191 | ///
192 | /// - Parameters:
193 | /// - key: The key to read and write the value to in the user defaults store.
194 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
195 | init(_ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == URL? {
196 | self.init(key.rawValue, store: store)
197 | }
198 |
199 | /// Creates a property that can read and write an Optional data user default.
200 | ///
201 | /// Defaults to nil if there is no restored value.
202 | ///
203 | /// - Parameters:
204 | /// - key: The key to read and write the value to in the user defaults store.
205 | /// - store: The user defaults store to read and write to. A value of `nil` will use the user default store from the environment.
206 | init(_ key: UserDefaults.Key, store: UserDefaults? = nil) where Value == Data? {
207 | self.init(key.rawValue, store: store)
208 | }
209 | }
210 | #endif
211 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/LaunchArgumentEncodable.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A protocol used by container types that can have their representations encoded into launch arguments via the ``encodeLaunchArguments()`` method.
4 | ///
5 | /// This protocol works exclusively in conjunction with the ``UserDefaultOverride`` property wrapper.
6 | public protocol LaunchArgumentEncodable {
7 | /// Additional values to be appended to the result of `collectLaunchArguments()`.
8 | ///
9 | /// A default implementation is provided that returns an empty array.
10 | var additionalLaunchArguments: [String] { get }
11 |
12 | /// An array of types that represent UserDefault key/value overrides to be converted into launch arguments.
13 | ///
14 | /// A default implementation is provided that uses reflection to collect these values from the receiver.
15 | /// You are free to override and provide your own implementation if you would prefer.
16 | var userDefaultOverrides: [UserDefaultOverrideRepresentable] { get }
17 | }
18 |
19 | public extension LaunchArgumentEncodable {
20 | var additionalLaunchArguments: [String] {
21 | []
22 | }
23 |
24 | /// Uses reflection to collect properties that conform to `UserDefaultOverrideRepresentable` from the receiver.
25 | var userDefaultOverrides: [UserDefaultOverrideRepresentable] {
26 | Mirror(reflecting: self)
27 | .children
28 | .compactMap { $0.value as? UserDefaultOverrideRepresentable }
29 | }
30 |
31 | /// Collects the complete array of launch arguments from the receiver.
32 | ///
33 | /// The contents of the return value is built by using Reflection to look for all `@UserDefaultOverride` property wrapper instances. See ``UserDefaultOverride`` for more information.
34 | ///
35 | /// In addition to overrides, the contents of `additionalLaunchArguments` is appended to the return value.
36 | func encodeLaunchArguments() throws -> [String] {
37 | // Map the overrides into a container
38 | var container = UserDefaults.ValueContainer()
39 | for userDefaultOverride in userDefaultOverrides {
40 | // Add the storable value into the container only if it wasn't nil
41 | guard let value = try userDefaultOverride.getValue() else { continue }
42 | container.set(value, forKey: userDefaultOverride.key)
43 | }
44 |
45 | // Return the collected user default overrides along with any additional arguments
46 | return container.launchArguments + additionalLaunchArguments
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPrivacyTracking
6 |
7 | NSPrivacyTrackingDomains
8 |
9 | NSPrivacyCollectedDataTypes
10 |
11 | NSPrivacyAccessedAPITypes
12 |
13 |
14 | NSPrivacyAccessedAPIType
15 | NSPrivacyAccessedAPICategoryUserDefaults
16 | NSPrivacyAccessedAPITypeReasons
17 |
18 | C56D.1
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/UserDefault.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import Foundation
24 |
25 | /// A property wrapper that uses an instance of `UserDefaults` for the storage mechanism.
26 | @propertyWrapper
27 | public struct UserDefault {
28 | let getValue: () -> Value
29 | let setValue: (Value) -> Void
30 | let resetValue: () -> Void
31 | let observeValue: (@escaping (UserDefaults.Change) -> Void) -> UserDefaults.Observation
32 |
33 | public var wrappedValue: Value {
34 | get {
35 | getValue()
36 | }
37 | nonmutating set {
38 | setValue(newValue)
39 | }
40 | }
41 |
42 | public var projectedValue: Self {
43 | self
44 | }
45 |
46 | /// Removes any previously stored value from `UserDefaults` resetting the wrapped value to either `nil` or its default value.
47 | public func reset() {
48 | resetValue()
49 | }
50 |
51 | /// Observes changes to the specified user default in the underlying database.
52 | ///
53 | /// - Parameter handler: A closure invoked whenever the observed value is modified.
54 | /// - Returns: A token object to be used to invalidate the observation by either deallocating the value or calling `invalidate()`.
55 | public func addObserver(handler: @escaping (UserDefaults.Change) -> Void) -> UserDefaults.Observation {
56 | observeValue(handler)
57 | }
58 | }
59 |
60 | // MARK: - Default Value
61 | public extension UserDefault {
62 | /// Creates a property that can read and write a user default with a default value.
63 | ///
64 | /// ```swift
65 | /// @UserDefault(.userHasViewedProfile)
66 | /// var userHasViewedProfile: Bool = false
67 | /// ```
68 | ///
69 | /// - Parameters:
70 | /// - defaultValue: The default value used when a value is not stored.
71 | /// - key: The key to read and write the value to in the user defaults store.
72 | /// - userDefaults: The instance of `UserDefaults` used for storing the value. Defaults to `UserDefaults.standard`.
73 | init(
74 | wrappedValue defaultValue: Value,
75 | _ key: UserDefaults.Key,
76 | store userDefaults: UserDefaults = .standard
77 | ) where Value: UserDefaultsStorable {
78 | self.init(
79 | getValue: {
80 | userDefaults.x.object(forKey: key) ?? defaultValue
81 | },
82 | setValue: { value in
83 | userDefaults.x.set(value, forKey: key)
84 | },
85 | resetValue: {
86 | userDefaults.x.removeObject(forKey: key)
87 | },
88 | observeValue: { handler in
89 | userDefaults.x.observeObject(Value.self, forKey: key) { change in
90 | handler(change.map({ $0 ?? defaultValue }))
91 | }
92 | }
93 | )
94 | }
95 |
96 | /// Creates a property that can read and write a user default with a default value.
97 | ///
98 | /// ```swift
99 | /// enum State: String {
100 | /// case unregistered, registered
101 | /// }
102 | ///
103 | /// @UserDefault(.state)
104 | /// var state: State = .unregistered
105 | /// ```
106 | ///
107 | /// - Parameters:
108 | /// - defaultValue: The default value used when a value is not stored.
109 | /// - key: The key to read and write the value to in the user defaults store.
110 | /// - userDefaults: The instance of `UserDefaults` used for storing the value. Defaults to `UserDefaults.standard`.
111 | init(
112 | wrappedValue defaultValue: Value,
113 | _ key: UserDefaults.Key,
114 | store userDefaults: UserDefaults = .standard
115 | ) where Value: RawRepresentable, Value.RawValue: UserDefaultsStorable {
116 | self.init(
117 | getValue: {
118 | userDefaults.x.object(forKey: key) ?? defaultValue
119 | },
120 | setValue: { value in
121 | userDefaults.x.set(value, forKey: key)
122 | },
123 | resetValue: {
124 | userDefaults.x.removeObject(forKey: key)
125 | },
126 | observeValue: { handler in
127 | userDefaults.x.observeObject(Value.self, forKey: key) { change in
128 | handler(change.map({ $0 ?? defaultValue }))
129 | }
130 | }
131 | )
132 | }
133 |
134 | /// Creates a property that can read and write a user default using a custom coding strategy.
135 | ///
136 | /// In the example below, the `ProfileSummary` type conforms to the `Codable` protocol
137 | /// and is read/written from `UserDefaults` as JSON encoded `Data`.
138 | ///
139 | /// ```swift
140 | /// @UserDefault(.profileSummary, strategy: .json)
141 | /// var profileSummary = ProfileSummary()
142 | /// ```
143 | ///
144 | /// - Parameters:
145 | /// - defaultValue: The default value used when a value is not stored.
146 | /// - key: The key to read and write the value to in the user defaults store.
147 | /// - strategy: The custom coding strategy used when decoding the stored data.
148 | /// - userDefaults: The instance of `UserDefaults` used for storing the value. Defaults to `UserDefaults.standard`.
149 | init(
150 | wrappedValue defaultValue: Value,
151 | _ key: UserDefaults.Key,
152 | strategy: UserDefaults.CodingStrategy,
153 | store userDefaults: UserDefaults = .standard
154 | ) where Value: Codable {
155 | self.init(
156 | getValue: {
157 | userDefaults.x.object(forKey: key, strategy: strategy) ?? defaultValue
158 | },
159 | setValue: { value in
160 | userDefaults.x.set(value, forKey: key, strategy: strategy)
161 | },
162 | resetValue: {
163 | userDefaults.x.removeObject(forKey: key)
164 | },
165 | observeValue: { handler in
166 | userDefaults.x.observeObject(forKey: key, strategy: strategy) { change in
167 | handler(change.map({ $0 ?? defaultValue }))
168 | }
169 | }
170 | )
171 | }
172 | }
173 |
174 | // MARK: - Direct Usage (convenience)
175 | public extension UserDefault {
176 | /// Creates a property that can read and write a user default with a default value.
177 | ///
178 | /// This initialiser is more suitable when creating a property wrapper using injected values:
179 | ///
180 | /// ```swift
181 | /// @UserDefault
182 | /// var userHasViewedProfile: Bool
183 | ///
184 | /// init(userDefaults: UserDefaults) {
185 | /// _userHasViewedProfile = UserDefault(.userHasViewedProfile, store: userDefaults, defaultValue: false)
186 | /// }
187 | /// ```
188 | ///
189 | /// - Parameters:
190 | /// - key: The key to read and write the value to in the user defaults store.
191 | /// - userDefaults: The instance of `UserDefaults` used for storing the value. Defaults to `UserDefaults.standard`.
192 | /// - defaultValue: The default value used when a value is not stored.
193 | init(
194 | _ key: UserDefaults.Key,
195 | store userDefaults: UserDefaults = .standard,
196 | defaultValue: Value
197 | ) where Value: UserDefaultsStorable {
198 | self.init(wrappedValue: defaultValue, key, store: userDefaults)
199 | }
200 |
201 | /// Creates a property that can read and write a user default with a default value.
202 | ///
203 | /// This initialiser is more suitable when creating a property wrapper using injected values:
204 | ///
205 | /// ```swift
206 | /// enum State: String {
207 | /// case unregistered, registered
208 | /// }
209 | ///
210 | /// @UserDefault
211 | /// var state: State
212 | ///
213 | /// init(userDefaults: UserDefaults) {
214 | /// _state = UserDefault(.state, store: userDefaults, defaultValue: .unregistered)
215 | /// }
216 | /// ```
217 | ///
218 | /// - Parameters:
219 | /// - key: The key to read and write the value to in the user defaults store.
220 | /// - userDefaults: The instance of `UserDefaults` used for storing the value. Defaults to `UserDefaults.standard`.
221 | /// - defaultValue: The default value used when a value is not stored.
222 | init(
223 | _ key: UserDefaults.Key,
224 | store userDefaults: UserDefaults = .standard,
225 | defaultValue: Value
226 | ) where Value: RawRepresentable, Value.RawValue: UserDefaultsStorable {
227 | self.init(wrappedValue: defaultValue, key, store: userDefaults)
228 | }
229 |
230 | /// Creates a property that can read and write a user default using a custom coding strategy.
231 | ///
232 | /// In the example below, the `ProfileSummary` type conforms to the `Codable` protocol
233 | /// and is read/written from `UserDefaults` as JSON encoded `Data`.
234 | ///
235 | /// ```swift
236 | /// @UserDefault
237 | /// var profileSummary: ProfileSummary
238 | ///
239 | /// init(userDefaults: UserDefaults) {
240 | /// _profileSummary = UserDefault(.profileSummary, store: userDefaults, defaultValue: ProfileSummary())
241 | /// }
242 | /// ```
243 | ///
244 | /// - Parameters:
245 | /// - key: The key to read and write the value to in the user defaults store.
246 | /// - strategy: The custom coding strategy used when decoding the stored data.
247 | /// - userDefaults: The instance of `UserDefaults` used for storing the value. Defaults to `UserDefaults.standard`.
248 | /// - defaultValue: The default value used when a value is not stored.
249 | init(
250 | _ key: UserDefaults.Key,
251 | strategy: UserDefaults.CodingStrategy,
252 | store userDefaults: UserDefaults = .standard,
253 | defaultValue: Value
254 | ) where Value: Codable {
255 | self.init(wrappedValue: defaultValue, key, strategy: strategy, store: userDefaults)
256 | }
257 | }
258 |
259 | // MARK: - Optionals
260 | public extension UserDefault where Value: ExpressibleByNilLiteral {
261 | /// Creates a property that can read and write an Optional user default.
262 | ///
263 | /// ```swift
264 | /// @UserDefault(.userName)
265 | /// var userName: String?
266 | /// ```
267 | ///
268 | /// - Parameters:
269 | /// - key: The key to read and write the value to in the user defaults store.
270 | /// - userDefaults: The instance of `UserDefaults` used for storing the value. Defaults to `UserDefaults.standard`.
271 | init(
272 | _ key: UserDefaults.Key,
273 | store userDefaults: UserDefaults = .standard
274 | ) where Value == T? {
275 | self.init(
276 | getValue: {
277 | userDefaults.x.object(forKey: key)
278 | },
279 | setValue: { value in
280 | if let value = value {
281 | userDefaults.x.set(value, forKey: key)
282 | } else {
283 | userDefaults.x.removeObject(forKey: key)
284 | }
285 | },
286 | resetValue: {
287 | userDefaults.x.removeObject(forKey: key)
288 | },
289 | observeValue: { handler in
290 | userDefaults.x.observeObject(forKey: key, handler: handler)
291 | }
292 | )
293 | }
294 |
295 | /// Creates a property that can read and write an Optional user default.
296 | ///
297 | /// ```swift
298 | /// struct PaymentType: RawRepresentable {
299 | /// let rawValue: String
300 | ///
301 | /// init(rawValue: String) {
302 | /// self.rawValue = rawValue
303 | /// }
304 | /// }
305 | ///
306 | /// @UserDefault(.lastPaymentType)
307 | /// var lastPaymentType: PaymentType?
308 | /// ```
309 | ///
310 | /// - Parameters:
311 | /// - key: The key to read and write the value to in the user defaults store.
312 | /// - userDefaults: The instance of `UserDefaults` used for storing the value. Defaults to `UserDefaults.standard`.
313 | init(
314 | _ key: UserDefaults.Key,
315 | store userDefaults: UserDefaults = .standard
316 | ) where T.RawValue: UserDefaultsStorable, Value == T? {
317 | self.init(
318 | getValue: {
319 | userDefaults.x.object(forKey: key)
320 | },
321 | setValue: { value in
322 | if let value = value {
323 | userDefaults.x.set(value, forKey: key)
324 | } else {
325 | userDefaults.x.removeObject(forKey: key)
326 | }
327 | },
328 | resetValue: {
329 | userDefaults.x.removeObject(forKey: key)
330 | },
331 | observeValue: { handler in
332 | userDefaults.x.observeObject(forKey: key, handler: handler)
333 | }
334 | )
335 | }
336 |
337 | /// Creates a property that can read and write an Optional user default using a custom coding strategy.
338 | ///
339 | /// ```swift
340 | /// @UserDefault(.profileSummary, strategy: .plist)
341 | /// var userName: ProfileSummary?
342 | /// ```
343 | ///
344 | /// - Parameters:
345 | /// - key: The key to read and write the value to in the user defaults store.
346 | /// - strategy: The custom coding strategy used when decoding the stored data.
347 | /// - userDefaults: The instance of `UserDefaults` used for storing the value. Defaults to `UserDefaults.standard`.
348 | init(
349 | _ key: UserDefaults.Key,
350 | strategy: UserDefaults.CodingStrategy,
351 | store userDefaults: UserDefaults = .standard
352 | ) where Value == T? {
353 | self.init(
354 | getValue: {
355 | userDefaults.x.object(forKey: key, strategy: strategy)
356 | },
357 | setValue: { value in
358 | if let value = value {
359 | userDefaults.x.set(value, forKey: key, strategy: strategy)
360 | } else {
361 | userDefaults.x.removeObject(forKey: key)
362 | }
363 | },
364 | resetValue: {
365 | userDefaults.x.removeObject(forKey: key)
366 | },
367 | observeValue: { handler in
368 | userDefaults.x.observeObject(forKey: key, strategy: strategy, handler: handler)
369 | }
370 | )
371 | }
372 | }
373 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/UserDefaultOverride.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A protocol to help erase generic type information from ``UserDefaultOverride`` when attempting to obtain the key value pair.
4 | public protocol UserDefaultOverrideRepresentable {
5 | /// The key of the user default value that should be overidden.
6 | var key: UserDefaults.Key { get }
7 |
8 | /// The value of the user default value that shoul be overridden, or nil if an override should not be applied.
9 | func getValue() throws -> UserDefaultsStorable?
10 | }
11 |
12 | /// A property wrapper used for marking types as a value that should be used as an override in `UserDefaults`.
13 | ///
14 | /// On its own, `@UserDefaultOverride` or `LaunchOverrides` cannot override values stored in `UserDefaults`, but they can provide an array of launch arguments that you can then pass to a process. There are two scenarios where you might find this useful:
15 | ///
16 | /// 1. Running UI Tests via XCTest, you might set `XCUIApplication`'s `launchArguments` array before calling `launch()`.
17 | /// 2. Invoking a `Process`, you might pass values to the `arguments` array.
18 | ///
19 | /// **UI Test Example**
20 | ///
21 | /// When using SwiftUserDefaults, if you define `UserDefaults.Key` definitions and other model types in a separate framework target (in this example, `MyFramework`), you can then share them between your application target and your UI test target:
22 | ///
23 | /// ```swift
24 | /// import SwiftUserDefaults
25 | ///
26 | /// public extension UserDefaults.Key {
27 | /// public static let user = Self("User")
28 | /// public static let state = Self("State")
29 | /// public static let isLegacyUser = Self("LegacyUser")
30 | /// }
31 | ///
32 | /// public struct User: Codable {
33 | /// public var name: String
34 | ///
35 | /// public init(name: String) {
36 | /// self.name = name
37 | /// }
38 | /// }
39 | ///
40 | /// public enum State: String {
41 | /// case registered, unregistered
42 | /// }
43 | /// ```
44 | /// To easily manage overrides in your UI Testing target, import your framework target and define a container that conforms to `LaunchArgumentEncodable`. In this container, use the `@UserDefaultOverride` property wrapper to build up a configuration of overrides that match usage in your app:
45 | ///
46 | /// ```swift
47 | /// import MyFramework
48 | /// import SwiftUserDefaults
49 | ///
50 | /// struct AppConfiguration: LaunchArgumentEncodable {
51 | /// // An optional Codable property, encoded to data using the `.plist` strategy.
52 | /// @UserDefaultOverride(.user, strategy: .plist)
53 | /// var user: User?
54 | ///
55 | /// // A RawRepresentable enum with a default value, encoded to it's backing `rawValue` (a String).
56 | /// @UserDefaultOverride(.state)
57 | /// var state: State = .unregistered
58 | ///
59 | /// // An optional primitive type (Bool). When `nil`, values will not be used as an override since null cannot be represented.
60 | /// @UserDefaultOverride(.isLegacyUser)
61 | /// var isLegacyUser: Bool?
62 | ///
63 | /// // A convenient place to define other launch arguments that don't relate to `UserDefaults`.
64 | /// var additionalLaunchArguments: [String] {
65 | /// ["UI-Testing"]
66 | /// }
67 | /// }
68 | /// ```
69 | ///
70 | /// Finally, in your test cases, create and configure an instance of your container type and use the `collectLaunchArguments()` method to pass the overrides into your `XCUIApplication` and perform the UI tests like normal. The overrides will be picked up by `UserDefaults` instances in your app to help you in testing pre-configured states.
71 | ///
72 | /// ```swift
73 | /// import SwiftUserDefaults
74 | /// import XCTest
75 | ///
76 | /// class MyAppUITestCase: XCTestCase {
77 | /// func testScenario() throws {
78 | /// // Create a configuration, update the overrides
79 | /// var configuration = AppConfiguration()
80 | /// configuration.user = User(name: "John")
81 | /// configuration.state = .registered
82 | ///
83 | /// // Create the test app, assign the launch arguments and launch the process.
84 | /// let app = XCUIApplication()
85 | /// app.launchArguments = try configuration.encodeLaunchArguments()
86 | /// app.launch()
87 | ///
88 | /// // The launch arguments will look like the following:
89 | /// app.launchArguments
90 | /// // ["-User", "...", "-State", "registered", "UI-Testing"]
91 | ///
92 | /// // ...
93 | /// }
94 | /// }
95 | /// ```
96 | @propertyWrapper
97 | public struct UserDefaultOverride: UserDefaultOverrideRepresentable {
98 | let valueGetter: () -> Value
99 | let valueSetter: (Value) -> Void
100 | let storableValue: () throws -> UserDefaultsStorable?
101 |
102 | public let key: UserDefaults.Key
103 |
104 | public func getValue() throws -> UserDefaultsStorable? {
105 | try storableValue()
106 | }
107 |
108 | public var wrappedValue: Value {
109 | get {
110 | valueGetter()
111 | }
112 | set {
113 | valueSetter(newValue)
114 | }
115 | }
116 |
117 | public var projectedValue: UserDefaultOverrideRepresentable {
118 | self
119 | }
120 |
121 | init(
122 | wrappedValue defaultValue: Value,
123 | key: UserDefaults.Key,
124 | transform: @escaping (Value) throws -> UserDefaultsStorable?
125 | ) {
126 | var value: Value = defaultValue
127 |
128 | self.key = key
129 | self.valueGetter = { value }
130 | self.valueSetter = { value = $0 }
131 | self.storableValue = {
132 | guard let value = try transform(value) else { return nil }
133 | return value
134 | }
135 | }
136 |
137 | public init(
138 | wrappedValue defaultValue: Value,
139 | _ key: UserDefaults.Key
140 | ) where Value: UserDefaultsStorable {
141 | self.init(wrappedValue: defaultValue, key: key, transform: { $0 })
142 | }
143 |
144 | public init(
145 | wrappedValue defaultValue: Value = nil,
146 | _ key: UserDefaults.Key
147 | ) where Value == T? {
148 | self.init(wrappedValue: defaultValue, key: key, transform: { $0 })
149 | }
150 |
151 | public init(
152 | wrappedValue defaultValue: Value,
153 | _ key: UserDefaults.Key
154 | ) where Value: RawRepresentable, Value.RawValue: UserDefaultsStorable {
155 | self.init(wrappedValue: defaultValue, key: key, transform: { $0.rawValue })
156 | }
157 |
158 | public init(
159 | wrappedValue defaultValue: Value = nil,
160 | _ key: UserDefaults.Key
161 | ) where Value == T?, T.RawValue: UserDefaultsStorable {
162 | self.init(wrappedValue: defaultValue, key: key, transform: { $0?.rawValue })
163 | }
164 |
165 | public init(
166 | wrappedValue defaultValue: Value,
167 | _ key: UserDefaults.Key,
168 | strategy: UserDefaults.CodingStrategy
169 | ) where Value: Encodable {
170 | self.init(wrappedValue: defaultValue, key: key, transform: { try strategy.encode($0) })
171 | }
172 |
173 | public init(
174 | wrappedValue defaultValue: Value = nil,
175 | _ key: UserDefaults.Key,
176 | strategy: UserDefaults.CodingStrategy
177 | ) where Value == T? {
178 | self.init(wrappedValue: defaultValue, key: key, transform: { try $0.flatMap({ try strategy.encode($0) }) })
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/UserDefaults+CodingStrategy.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import Foundation
24 |
25 | public extension UserDefaults {
26 | /// Available strategies for serializing `Codable` types into `UserDefaults` as data blobs and reading them back again.
27 | enum CodingStrategy {
28 | /// Uses the default `JSONEncoder` and `JSONDecoder` types to map between data and a `Codable` type
29 | case json
30 |
31 | /// Uses the default `PropertyListEncoder` and `PropertyListDecoder` types to map between data and a `Codable` type
32 | case plist
33 | }
34 | }
35 |
36 | public extension UserDefaults.CodingStrategy {
37 | /// Encodes an instance of the indicated type.
38 | ///
39 | /// - Parameter value: The value to encode.
40 | /// - Throws: An error if any value throws an error during encoding.
41 | /// - Returns: A new `Data` value containing the encoded type using the receivers strategy.
42 | func encode(_ value: T) throws -> Data {
43 | switch self {
44 | case .json:
45 | let encoder = JSONEncoder()
46 | encoder.outputFormatting = .sortedKeys
47 | return try encoder.encode(value)
48 | case .plist:
49 | return try PropertyListEncoder().encode(value)
50 | }
51 | }
52 |
53 | /// Decodes an instance of the indicated type.
54 | ///
55 | /// - Parameters:
56 | /// - type: The type of the value to decode.
57 | /// - data: The data to decode from.
58 | /// - Throws: An error if any value throws an error during decoding.
59 | /// - Returns: The value of the requested type.
60 | func decode(_ type: T.Type = T.self, from data: Data) throws -> T {
61 | switch self {
62 | case .json:
63 | return try JSONDecoder().decode(T.self, from: data)
64 | case .plist:
65 | return try PropertyListDecoder().decode(T.self, from: data)
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/UserDefaults+Key.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import Foundation
24 |
25 | public extension UserDefaults {
26 | /// An alias for a type used to represent the key of a user default value.
27 | ///
28 | /// # Usage
29 | ///
30 | /// This type can be used as a namespace for defining type-safe key definitions within your own code.
31 | /// Simply create an extension and define your own static properties:
32 | ///
33 | /// ```swift
34 | /// import SwiftUserDefaults
35 | ///
36 | /// extension UserDefaults.Key {
37 | /// /// The current state of a user
38 | /// static let userState = Self("user_state")
39 | ///
40 | /// // ...
41 | /// }
42 | /// ```
43 | ///
44 | /// You can then use your custom defined keys with other API provided by SwiftUserDefaults:
45 | ///
46 | /// ```swift
47 | /// import SwiftUserDefaults
48 | /// import UIKit
49 | ///
50 | /// enum UserState: String {
51 | /// case idle, onboarding, active
52 | /// }
53 | ///
54 | /// class ViewController: UIViewController {
55 | /// @UserDefault(.userState, defaultValue: .idle)
56 | /// var userState: UserState
57 | /// }
58 | /// ```
59 | ///
60 | /// Or you can access the underlying value using the `rawValue` property should you need to:
61 | ///
62 | /// ```swift
63 | /// import Foundation
64 | /// import SwiftUserDefaults
65 | ///
66 | /// let rawValue = UserDefaults.standard.string(forKey: UserDefaults.Key.userState.rawValue)
67 | /// ```
68 | struct Key: RawRepresentable, Hashable {
69 | /// The underlying string value that is used for assigning a value against within the user defaults.
70 | public let rawValue: String
71 |
72 | public init(rawValue: String) {
73 | self.rawValue = rawValue
74 | }
75 |
76 | public init(_ rawValue: String) {
77 | self.rawValue = rawValue
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/UserDefaults+Observation.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import Foundation
24 |
25 | private var userDefaultsObserverContext = 0
26 |
27 | public extension UserDefaults {
28 | /// Observes changes to the object associated with the specified key.
29 | ///
30 | /// - Parameters:
31 | /// - key: The key of the object in `UserDefaults` to observe.
32 | /// - handler: A closure invoked whenever the observed value is modified.
33 | /// - Returns: A token object to be used to invalidate the observation by either deallocating the value or calling `invalidate()`.
34 | /// - Warning: The underline observation leverages `UserDefault`'s KVO compliance and as a result, requires that the `key`'s underlying `rawValue` is compliant with a key path. This typically means that the use of `.` can result in an observation not working. An assertion will be raised if the key is not valid.
35 | func observeObject(forKey key: String, handler: @escaping (Change) -> Void) -> Observation {
36 | assert(!key.contains("."), "Key '\(key)' is not suitable for observation")
37 | return Observation(userDefaults: self, keyPath: key, handler: handler)
38 | }
39 |
40 | // MARK: - Change
41 |
42 | /// Encapsulates change updates produced by an observer
43 | enum Change {
44 | /// The initial results of the observation.
45 | ///
46 | /// Does not signify a change but instead can be used as a base for comparison of future changes.
47 | case initial(T)
48 |
49 | /// Indicates that the observed collection has changed and includes the updated value.
50 | case update(T)
51 | }
52 |
53 | // MARK: - Observation
54 |
55 | /// An observation focused on a specific user default key.
56 | ///
57 | /// The `invalidate()` method will be called automatically when an `Observation` is deallocated.
58 | final class Observation: NSObject {
59 | let userDefaults: UserDefaults
60 | let keyPath: String
61 | let handler: (Change) -> Void
62 |
63 | private(set) var isRegistered: Bool = false
64 |
65 | init(
66 | userDefaults: UserDefaults,
67 | keyPath: String,
68 | handler: @escaping (Change) -> Void
69 | ) {
70 | self.userDefaults = userDefaults
71 | self.keyPath = keyPath
72 | self.handler = handler
73 |
74 | super.init()
75 |
76 | userDefaults.addObserver(
77 | self,
78 | forKeyPath: keyPath,
79 | options: [.initial, .new, .old],
80 | context: &userDefaultsObserverContext
81 | )
82 | isRegistered = true
83 | }
84 |
85 | // swiftlint:disable:next block_based_kvo
86 | public override func observeValue(
87 | forKeyPath keyPath: String?,
88 | of object: Any?,
89 | change: [NSKeyValueChangeKey: Any]?,
90 | context: UnsafeMutableRawPointer?
91 | ) {
92 | guard let change = change, context == &userDefaultsObserverContext else {
93 | super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
94 | return
95 | }
96 |
97 | // `change` contains `NSNull` if no value was stored so be sure to remove that.
98 | let value = change[.newKey] ?? NSNull()
99 | let actualValue: Any? = value is NSNull ? nil : value
100 |
101 | if change.keys.contains(.oldKey) {
102 | handler(.update(actualValue))
103 | } else {
104 | handler(.initial(actualValue))
105 | }
106 | }
107 |
108 | deinit {
109 | invalidate()
110 | }
111 |
112 | /// Stops observing the user defaults for changes.
113 | public func invalidate() {
114 | guard isRegistered else { return }
115 |
116 | userDefaults.removeObserver(self, forKeyPath: keyPath, context: &userDefaultsObserverContext)
117 | isRegistered = false
118 | }
119 | }
120 | }
121 |
122 | // MARK: - Change Util
123 |
124 | extension UserDefaults.Change: Equatable where T: Equatable {}
125 | extension UserDefaults.Change: Hashable where T: Hashable {}
126 |
127 | public extension UserDefaults.Change {
128 | /// Convenience property for returning the value. Useful in scenarios where you don't need to distinguish between the initial value or an update.
129 | var value: T {
130 | switch self {
131 | case .initial(let value), .update(let value):
132 | return value
133 | }
134 | }
135 |
136 | /// Returns a change containing the results of mapping the changes value using the given closure.
137 | ///
138 | /// - Parameter transform: A mapping closure. `transform` accepts the value of this change as its parameter and returns a transformed value of the same or of a different type.
139 | /// - Returns: A change with the transformed value
140 | func map(_ transform: (T) -> U) -> UserDefaults.Change {
141 | switch self {
142 | case .initial(let value):
143 | return .initial(transform(value))
144 | case .update(let value):
145 | return .update(transform(value))
146 | }
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/UserDefaults+ValueContainer.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import Foundation
24 |
25 | public extension UserDefaults {
26 | /// A container used for holding `UserDefaultsStorable` representations
27 | struct ValueContainer {
28 | /// The underlying contents of the container.
29 | public private(set) var contents: [UserDefaults.Key: UserDefaultsStorable]
30 |
31 | /// An array of keys in the order that they were applied
32 | private var order: [UserDefaults.Key] = []
33 |
34 | public init() {
35 | contents = [:]
36 | }
37 |
38 | // MARK: -
39 |
40 | /// Sets the value of the specified default key.
41 | ///
42 | /// - Parameters:
43 | /// - value: The object to store in the container.
44 | /// - key: The key with which to associate the value.
45 | public mutating func set(_ value: UserDefaultsStorable, forKey key: UserDefaults.Key) {
46 | contents[key] = value
47 | order.append(key)
48 | }
49 |
50 | /// Sets the value of the specified default key.
51 | ///
52 | /// - Parameters:
53 | /// - value: The object of which the `rawValue` should be stored in the container.
54 | /// - key: The key with which to associate the value.
55 | public mutating func set(_ value: T, forKey key: UserDefaults.Key) where T.RawValue: UserDefaultsStorable {
56 | set(value.rawValue, forKey: key)
57 | }
58 |
59 | /// Sets the value of the specified default key.
60 | ///
61 | /// - Parameters:
62 | /// - value: The object to store in the container.
63 | /// - key: The key with which to associate the value.
64 | /// - strategy: The custom coding strategy used when decoding the stored data.
65 | public mutating func set(_ value: T, forKey key: UserDefaults.Key, strategy: UserDefaults.CodingStrategy) throws {
66 | set(try strategy.encode(value), forKey: key)
67 | }
68 |
69 | // MARK: - Launch Arguments
70 |
71 | private func sortValue(for key: UserDefaults.Key) -> Int {
72 | order.lastIndex(of: key) ?? 0
73 | }
74 |
75 | /// An array of strings representing the contents of the container that can be passed into a process as launch arguments in order to be read into `UserDefaults`'s `NSArgumentDomain`.
76 | public var launchArguments: [String] {
77 | contents
78 | .sorted(by: { sortValue(for: $0.key) < sortValue(for: $1.key) })
79 | .reduce(into: Array()) { launchArguments, element in
80 | launchArguments.append("-" + element.key.rawValue)
81 | launchArguments.append(element.value.storableXMLValue)
82 | }
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/UserDefaults+X.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import Foundation
24 | import os.log
25 |
26 | // MARK: - X
27 | public extension UserDefaults {
28 | /// A namespace for extra convenience API
29 | struct X {
30 | let base: UserDefaults
31 | }
32 |
33 | /// `UserDefaults` extra convenience API namespace.
34 | var x: X { X(base: self) }
35 | }
36 |
37 | // MARK: - API
38 | public extension UserDefaults.X {
39 | internal static let log: OSLog = OSLog(
40 | subsystem: "com.cookpad.swift-user-defaults",
41 | category: "UserDefaults.X"
42 | )
43 |
44 | // MARK: Common
45 |
46 | /// Adds the contents of the specified dictionary to the registration domain.
47 | ///
48 | /// - Parameter defaults: The dictionary of keys and values you want to register.
49 | func register(defaults: [UserDefaults.Key: UserDefaultsStorable]) {
50 | let sequence = defaults.map({ ($0.key.rawValue, $0.value.storableValue) })
51 | base.register(defaults: Dictionary(uniqueKeysWithValues: sequence))
52 | }
53 |
54 | func register(defaults container: UserDefaults.ValueContainer) {
55 | register(defaults: container.contents)
56 | }
57 |
58 | /// Removes the value of the specified default key.
59 | ///
60 | /// - Parameter key: The key whose value you want to remove.
61 | func removeObject(forKey key: UserDefaults.Key) {
62 | base.removeObject(forKey: key.rawValue)
63 | }
64 |
65 | // MARK: Read
66 |
67 | /// Returns the object associated with the specified key.
68 | ///
69 | /// - Parameters:
70 | /// - type: The type of the value to decode.
71 | /// - key: A key in the user‘s defaults database.
72 | /// - Returns: The object associated with the specified key, or `nil` if the key was not found or if the value did not match the generic type `T`.
73 | func object(_ type: T.Type = T.self, forKey key: UserDefaults.Key) -> T? {
74 | base.object(forKey: key.rawValue).flatMap({ decode(from: $0, context: key) })
75 | }
76 |
77 | /// Returns the object associated with the specified key.
78 | ///
79 | /// - Parameters:
80 | /// - type: The type of the value to return.
81 | /// - key: A key in the user‘s defaults database.
82 | /// - Returns: The object associated with the specified key, or `nil` if the key was not found or if the value was not stored in a format that was compatible with `T.RawValue`.
83 | func object(_ type: T.Type = T.self, forKey key: UserDefaults.Key) -> T? where T.RawValue: UserDefaultsStorable {
84 | object(forKey: key).flatMap({ T.init(rawValue: $0) })
85 | }
86 |
87 | /// Returns the object associated with the specific key after attempting to deserialise a data blob using the provided strategy.
88 | ///
89 | /// If an error occurs trying to decode the data into the given type, or if a value is not stored against the given key, `nil` will be returned.
90 | ///
91 | /// - Parameters:
92 | /// - type: The type of the value to decode.
93 | /// - key: A key in the user‘s defaults database.
94 | /// - strategy: The custom coding strategy used when decoding the stored data.
95 | /// - Returns: The deserialised object conforming to the `Decodable` protocol.
96 | func object(_ type: T.Type = T.self, forKey key: UserDefaults.Key, strategy: UserDefaults.CodingStrategy) -> T? {
97 | base.object(forKey: key.rawValue).flatMap({ decode(from: $0, strategy: strategy, context: key) })
98 | }
99 |
100 | // MARK: Write
101 |
102 | /// Sets the value of the specified default key.
103 | ///
104 | /// - Parameters:
105 | /// - value: The object to store in the defaults database.
106 | /// - key: The key with which to associate the value.
107 | func set(_ value: UserDefaultsStorable, forKey key: UserDefaults.Key) {
108 | base.set(value.storableValue, forKey: key.rawValue)
109 | }
110 |
111 | /// Sets the value of the specified default key.
112 | ///
113 | /// - Parameters:
114 | /// - value: The object of which the `rawValue` should be stored in the defaults database.
115 | /// - key: The key with which to associate the value.
116 | func set(_ value: T, forKey key: UserDefaults.Key) where T.RawValue: UserDefaultsStorable {
117 | set(value.rawValue, forKey: key)
118 | }
119 |
120 | /// Sets the value of the specified default key.
121 | ///
122 | /// While primitive `UserDefaults` types are stored directly in the property list data,
123 | /// this method uses the `Codable` protocol to serialize a data blob using the specified coding strategy.
124 | ///
125 | ///
126 | /// When using this method, you should continue to consider that `UserDefaults` might not be suitable for storing large amounts of data.
127 | ///
128 | /// - Parameters:
129 | /// - value: The object to store in the defaults database.
130 | /// - key: A key in the user‘s defaults database.
131 | /// - strategy: The custom coding strategy used when decoding the stored data.
132 | func set(_ value: T, forKey key: UserDefaults.Key, strategy: UserDefaults.CodingStrategy) {
133 | if let value = encode(value, strategy: strategy, context: key) {
134 | set(value, forKey: key)
135 | } else {
136 | // FIXME: Can we improve this? Is removing the data the right approach?
137 | //
138 | // Pros: It's convenient (user doesn't have to think about it) and will be consistent with @UserDefault
139 | // Cons: It's not clear, logs might get missed
140 | //
141 | // I'm leaning towards asserting or throwing a fatal error but need to wait and see i think.
142 | //
143 | // If we didn't remove the data, i figure it might be confusing because you wouldn't expect the old
144 | // value to return in `object(forKey:)` but at the same time, this is also confusing.
145 | os_log("Removing data stored for '%@' after failing to encode new value", log: Self.log, type: .info, key.rawValue)
146 | removeObject(forKey: key)
147 | }
148 | }
149 |
150 | // MARK: Observe
151 |
152 | /// Observes changes to the object associated with the specified key.
153 | ///
154 | /// - Parameters:
155 | /// - type: The type of the value to observe.
156 | /// - key: A key in the user‘s defaults database.
157 | /// - handler: A closure invoked whenever the observed value is modified.
158 | /// - Returns: A token object to be used to invalidate the observation by either deallocating the value or calling `invalidate()`.
159 | func observeObject(
160 | _ type: T.Type = T.self,
161 | forKey key: UserDefaults.Key,
162 | handler: @escaping (UserDefaults.Change) -> Void
163 | ) -> UserDefaults.Observation {
164 | base.observeObject(forKey: key.rawValue) { change in
165 | handler(change.map({ $0.flatMap({ decode(from: $0, context: key) }) }))
166 | }
167 | }
168 |
169 | /// Observes changes to the object associated with the specified key.
170 | ///
171 | /// - Parameters:
172 | /// - type: The type of the value to observe.
173 | /// - key: A key in the user‘s defaults database.
174 | /// - handler: A closure invoked whenever the observed value is modified.
175 | /// - Returns: A token object to be used to invalidate the observation by either deallocating the value or calling `invalidate()`.
176 | func observeObject(
177 | _ type: T.Type = T.self,
178 | forKey key: UserDefaults.Key,
179 | handler: @escaping (UserDefaults.Change) -> Void
180 | ) -> UserDefaults.Observation where T.RawValue: UserDefaultsStorable {
181 | observeObject(T.RawValue.self, forKey: key) { change in
182 | handler(change.map({ $0.flatMap(T.init(rawValue:)) }))
183 | }
184 | }
185 |
186 | /// Observes changes to the data associated with the specified key and decodes it into the given type.
187 | ///
188 | /// Even when using a custom coding strategy, types are stored in the underlying `UserDefaults` instance as `Data`.
189 | /// If an error occurs trying to decode the data into the given type, the value will be returned as `nil`.
190 | ///
191 | /// - Parameters:
192 | /// - type: The type of the value to observe.
193 | /// - key: A key in the user‘s defaults database.
194 | /// - strategy: The strategy used when decoding the stored data.
195 | /// - handler: A closure invoked whenever the observed value is modified.
196 | /// - Returns: A token object to be used to invalidate the observation by either deallocating the value or calling `invalidate()`.
197 | func observeObject(
198 | _ type: T.Type = T.self,
199 | forKey key: UserDefaults.Key,
200 | strategy: UserDefaults.CodingStrategy,
201 | handler: @escaping (UserDefaults.Change) -> Void
202 | ) -> UserDefaults.Observation {
203 | base.observeObject(forKey: key.rawValue) { change in
204 | handler(change.map({ $0.flatMap({ decode(from: $0, strategy: strategy, context: key) }) }))
205 | }
206 | }
207 | }
208 |
209 | // MARK: - Internal
210 | extension UserDefaults.X {
211 | func decode(
212 | from storedValue: Any,
213 | context key: UserDefaults.Key
214 | ) -> Value? {
215 | // If the value can be decoded successfully, simply return it
216 | if let decoded = Value(storedValue: storedValue) {
217 | return decoded
218 | }
219 |
220 | // If the value wasn't decoded, log a message for debugging
221 | os_log(
222 | "Unable to decode '%@' as %{public}@ when stored object was %{public}@",
223 | log: Self.log,
224 | type: .info,
225 | key.rawValue,
226 | String(describing: Value.self),
227 | String(describing: type(of: storedValue))
228 | )
229 |
230 | return nil
231 | }
232 |
233 | func decode(
234 | from storedValue: Any,
235 | strategy: UserDefaults.CodingStrategy,
236 | context key: UserDefaults.Key
237 | ) -> Value? {
238 | // Before decoding using the custom strategy, we must first attempt to read as data
239 | guard let data: Data = decode(from: storedValue, context: key) else {
240 | return nil
241 | }
242 |
243 | do {
244 | // Return the decoded object
245 | return try strategy.decode(from: data)
246 |
247 | } catch {
248 | // Log any errors thrown during decoding
249 | os_log(
250 | "Error thrown decoding data for '%@' using strategy '%{public}@' as %{public}@: %@",
251 | log: Self.log,
252 | type: .fault,
253 | key.rawValue,
254 | String(describing: strategy),
255 | String(describing: Value.self),
256 | error as NSError
257 | )
258 | return nil
259 | }
260 | }
261 |
262 | func encode(
263 | _ value: Value,
264 | strategy: UserDefaults.CodingStrategy,
265 | context key: UserDefaults.Key
266 | ) -> Data? {
267 | do {
268 | // Encode the object and return the data to be written to UserDefaults
269 | return try strategy.encode(value)
270 |
271 | } catch {
272 | // Log an error to help with debugging
273 | os_log(
274 | "Error thrown encoding data for '%@' using strategy '%{public}@': %@",
275 | log: Self.log,
276 | type: .fault,
277 | key.rawValue,
278 | String(describing: strategy),
279 | error as NSError
280 | )
281 | return nil
282 | }
283 | }
284 | }
285 |
--------------------------------------------------------------------------------
/Sources/SwiftUserDefaults/UserDefaultsStorable.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import Foundation
24 |
25 | /// A protocol used to identify types that can be stored within `UserDefaults`.
26 | ///
27 | /// - Warning: It is not suitable to add `UserDefaultsStorable` conformance to types that cannot be passed into the `UserDefaults.set(_:forKey:)` method. Doing so defeats the purpose of this protocol.
28 | public protocol UserDefaultsStorable {
29 | /// A representation of the given property list value that can be passed via command line arguments.
30 | ///
31 | /// The value returned by this property will match what you would see if you opened a **.plist** file that had used xml encoding but instead of representing a complete property list, the xml represents just a single value (and its children if its a container type).
32 | var storableXMLValue: String { get }
33 | }
34 |
35 | // MARK: - Default Implementation
36 | extension UserDefaultsStorable {
37 | init?(storedValue: Any) {
38 | guard let value = storedValue as? Self else { return nil }
39 | self = value
40 | }
41 |
42 | var storableValue: Any {
43 | self
44 | }
45 | }
46 |
47 | // MARK: - Array
48 | extension Array: UserDefaultsStorable where Element: UserDefaultsStorable {
49 | public var storableXMLValue: String {
50 | if isEmpty {
51 | return ""
52 | }
53 |
54 | return "" + map(\.storableXMLValue).joined() + ""
55 | }
56 | }
57 |
58 | // MARK: - Bool
59 | extension Bool: UserDefaultsStorable {
60 | public var storableXMLValue: String {
61 | self ? "" : ""
62 | }
63 | }
64 |
65 | // MARK: - Data
66 | extension Data: UserDefaultsStorable {
67 | public var storableXMLValue: String {
68 | "" + base64EncodedString() + ""
69 | }
70 | }
71 |
72 | // MARK: - Date
73 | extension Date: UserDefaultsStorable {
74 | public var storableXMLValue: String {
75 | "" + stringValue + ""
76 | }
77 |
78 | private var stringValue: String {
79 | ISO8601DateFormatter.string(
80 | from: self,
81 | timeZone: TimeZone(abbreviation: "GMT")!,
82 | formatOptions: .withInternetDateTime
83 | )
84 | }
85 | }
86 |
87 | // MARK: - Dictionary
88 | extension Dictionary: UserDefaultsStorable where Key == String, Value: UserDefaultsStorable {
89 | public var storableXMLValue: String {
90 | if isEmpty {
91 | return ""
92 | }
93 |
94 | return ""
95 | + map({ "" + $0.xmlEscapedValue + "" + $1.storableXMLValue }).joined()
96 | + ""
97 | }
98 | }
99 |
100 | // MARK: - Floating Point
101 | extension BinaryFloatingPoint where Self: UserDefaultsStorable {
102 | public var storableXMLValue: String {
103 | "" + stringValue + ""
104 | }
105 |
106 | // Reimplementation of __CFNumberCopyFormattingDescriptionAsFloat64
107 | // https://opensource.apple.com/source/CF/CF-299.3/NumberDate.subproj/CFNumber.c.auto.html
108 | //
109 | // Matches implementation of _CFAppendXML0
110 | // https://opensource.apple.com/source/CF/CF-550/CFPropertyList.c.auto.html
111 | private var stringValue: String {
112 | let floatValue = Float64(self)
113 |
114 | if floatValue.isNaN {
115 | return "nan"
116 | }
117 |
118 | if floatValue.isInfinite {
119 | return 0.0 < floatValue ? "+infinity" : "-infinity"
120 | }
121 |
122 | if floatValue == 0.0 {
123 | return "0.0"
124 | }
125 |
126 | return String(format: "%.*g", DBL_DIG + 2, floatValue)
127 | }
128 | }
129 |
130 | extension Double: UserDefaultsStorable {}
131 | extension Float: UserDefaultsStorable {}
132 |
133 | // MARK: - Integer
134 | extension UserDefaultsStorable where Self: BinaryInteger {
135 | public var storableXMLValue: String {
136 | "" + String(describing: self) + ""
137 | }
138 | }
139 |
140 | extension UInt: UserDefaultsStorable {}
141 | extension UInt8: UserDefaultsStorable {}
142 | extension UInt16: UserDefaultsStorable {}
143 | extension UInt32: UserDefaultsStorable {}
144 | extension UInt64: UserDefaultsStorable {}
145 |
146 | extension Int: UserDefaultsStorable {}
147 | extension Int8: UserDefaultsStorable {}
148 | extension Int16: UserDefaultsStorable {}
149 | extension Int32: UserDefaultsStorable {}
150 | extension Int64: UserDefaultsStorable {}
151 |
152 | // MARK: - String
153 | extension UserDefaultsStorable where Self: StringProtocol {
154 | public var storableXMLValue: String {
155 | "" + xmlEscapedValue + ""
156 | }
157 |
158 | // There is probably a more efficient way to do this
159 | var xmlEscapedValue: String {
160 | self
161 | .replacingOccurrences(of: "&", with: "&")
162 | .replacingOccurrences(of: "<", with: "<")
163 | .replacingOccurrences(of: ">", with: ">")
164 | }
165 | }
166 |
167 | extension String: UserDefaultsStorable {}
168 | extension Substring: UserDefaultsStorable {}
169 |
--------------------------------------------------------------------------------
/Tests/SwiftUserDefaultsTests/LaunchArgumentEncodableTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftUserDefaults
2 | import XCTest
3 |
4 | private extension UserDefaults.Key {
5 | static let appleLanguages = Self("AppleLanguages")
6 | static let appleLocale = Self("AppleLocale")
7 | }
8 |
9 | private extension UserDefaults.Key {
10 | static let uuid = Self("UUID")
11 | static let user = Self("User")
12 | static let state = Self("State")
13 | static let lastState = Self("LastState")
14 | static let isLegacyUser = Self("LegacyUser")
15 | static let lastVisitDate = Self("LastVisitDate")
16 | static let windowPreferences = Self("WindowPreferences")
17 | }
18 |
19 | private struct AppConfiguration: LaunchArgumentEncodable {
20 | struct User: Codable, Equatable {
21 | var name: String
22 | }
23 |
24 | enum State: String {
25 | case registered, unregistered
26 | }
27 |
28 | struct WindowPreferences: Codable, Equatable {
29 | var isFullScreenSupported: Bool = false
30 | var isMinimizeEnabled: Bool = true
31 | }
32 |
33 | // UserDefaultsStorable with default value
34 | @UserDefaultOverride(.uuid)
35 | var uuid: String = "TESTING"
36 |
37 | // Optional Codable
38 | @UserDefaultOverride(.user, strategy: .json)
39 | var user: User?
40 |
41 | // RawRepresentable with default value
42 | @UserDefaultOverride(.state)
43 | var state: State = .unregistered
44 |
45 | // Optional RawRepresentable
46 | @UserDefaultOverride(.lastState)
47 | var lastState: State?
48 |
49 | // Optional UserDefaultsStorable
50 | @UserDefaultOverride(.isLegacyUser)
51 | var isLegacyUser: Bool? = false
52 |
53 | // Optional UserDefaultsStorable
54 | @UserDefaultOverride(.lastVisitDate)
55 | var lastVisitDate: Date?
56 |
57 | // Codable with default value
58 | @UserDefaultOverride(.windowPreferences, strategy: .json)
59 | var windowPreferences: WindowPreferences = WindowPreferences()
60 |
61 | // The device locale to mock, can't be represented as a single @UserDefaultOverride
62 | var deviceLocale: Locale = Locale(identifier: "en_US")
63 |
64 | // Additonal Launch Arguments
65 | var additionalLaunchArguments: [String] {
66 | ["UI-Testing"]
67 | }
68 | }
69 |
70 | class LaunchArgumentEncodableTests: XCTestCase {
71 | func testEncodeLaunchArguments() throws {
72 | var configuration = AppConfiguration()
73 | configuration.user = AppConfiguration.User(name: "John")
74 | configuration.state = .registered
75 | configuration.lastState = .unregistered
76 | configuration.lastVisitDate = Date(timeIntervalSinceReferenceDate: 60 * 60 * 24)
77 | configuration.windowPreferences.isMinimizeEnabled = false
78 |
79 | let launchArguments = try configuration.encodeLaunchArguments()
80 |
81 | XCTAssertEqual(configuration.uuid, "TESTING")
82 | XCTAssertEqual(configuration.user, AppConfiguration.User(name: "John"))
83 | XCTAssertEqual(configuration.state, .registered)
84 | XCTAssertEqual(configuration.lastState, .unregistered)
85 | XCTAssertEqual(configuration.isLegacyUser, false)
86 | XCTAssertEqual(configuration.lastVisitDate, Date(timeIntervalSinceReferenceDate: 60 * 60 * 24))
87 | XCTAssertEqual(configuration.windowPreferences.isMinimizeEnabled, false)
88 |
89 | XCTAssertEqual(launchArguments, [
90 | "-UUID", "TESTING",
91 | "-User", "eyJuYW1lIjoiSm9obiJ9",
92 | "-State", "registered",
93 | "-LastState", "unregistered",
94 | "-LegacyUser", "",
95 | "-LastVisitDate", "2001-01-02T00:00:00Z",
96 | "-WindowPreferences", "eyJpc0Z1bGxTY3JlZW5TdXBwb3J0ZWQiOmZhbHNlLCJpc01pbmltaXplRW5hYmxlZCI6ZmFsc2V9",
97 | "UI-Testing"
98 | ])
99 | }
100 |
101 | func testProjectedValue() throws {
102 | // Given a property wrapper
103 | @UserDefaultOverride(.state)
104 | var state = AppConfiguration.State.registered
105 |
106 | // When the projected value is accessed
107 | let projectedValue = $state
108 |
109 | XCTAssertEqual(projectedValue.key, .state)
110 | XCTAssertEqual(try projectedValue.getValue() as? String, "registered")
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/Tests/SwiftUserDefaultsTests/StorableValueTests.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | @testable import SwiftUserDefaults
24 | import XCTest
25 |
26 | final class StorableValueTests: XCTestCase {
27 | func testArray() {
28 | let arrayValue: [String] = ["one", "two", "three"]
29 | let nsArrayValue: NSArray = ["one", "two", "three"]
30 |
31 | XCTAssertEqual(Array(storedValue: arrayValue), ["one", "two", "three"])
32 | XCTAssertEqual(Array(storedValue: nsArrayValue), ["one", "two", "three"])
33 |
34 | XCTAssertEqual(arrayValue.storableValue as? NSArray, ["one", "two", "three"])
35 |
36 | XCTAssertNil(Array(storedValue: "A"))
37 | XCTAssertNil(Array(storedValue: ["1", 2, "3"]))
38 | }
39 |
40 | func testBool() {
41 | let boolValue: Bool = true
42 | let numberValue: NSNumber = false
43 |
44 | XCTAssertEqual(Bool(storedValue: boolValue), true)
45 | XCTAssertEqual(Bool(storedValue: numberValue), false)
46 |
47 | XCTAssertEqual(boolValue.storableValue as? NSNumber, NSNumber(value: true))
48 |
49 | XCTAssertNil(Bool(storedValue: "true"))
50 | }
51 |
52 | func testData() throws {
53 | let dataValue = Data([0x48, 0x65, 0x6c, 0x6c, 0x6f])
54 | let nsDataValue = try XCTUnwrap(NSData(base64Encoded: "SGVsbG8=", options: .ignoreUnknownCharacters))
55 |
56 | XCTAssertEqual(Data(storedValue: dataValue), Data([0x48, 0x65, 0x6c, 0x6c, 0x6f]))
57 | XCTAssertEqual(Data(storedValue: nsDataValue), Data([0x48, 0x65, 0x6c, 0x6c, 0x6f]))
58 |
59 | XCTAssertEqual(dataValue.storableValue as? NSData, nsDataValue)
60 |
61 | XCTAssertNil(Data(storedValue: "SGVsbG8"))
62 | }
63 |
64 | func testDate() {
65 | let dateValue = Date(timeIntervalSinceReferenceDate: 60 * 60 * 24)
66 | let nsDateValue: NSDate = NSDate(timeIntervalSinceReferenceDate: 60 * 60 * 24)
67 |
68 | XCTAssertEqual(Date(storedValue: dateValue), dateValue)
69 | XCTAssertEqual(Date(storedValue: nsDateValue), dateValue)
70 |
71 | XCTAssertEqual(dateValue.storableValue as? NSDate, nsDateValue)
72 | }
73 |
74 | func testDictionary() {
75 | let dictionaryValue: [String: Int] = ["A": 1, "B": 2, "C": 3]
76 | let nsDictionaryValue: NSDictionary = ["A": 1, "B": 2, "C": 3]
77 |
78 | XCTAssertEqual(Dictionary(storedValue: dictionaryValue), dictionaryValue)
79 | XCTAssertEqual(Dictionary(storedValue: nsDictionaryValue), dictionaryValue)
80 |
81 | XCTAssertEqual(dictionaryValue.storableValue as? NSDictionary, nsDictionaryValue)
82 |
83 | XCTAssertNil(Dictionary(storedValue: "{}"))
84 | XCTAssertNil(Dictionary(storedValue: ["A": "1", "B": 2, "C": "3"]))
85 | }
86 |
87 | func testFloatingPoints() {
88 | let numberValue = NSNumber(value: 1)
89 | let floatValue: Float = 1
90 | let doubleValue: Double = 1
91 |
92 | XCTAssertEqual(Double(storedValue: doubleValue), 1)
93 | XCTAssertEqual(Double(storedValue: numberValue), 1)
94 |
95 | XCTAssertEqual(Float(storedValue: floatValue), 1)
96 | XCTAssertEqual(Float(storedValue: numberValue), 1)
97 |
98 | XCTAssertEqual(floatValue.storableValue as? NSNumber, NSNumber(value: 1))
99 | XCTAssertEqual(doubleValue.storableValue as? NSNumber, NSNumber(value: 1))
100 |
101 | XCTAssertNil(Float(storedValue: "1"))
102 | XCTAssertNil(Double(storedValue: "1"))
103 | }
104 |
105 | func testIntegers() {
106 | let numberValue = NSNumber(value: 123)
107 | let intValue = Int(123)
108 | let int8Value = Int8(123)
109 | let int16Value = Int16(123)
110 | let int32Value = Int32(123)
111 | let int64Value = Int64(123)
112 | let uIntValue = UInt(123)
113 | let uInt8Value = UInt8(123)
114 | let uInt16Value = UInt16(123)
115 | let uInt32Value = UInt32(123)
116 | let uInt64Value = UInt64(123)
117 |
118 | XCTAssertEqual(Int(storedValue: numberValue), 123)
119 | XCTAssertEqual(Int(storedValue: intValue), 123)
120 |
121 | XCTAssertEqual(Int8(storedValue: numberValue), 123)
122 | XCTAssertEqual(Int8(storedValue: int8Value), 123)
123 |
124 | XCTAssertEqual(Int16(storedValue: numberValue), 123)
125 | XCTAssertEqual(Int16(storedValue: int16Value), 123)
126 |
127 | XCTAssertEqual(Int32(storedValue: numberValue), 123)
128 | XCTAssertEqual(Int32(storedValue: int32Value), 123)
129 |
130 | XCTAssertEqual(Int64(storedValue: numberValue), 123)
131 | XCTAssertEqual(Int64(storedValue: int64Value), 123)
132 |
133 | XCTAssertEqual(UInt(storedValue: numberValue), 123)
134 | XCTAssertEqual(UInt(storedValue: uIntValue), 123)
135 |
136 | XCTAssertEqual(UInt8(storedValue: numberValue), 123)
137 | XCTAssertEqual(UInt8(storedValue: uInt8Value), 123)
138 |
139 | XCTAssertEqual(UInt16(storedValue: numberValue), 123)
140 | XCTAssertEqual(UInt16(storedValue: uInt16Value), 123)
141 |
142 | XCTAssertEqual(UInt32(storedValue: numberValue), 123)
143 | XCTAssertEqual(UInt32(storedValue: uInt32Value), 123)
144 |
145 | XCTAssertEqual(UInt64(storedValue: numberValue), 123)
146 | XCTAssertEqual(UInt64(storedValue: uInt64Value), 123)
147 |
148 | XCTAssertEqual(intValue.storableValue as? NSNumber, NSNumber(value: 123))
149 | XCTAssertEqual(int8Value.storableValue as? NSNumber, NSNumber(value: 123))
150 | XCTAssertEqual(int16Value.storableValue as? NSNumber, NSNumber(value: 123))
151 | XCTAssertEqual(int32Value.storableValue as? NSNumber, NSNumber(value: 123))
152 | XCTAssertEqual(int64Value.storableValue as? NSNumber, NSNumber(value: 123))
153 | XCTAssertEqual(uIntValue.storableValue as? NSNumber, NSNumber(value: 123))
154 | XCTAssertEqual(uInt8Value.storableValue as? NSNumber, NSNumber(value: 123))
155 | XCTAssertEqual(uInt16Value.storableValue as? NSNumber, NSNumber(value: 123))
156 | XCTAssertEqual(uInt32Value.storableValue as? NSNumber, NSNumber(value: 123))
157 | XCTAssertEqual(uInt64Value.storableValue as? NSNumber, NSNumber(value: 123))
158 |
159 | XCTAssertNil(Int(storedValue: "123"))
160 | XCTAssertNil(Int8(storedValue: "123"))
161 | XCTAssertNil(Int16(storedValue: "123"))
162 | XCTAssertNil(Int32(storedValue: "123"))
163 | XCTAssertNil(Int64(storedValue: "123"))
164 | XCTAssertNil(UInt(storedValue: "123"))
165 | XCTAssertNil(UInt8(storedValue: "123"))
166 | XCTAssertNil(UInt16(storedValue: "123"))
167 | XCTAssertNil(UInt32(storedValue: "123"))
168 | XCTAssertNil(UInt64(storedValue: "123"))
169 | }
170 |
171 | func testString() throws {
172 | let stringValue = "Hello"
173 | let substringValue = stringValue[stringValue.startIndex ..< stringValue.endIndex]
174 | let nsStringValue: NSString = "Hello"
175 |
176 | XCTAssertEqual(String(storedValue: stringValue), "Hello")
177 | XCTAssertEqual(String(storedValue: nsStringValue), "Hello")
178 |
179 | XCTAssertEqual(stringValue.storableValue as? NSString, nsStringValue)
180 | XCTAssertEqual(substringValue.storableValue as? NSString, nsStringValue)
181 |
182 | XCTAssertNil(String(storedValue: NSObject()))
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/Tests/SwiftUserDefaultsTests/StorableXMLValueTests.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import SwiftUserDefaults
24 | import XCTest
25 |
26 | // A test suite for
27 | final class StorableXMLValueTests: XCTestCase {
28 | func testXMLString_array() {
29 | assertValueMatchesPlist(Array())
30 | assertValueMatchesPlist([true, true, false])
31 | assertValueMatchesPlist([["A": 1], ["B": 2], ["C": 3]])
32 | }
33 |
34 | func testXMLString_boolean() {
35 | assertValueMatchesPlist(true)
36 | assertValueMatchesPlist(false)
37 | }
38 |
39 | func testXMLString_data() {
40 | assertValueMatchesPlist(Data([0x48, 0x65, 0x6c, 0x6c, 0x6f]))
41 | }
42 |
43 | func testXMLString_date() {
44 | assertValueMatchesPlist(Date(timeIntervalSinceReferenceDate: 60 * 60 * 24))
45 | }
46 |
47 | func testXMLString_dictionary() {
48 | assertValueMatchesPlist(Dictionary())
49 | assertValueMatchesPlist(["Data": Data([0x48, 0x65, 0x6c, 0x6c, 0x6f])])
50 | assertValueMatchesPlist(["Array": ["A", "B", "C"]])
51 | assertValueMatchesPlist(["Dictionary": ["A": 1]])
52 | }
53 |
54 | func testXMLString_integer() {
55 | assertValueMatchesPlist(Int(1))
56 | assertValueMatchesPlist(Int8(-22))
57 | assertValueMatchesPlist(Int32(213))
58 | assertValueMatchesPlist(Int64.min)
59 |
60 | assertValueMatchesPlist(UInt(1))
61 | assertValueMatchesPlist(UInt8(22))
62 | assertValueMatchesPlist(UInt32(213))
63 | assertValueMatchesPlist(UInt64.max)
64 | }
65 |
66 | func testXMLString_real() {
67 | assertValueMatchesPlist(Float(1.0))
68 | assertValueMatchesPlist(Float(0))
69 | assertValueMatchesPlist(Float(-1.1))
70 | assertValueMatchesPlist(Float(200.20))
71 | assertValueMatchesPlist(Float.nan)
72 | assertValueMatchesPlist(Float.infinity)
73 | assertValueMatchesPlist(-Float.infinity)
74 |
75 | assertValueMatchesPlist(Double(200.20))
76 | assertValueMatchesPlist(Double.infinity - 1)
77 | }
78 |
79 | func testXMLString_string() {
80 | // FIXME: Multiline string encoding.
81 | XCTExpectFailure()
82 |
83 | let multiline = """
84 | Hello,
85 | World!
86 | """
87 | XCTAssertEqual(multiline.storableXMLValue, "Hello,\\nWorld!")
88 |
89 | assertValueMatchesPlist("Hello, World")
90 |
91 | assertValueMatchesPlist("")
92 | }
93 | }
94 |
95 | // MARK: - Utilities
96 |
97 | private func assertValueMatchesPlist(_ value: UserDefaultsStorable, file: StaticString = #filePath, line: UInt = #line) {
98 | do {
99 | let linesToRemove: [String] = [
100 | #""#,
101 | #""#,
102 | #""#,
103 | #""#
104 | ]
105 |
106 | let data = try PropertyListSerialization.data(fromPropertyList: value, format: .xml, options: .zero)
107 | let plistString = try XCTUnwrap(String(data: data, encoding: .utf8))
108 |
109 | var plistLines = plistString.components(separatedBy: .newlines)
110 | plistLines.removeAll(where: { linesToRemove.contains($0) })
111 | plistLines = plistLines.map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) })
112 |
113 | let string = plistLines.joined().trimmingCharacters(in: .whitespacesAndNewlines)
114 | XCTAssertEqual(value.storableXMLValue, string, file: file, line: line)
115 | } catch {
116 | XCTFail(error.localizedDescription, file: file, line: line)
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/Tests/SwiftUserDefaultsTests/Types/RawSubject.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | enum RawSubject: String {
24 | case foo, bar, baz
25 | }
26 |
--------------------------------------------------------------------------------
/Tests/SwiftUserDefaultsTests/Types/Subject.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | struct Subject: Codable, Equatable {
24 | var value: String
25 | }
26 |
--------------------------------------------------------------------------------
/Tests/SwiftUserDefaultsTests/UserDefaultTests.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import SwiftUserDefaults
24 | import XCTest
25 |
26 | final class UserDefaultTests: XCTestCase {
27 | var userDefaults: UserDefaults!
28 |
29 | override func setUp() {
30 | super.setUp()
31 | userDefaults = UserDefaults(suiteName: #fileID)
32 | userDefaults.removePersistentDomain(forName: #fileID)
33 | }
34 |
35 | func testUserDefaultsStorableType() {
36 | let wrapper = UserDefault(.init("StringKey"), store: userDefaults, defaultValue: "")
37 |
38 | // When UserDefaults does not have a value, the default value is used
39 | XCTAssertNil(userDefaults.object(forKey: "StringKey"))
40 | XCTAssertEqual(wrapper.wrappedValue, "")
41 |
42 | // When setting a value, it is written to UserDefaults
43 | wrapper.wrappedValue = "Some Value"
44 | XCTAssertEqual(wrapper.wrappedValue, "Some Value")
45 | XCTAssertEqual(userDefaults.string(forKey: "StringKey"), "Some Value")
46 |
47 | // Updates from UserDefaults are reflected
48 | userDefaults.setValue("Something Else", forKey: "StringKey")
49 | XCTAssertEqual(wrapper.wrappedValue, "Something Else")
50 | }
51 |
52 | func testOptionalUserDefaultsStorableType() {
53 | let wrapper = UserDefault(.init("IntegerKey"), store: userDefaults)
54 |
55 | // When UserDefaults does not have a value, the default value is nil
56 | XCTAssertNil(userDefaults.object(forKey: "IntegerKey"))
57 | XCTAssertNil(wrapper.wrappedValue)
58 |
59 | // When setting a value, it is written to UserDefaults
60 | wrapper.wrappedValue = 123
61 | XCTAssertEqual(wrapper.wrappedValue, 123)
62 | XCTAssertEqual(userDefaults.integer(forKey: "IntegerKey"), 123)
63 |
64 | // When setting the value to `nil`, it clears UserDefaults
65 | wrapper.wrappedValue = nil
66 | XCTAssertNil(wrapper.wrappedValue)
67 | XCTAssertNil(userDefaults.object(forKey: "IntegerKey"))
68 |
69 | // Updates from UserDefaults are reflected
70 | userDefaults.setValue(0, forKey: "IntegerKey")
71 | XCTAssertEqual(wrapper.wrappedValue, 0)
72 | }
73 |
74 | func testReset() {
75 | let wrapper = UserDefault(.init("BoolKey"), store: userDefaults, defaultValue: true)
76 |
77 | // When setting the value, it is written to UserDefaults
78 | wrapper.wrappedValue = false
79 | XCTAssertFalse(wrapper.wrappedValue)
80 | XCTAssertEqual(userDefaults.object(forKey: "BoolKey") as? Bool, false)
81 |
82 | // When resetting the value, it's cleared from UserDefaults and the wrappedValue uses the defaultValue
83 | wrapper.reset()
84 | XCTAssertTrue(wrapper.wrappedValue)
85 | XCTAssertNil(userDefaults.object(forKey: "BoolKey"))
86 | }
87 |
88 | func testObserver() {
89 | let wrapper = UserDefault(.init("StringKey"), store: userDefaults, defaultValue: "")
90 |
91 | var changes: [UserDefaults.Change] = []
92 | let observer = wrapper.addObserver { changes.append($0) }
93 | addTeardownBlock(observer.invalidate)
94 |
95 | wrapper.wrappedValue = "One"
96 | wrapper.reset()
97 | wrapper.wrappedValue = "Two"
98 | userDefaults.x.set("Three", forKey: .init("StringKey"))
99 |
100 | XCTAssertEqual(changes, [.initial(""), .update("One"), .update(""), .update("Two"), .update("Three")])
101 | }
102 |
103 | func testCodableWithDefault() {
104 | let key = UserDefaults.Key("CodableKey")
105 | let wrapper = UserDefault(key, strategy: .json, store: userDefaults, defaultValue: Subject(value: "default"))
106 |
107 | // Observe changes
108 | var changes: [Subject] = []
109 | let token = wrapper.addObserver(handler: { changes.append($0.value) })
110 | addTeardownBlock(token.invalidate)
111 |
112 | // Uses default
113 | XCTAssertNil(userDefaults.object(forKey: key.rawValue))
114 | XCTAssertEqual(wrapper.wrappedValue, Subject(value: "default"))
115 |
116 | // Writes value
117 | wrapper.wrappedValue.value = "updated"
118 | XCTAssertEqual(userDefaults.data(forKey: key.rawValue), Data(#"{"value":"updated"}"#.utf8))
119 | XCTAssertEqual(wrapper.wrappedValue, Subject(value: "updated"))
120 |
121 | // Resets
122 | wrapper.reset()
123 | XCTAssertNil(userDefaults.object(forKey: key.rawValue))
124 |
125 | // Ignores bad data
126 | userDefaults.set("string", forKey: key.rawValue)
127 | XCTAssertEqual(wrapper.wrappedValue, Subject(value: "default"))
128 |
129 | // Notifies changes
130 | XCTAssertEqual(changes.map(\.value), [
131 | "default",
132 | "updated",
133 | "default",
134 | "default"
135 | ])
136 | }
137 |
138 | func testCodable() {
139 | let key = UserDefaults.Key("CodableKey")
140 | let wrapper = UserDefault(key, strategy: .json, store: userDefaults)
141 |
142 | // Observe changes
143 | var changes: [Subject?] = []
144 | let token = wrapper.addObserver(handler: { changes.append($0.value) })
145 | addTeardownBlock(token.invalidate)
146 |
147 | // nil when unset
148 | XCTAssertNil(userDefaults.object(forKey: key.rawValue))
149 | XCTAssertNil(wrapper.wrappedValue)
150 |
151 | // Writes value
152 | wrapper.wrappedValue = Subject(value: "updated")
153 | XCTAssertEqual(userDefaults.data(forKey: key.rawValue), Data(#"{"value":"updated"}"#.utf8))
154 | XCTAssertEqual(wrapper.wrappedValue, Subject(value: "updated"))
155 |
156 | // Resets
157 | wrapper.reset()
158 | XCTAssertNil(userDefaults.object(forKey: key.rawValue))
159 | XCTAssertNil(wrapper.wrappedValue)
160 |
161 | // Ignores bad data
162 | userDefaults.set("string", forKey: key.rawValue)
163 | XCTAssertNil(wrapper.wrappedValue)
164 |
165 | // Set to nil clears
166 | userDefaults.set(Data(#"{"value":"value"}"#.utf8), forKey: key.rawValue)
167 | wrapper.wrappedValue = nil
168 |
169 | // Notifies changes
170 | XCTAssertEqual(changes.map(\.?.value), [
171 | nil,
172 | "updated",
173 | nil,
174 | nil,
175 | "value",
176 | nil
177 | ])
178 | }
179 |
180 | func testRawRepresentableWithDefault() {
181 | let key = UserDefaults.Key("RawRepresentableKey")
182 | let wrapper = UserDefault(key, store: userDefaults, defaultValue: .foo)
183 |
184 | // Observe changes
185 | var changes: [RawSubject] = []
186 | let token = wrapper.addObserver(handler: { changes.append($0.value) })
187 | addTeardownBlock(token.invalidate)
188 |
189 | // Uses default
190 | XCTAssertNil(userDefaults.object(forKey: key.rawValue))
191 | XCTAssertEqual(wrapper.wrappedValue, .foo)
192 |
193 | // Writes value
194 | wrapper.wrappedValue = .bar
195 | XCTAssertEqual(userDefaults.string(forKey: key.rawValue), "bar")
196 | XCTAssertEqual(wrapper.wrappedValue, .bar)
197 |
198 | // Resets
199 | wrapper.reset()
200 | XCTAssertNil(userDefaults.object(forKey: key.rawValue))
201 |
202 | // Uses default for bad data
203 | userDefaults.set("unknown", forKey: key.rawValue)
204 | XCTAssertEqual(wrapper.wrappedValue, .foo)
205 |
206 | // Notifies changes
207 | XCTAssertEqual(changes, [
208 | .foo,
209 | .bar,
210 | .foo,
211 | .foo
212 | ])
213 | }
214 |
215 | func testRawRepresentable() {
216 | let key = UserDefaults.Key("RawRepresentableKey")
217 | let wrapper = UserDefault(key, store: userDefaults)
218 |
219 | // Observe changes
220 | var changes: [RawSubject?] = []
221 | let token = wrapper.addObserver(handler: { changes.append($0.value) })
222 | addTeardownBlock(token.invalidate)
223 |
224 | // Uses default
225 | XCTAssertNil(userDefaults.object(forKey: key.rawValue))
226 | XCTAssertNil(wrapper.wrappedValue)
227 |
228 | // Writes value
229 | wrapper.wrappedValue = .bar
230 | XCTAssertEqual(userDefaults.string(forKey: key.rawValue), "bar")
231 | XCTAssertEqual(wrapper.wrappedValue, .bar)
232 |
233 | // Resets
234 | wrapper.reset()
235 | XCTAssertNil(userDefaults.object(forKey: key.rawValue))
236 | XCTAssertNil(wrapper.wrappedValue)
237 |
238 | // Uses default for bad data
239 | userDefaults.set("unknown", forKey: key.rawValue)
240 | XCTAssertNil(wrapper.wrappedValue)
241 |
242 | // Reads raw value
243 | userDefaults.set("baz", forKey: key.rawValue)
244 | XCTAssertEqual(wrapper.wrappedValue, .baz)
245 |
246 | // Notifies changes
247 | XCTAssertEqual(changes, [
248 | nil,
249 | .bar,
250 | nil,
251 | nil,
252 | .baz
253 | ])
254 | }
255 | }
256 |
--------------------------------------------------------------------------------
/Tests/SwiftUserDefaultsTests/UserDefaultsKeyTests.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import SwiftUserDefaults
24 | import XCTest
25 |
26 | final class UserDefaultsKeyTests: XCTestCase {
27 | func testKey() {
28 | let string = UserDefaults.Key("String")
29 | XCTAssertEqual(string.rawValue, "String")
30 |
31 | let rawKey = UserDefaults.Key(rawValue: "RawValue")
32 | XCTAssertEqual(rawKey.rawValue, "RawValue")
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Tests/SwiftUserDefaultsTests/UserDefaultsObservationTests.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import SwiftUserDefaults
24 | import XCTest
25 |
26 | final class UserDefaultsObservationTests: XCTestCase {
27 | var userDefaults: UserDefaults!
28 |
29 | override func setUp() {
30 | super.setUp()
31 | userDefaults = UserDefaults(suiteName: #fileID)
32 | userDefaults.removePersistentDomain(forName: #fileID)
33 | }
34 |
35 | func testObserveValue() {
36 | // Given an observer is registered for a specific key
37 | var changes: [UserDefaults.Change] = []
38 | let observer = userDefaults.observeObject(forKey: "TestKey") { change in
39 | changes.append(change)
40 | }
41 |
42 | // When the user defaults updates the value
43 | userDefaults.set("Test", forKey: "TestKey")
44 | userDefaults.removeObject(forKey: "TestKey")
45 | userDefaults.register(defaults: ["TestKey": "Default"])
46 | userDefaults.set(1, forKey: "TestKey")
47 | userDefaults.set(2, forKey: "OtherKey")
48 |
49 | observer.invalidate()
50 |
51 | userDefaults.set("Ignored", forKey: "TestKey")
52 |
53 | // Then the observer should have tracked the changes for test_key_3 up until the observer is cancelled
54 | XCTAssertEqual(changes.map(\.value) as NSArray, [nil, "Test", nil, "Default", 1] as NSArray)
55 | XCTAssertEqual(changes.map(\.label), [.initial, .update, .update, .update, .update])
56 | }
57 | }
58 |
59 | private extension UserDefaults.Change {
60 | enum Label {
61 | case initial, update
62 | }
63 |
64 | var label: Label {
65 | switch self {
66 | case .initial:
67 | return .initial
68 | case .update:
69 | return .update
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Tests/SwiftUserDefaultsTests/UserDefaultsValueContainerTests.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import SwiftUserDefaults
24 | import XCTest
25 |
26 | private extension UserDefaults.Key {
27 | static let valueOne = Self("ValueOne")
28 | static let valueTwo = Self("ValueTwo")
29 | static let valueThree = Self("ValueThree")
30 | }
31 |
32 | final class UserDefaultsValueContainerTests: XCTestCase {
33 | func testContents() {
34 | // When values are stored within the container
35 | var container = UserDefaults.ValueContainer()
36 | container.set("First Value", forKey: .valueOne)
37 | container.set("Second Value", forKey: .valueOne)
38 | container.set(RawSubject.baz, forKey: .valueTwo)
39 | XCTAssertNoThrow(try container.set(Subject(value: "foo"), forKey: .valueThree, strategy: .json))
40 |
41 | // Then the contents should have encoded as expected
42 | let expected: [UserDefaults.Key: UserDefaultsStorable] = [
43 | .valueOne: "Second Value",
44 | .valueTwo: "baz",
45 | .valueThree: Data(#"{"value":"foo"}"#.utf8)
46 | ]
47 | XCTAssertEqual(container.contents as NSDictionary, expected as NSDictionary)
48 | }
49 |
50 | func testLaunchArguments() {
51 | // Given values are stored within the container
52 | var container = UserDefaults.ValueContainer()
53 | container.set("Hello, World", forKey: .valueOne)
54 |
55 | // The values should be encoded to the expected launch arguments
56 | XCTAssertEqual(container.launchArguments, ["-ValueOne", "Hello, World"])
57 | }
58 |
59 | func testLaunchArguments_multiple() throws {
60 | // Given values are stored within the container
61 | var container = UserDefaults.ValueContainer()
62 | container.set("Hello, World", forKey: .valueOne)
63 | container.set(true, forKey: .valueTwo)
64 | container.set(123, forKey: .valueThree)
65 |
66 | // When the launch arguments are read
67 | let launchArguments = container.launchArguments
68 |
69 | // The contents will match as expected
70 | XCTAssertEqual(launchArguments, [
71 | "-ValueOne", "Hello, World",
72 | "-ValueTwo", "",
73 | "-ValueThree", "123"
74 | ])
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/Tests/SwiftUserDefaultsTests/UserDefaultsXTests.swift:
--------------------------------------------------------------------------------
1 | /// MIT License
2 | ///
3 | /// Copyright (c) 2021 Cookpad Inc.
4 | ///
5 | /// Permission is hereby granted, free of charge, to any person obtaining a copy
6 | /// of this software and associated documentation files (the "Software"), to deal
7 | /// in the Software without restriction, including without limitation the rights
8 | /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | /// copies of the Software, and to permit persons to whom the Software is
10 | /// furnished to do so, subject to the following conditions:
11 | ///
12 | /// The above copyright notice and this permission notice shall be included in all
13 | /// copies or substantial portions of the Software.
14 | ///
15 | /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | /// SOFTWARE.
22 |
23 | import SwiftUserDefaults
24 | import XCTest
25 |
26 | private extension UserDefaults.Key {
27 | static let rawSubject = Self("RawSubject")
28 | }
29 |
30 | final class UserDefaultsXTests: XCTestCase {
31 | var userDefaults: UserDefaults!
32 |
33 | override func setUp() {
34 | super.setUp()
35 | userDefaults = UserDefaults(suiteName: #fileID)
36 | userDefaults.removePersistentDomain(forName: #fileID)
37 | }
38 |
39 | func testConvenienceMethods() {
40 | // Observer is registered
41 | var changes: [UserDefaults.Change] = []
42 | let observer = userDefaults.x.observeObject(RawSubject.self, forKey: .rawSubject) { change in
43 | changes.append(change)
44 | }
45 |
46 | // Initial value should read nil
47 | let initialValue = userDefaults.x.object(RawSubject.self, forKey: .rawSubject)
48 | XCTAssertNil(initialValue)
49 |
50 | // Mutations should be recorded
51 | userDefaults.x.set(RawSubject.baz, forKey: .rawSubject)
52 | userDefaults.x.removeObject(forKey: .rawSubject)
53 |
54 | var container = UserDefaults.ValueContainer()
55 | container.set(RawSubject.bar, forKey: .rawSubject)
56 | userDefaults.x.register(defaults: container)
57 | observer.invalidate()
58 |
59 | // Updated value should be read
60 | XCTAssertEqual(userDefaults.x.object(forKey: .rawSubject), RawSubject.bar)
61 |
62 | // Changes should have been observed
63 | XCTAssertEqual(changes, [.initial(nil), .update(.baz), .update(nil), .update(.bar)])
64 | }
65 |
66 | func testDecodeFailsGracefully() {
67 | // When underlying data is a String
68 | userDefaults.set("0", forKey: "NumberAsString")
69 |
70 | // And we try to cast to Int
71 | let value = userDefaults.x.object(Int.self, forKey: .init("NumberAsString"))
72 |
73 | // Returned value is `nil`
74 | XCTAssertNil(value)
75 |
76 | // And log message is sent:
77 | // [UserDefaults.X] Unable to decode 'NumberAsString' as Int when stored object was NSTaggedPointerString
78 | }
79 |
80 | func testCodable_setJSON() throws {
81 | let subject = Subject(value: "something")
82 | let key = UserDefaults.Key("Key")
83 |
84 | // When the subject is set with the JSON strategy
85 | userDefaults.x.set(subject, forKey: key, strategy: .json)
86 |
87 | // The raw data is compact JSON
88 | let data = try XCTUnwrap(userDefaults.data(forKey: key.rawValue))
89 | XCTAssertEqual(data, Data(#"{"value":"something"}"#.utf8))
90 | }
91 |
92 | func testCodable_getJSON() throws {
93 | let key = UserDefaults.Key("Key")
94 |
95 | // Given JSON data exists in UserDefaults
96 | userDefaults.set(Data(#"{"value":"something else"}"#.utf8), forKey: key.rawValue)
97 |
98 | // Then reading into a Decodable type will parse the JSON
99 | let subject = userDefaults.x.object(Subject.self, forKey: key, strategy: .json)
100 | XCTAssertEqual(Subject(value: "something else"), subject)
101 | }
102 |
103 | func testCodable_plist() throws {
104 | let subject = Subject(value: "something")
105 | let key = UserDefaults.Key("Key")
106 |
107 | // When the subject is set with the plist strategy
108 | userDefaults.x.set(subject, forKey: key, strategy: .plist)
109 | XCTAssertNotNil(userDefaults.data(forKey: key.rawValue))
110 |
111 | // Then the subject can be read back
112 | XCTAssertEqual(subject, userDefaults.x.object(forKey: key, strategy: .plist))
113 | }
114 |
115 | func testCodable_readInvalid() throws {
116 | let key = UserDefaults.Key("Key")
117 |
118 | // Given invalid data is written
119 | userDefaults.x.set(Data("[]".utf8), forKey: key)
120 |
121 | // When UserDefaults attempts to read as a CodableType
122 | let value = userDefaults.x.object(Subject.self, forKey: key, strategy: .json)
123 |
124 | // Then the value will be nil
125 | XCTAssertNil(value)
126 |
127 | // And an error will be logged
128 | // [UserDefaults.X] Error thrown decoding data for 'Key' using strategy 'json' as Subject: {error}
129 | }
130 |
131 | func testCodable_writeInvalid() throws {
132 | let key = UserDefaults.Key("Key")
133 |
134 | // Given a value is already written
135 | userDefaults.setValue("Test", forKey: key.rawValue)
136 |
137 | // When writing an invalid value
138 | userDefaults.x.set("Test", forKey: key, strategy: .plist)
139 |
140 | // Then the value will have been removed due to the failure
141 | XCTAssertNil(userDefaults.object(forKey: key.rawValue))
142 |
143 | // And an error will have been logged
144 | // [UserDefaults.X] Error thrown encoding data for 'Key' using strategy 'plist': {error}
145 | // [UserDefaults.X] Removing data stored for 'Key' after failing to encode new value
146 | }
147 |
148 | func testCodableObservation() {
149 | let key = UserDefaults.Key("Key")
150 |
151 | // Given changes are being observed
152 | var changes: [Subject?] = []
153 | let observer = userDefaults.x.observeObject(Subject.self, forKey: key, strategy: .json) { change in
154 | changes.append(change.value)
155 | }
156 |
157 | // When a sequence of events take place
158 | userDefaults.x.set(Subject(value: "one"), forKey: key, strategy: .json)
159 | userDefaults.x.set(Subject(value: "two"), forKey: key, strategy: .json)
160 | userDefaults.x.set(Subject(value: "three"), forKey: key, strategy: .plist)
161 | userDefaults.x.set(Subject(value: "four"), forKey: key, strategy: .json)
162 | userDefaults.set("five", forKey: key.rawValue)
163 | userDefaults.set(Data(#"{"value":"six"}"#.utf8), forKey: key.rawValue)
164 | userDefaults.x.removeObject(forKey: key)
165 | observer.invalidate()
166 | userDefaults.x.set(Subject(value: "seven"), forKey: key, strategy: .json)
167 |
168 | // Then the correct values will have been observed
169 | XCTAssertEqual(changes, [
170 | nil, // initial
171 | Subject(value: "one"),
172 | Subject(value: "two"),
173 | nil, // plist
174 | Subject(value: "four"),
175 | nil, // string
176 | Subject(value: "six"), // manual
177 | nil, // remove
178 | ])
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/swift-user-defaults.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = "swift-user-defaults"
3 | s.module_name = "SwiftUserDefaults"
4 | s.version = "0.0.5"
5 | s.summary = "A series of Swift friendly utilities for Foundation's UserDefaults class"
6 | s.homepage = "https://github.com/cookpad/swift-user-defaults"
7 | s.license = { :type => "MIT", :file => "LICENSE.txt" }
8 | s.authors = { 'Liam Nichols' => 'liam.nichols.ln@gmail.com', 'Ryan Paterson' => 'ryan-paterson@cookpad.com' }
9 | s.source = { :git => "https://github.com/cookpad/swift-user-defaults.git", :tag => "#{s.version}" }
10 | s.source_files = "Sources/**/*.{swift}"
11 | s.resource_bundles = {'SwiftUserDefaults' => ['Sources/SwiftUserDefaults/PrivacyInfo.xcprivacy']}
12 | s.swift_version = "5.3"
13 |
14 | s.ios.deployment_target = '12.0'
15 | s.osx.deployment_target = '10.13'
16 |
17 | # Run Unit Tests
18 | s.test_spec 'Tests' do |test_spec|
19 | test_spec.source_files = 'Tests/**/*.{swift}'
20 | end
21 | end
22 |
--------------------------------------------------------------------------------