├── .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 | --------------------------------------------------------------------------------