├── .github └── workflows │ ├── ci.yml │ ├── documentation.yml │ └── format.yml ├── .gitignore ├── .spi.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ ├── swift-tca-loadable-Package.xcscheme │ └── swift-tca-loadable.xcscheme ├── Examples ├── Examples.xcodeproj │ └── project.pbxproj ├── Examples │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── ContentView.swift │ ├── ExamplesApp.swift │ ├── LoadablePicker.swift │ └── Preview Content │ │ └── Preview Assets.xcassets │ │ └── Contents.json ├── ExamplesTests │ └── ExamplesTests.swift ├── ExamplesUITests │ ├── ExamplesUITests.swift │ └── ExamplesUITestsLaunchTests.swift └── Package.swift ├── LICENSE ├── Loadable.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── Loadable │ ├── Documentation.docc │ ├── Articles │ │ ├── ErrorHandling.md │ │ └── GeneralUsage.md │ └── Loadable.md │ ├── Loadable.swift │ └── LoadableView.swift ├── TCALoadable_Example.gif └── Tests └── LoadableTests └── LoadableTests.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | name: MacOS 11 | runs-on: macos-14 12 | strategy: 13 | matrix: 14 | xcode: ['15.2'] 15 | config: ['debug', 'release'] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Select Xcode ${{ matrix.xcode }} 19 | run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app 20 | - name: Swift Version 21 | run: swift --version 22 | - name: Run ${{ matrix.xcode }} Tests 23 | run: make CONFIG=${{ matrix.config }} test-library 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: docs-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | permissions: 17 | contents: read 18 | pages: write 19 | id-token: write 20 | 21 | jobs: 22 | deploy: 23 | environment: 24 | name: github-pages 25 | url: ${{ steps.deployment.outputs.page_url }} 26 | runs-on: macos-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v3 30 | - name: Build DocC 31 | run: make build-documentation 32 | - name: Upload Artifact 33 | uses: actions/upload-pages-artifact@v1 34 | with: 35 | path: 'docs' 36 | - name: Deploy to Github Pages 37 | id: deployment 38 | uses: actions/deploy-pages@v1 39 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | swift_format: 10 | name: swift-format 11 | runs-on: macos-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Install Swift Format 15 | run: brew install swift-format 16 | - name: Run swift-format. 17 | run: make format 18 | - uses: stefanzweifel/git-auto-commit-action@v4 19 | with: 20 | commit_message: Run swift-format 21 | branch: 'main' 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | Package.resolved 7 | Examples/.build/* 8 | .dependencies/* 9 | .derivedData/* 10 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platfrom: ios 5 | scheme: swift-tca-loadable-Package 6 | - platform: macos-xcodebuild 7 | scheme: swift-tca-loadable-Package 8 | - platform: tvos 9 | scheme: swift-tca-loadable-Package 10 | - platform: watchos 11 | scheme: swift-tca-loadable-Package 12 | - documentation_targets: [Loadable] 13 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/swift-tca-loadable-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 61 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/swift-tca-loadable.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 | -------------------------------------------------------------------------------- /Examples/Examples.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 4C40D39B29E8480D00A8CF25 /* ExamplesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C40D39A29E8480D00A8CF25 /* ExamplesApp.swift */; }; 11 | 4C40D39D29E8480D00A8CF25 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C40D39C29E8480D00A8CF25 /* ContentView.swift */; }; 12 | 4C40D39F29E8480F00A8CF25 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4C40D39E29E8480F00A8CF25 /* Assets.xcassets */; }; 13 | 4C40D3A229E8480F00A8CF25 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4C40D3A129E8480F00A8CF25 /* Preview Assets.xcassets */; }; 14 | 4C40D3AC29E8480F00A8CF25 /* ExamplesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C40D3AB29E8480F00A8CF25 /* ExamplesTests.swift */; }; 15 | 4C40D3B629E8480F00A8CF25 /* ExamplesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C40D3B529E8480F00A8CF25 /* ExamplesUITests.swift */; }; 16 | 4C40D3B829E8480F00A8CF25 /* ExamplesUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C40D3B729E8480F00A8CF25 /* ExamplesUITestsLaunchTests.swift */; }; 17 | 4C40D3C829E8670500A8CF25 /* Loadable in Frameworks */ = {isa = PBXBuildFile; productRef = 4C40D3C729E8670500A8CF25 /* Loadable */; }; 18 | 4CFF0F9329EA5EA2008FD8D5 /* LoadablePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF0F9229EA5EA2008FD8D5 /* LoadablePicker.swift */; }; 19 | /* End PBXBuildFile section */ 20 | 21 | /* Begin PBXContainerItemProxy section */ 22 | 4C40D3A829E8480F00A8CF25 /* PBXContainerItemProxy */ = { 23 | isa = PBXContainerItemProxy; 24 | containerPortal = 4C40D38F29E8480D00A8CF25 /* Project object */; 25 | proxyType = 1; 26 | remoteGlobalIDString = 4C40D39629E8480D00A8CF25; 27 | remoteInfo = Examples; 28 | }; 29 | 4C40D3B229E8480F00A8CF25 /* PBXContainerItemProxy */ = { 30 | isa = PBXContainerItemProxy; 31 | containerPortal = 4C40D38F29E8480D00A8CF25 /* Project object */; 32 | proxyType = 1; 33 | remoteGlobalIDString = 4C40D39629E8480D00A8CF25; 34 | remoteInfo = Examples; 35 | }; 36 | /* End PBXContainerItemProxy section */ 37 | 38 | /* Begin PBXFileReference section */ 39 | 4C40D39729E8480D00A8CF25 /* Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Examples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 40 | 4C40D39A29E8480D00A8CF25 /* ExamplesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesApp.swift; sourceTree = ""; }; 41 | 4C40D39C29E8480D00A8CF25 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 42 | 4C40D39E29E8480F00A8CF25 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 43 | 4C40D3A129E8480F00A8CF25 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 44 | 4C40D3A729E8480F00A8CF25 /* ExamplesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExamplesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 45 | 4C40D3AB29E8480F00A8CF25 /* ExamplesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesTests.swift; sourceTree = ""; }; 46 | 4C40D3B129E8480F00A8CF25 /* ExamplesUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExamplesUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 47 | 4C40D3B529E8480F00A8CF25 /* ExamplesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesUITests.swift; sourceTree = ""; }; 48 | 4C40D3B729E8480F00A8CF25 /* ExamplesUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesUITestsLaunchTests.swift; sourceTree = ""; }; 49 | 4CFF0F9229EA5EA2008FD8D5 /* LoadablePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadablePicker.swift; sourceTree = ""; }; 50 | /* End PBXFileReference section */ 51 | 52 | /* Begin PBXFrameworksBuildPhase section */ 53 | 4C40D39429E8480D00A8CF25 /* Frameworks */ = { 54 | isa = PBXFrameworksBuildPhase; 55 | buildActionMask = 2147483647; 56 | files = ( 57 | 4C40D3C829E8670500A8CF25 /* Loadable in Frameworks */, 58 | ); 59 | runOnlyForDeploymentPostprocessing = 0; 60 | }; 61 | 4C40D3A429E8480F00A8CF25 /* Frameworks */ = { 62 | isa = PBXFrameworksBuildPhase; 63 | buildActionMask = 2147483647; 64 | files = ( 65 | ); 66 | runOnlyForDeploymentPostprocessing = 0; 67 | }; 68 | 4C40D3AE29E8480F00A8CF25 /* Frameworks */ = { 69 | isa = PBXFrameworksBuildPhase; 70 | buildActionMask = 2147483647; 71 | files = ( 72 | ); 73 | runOnlyForDeploymentPostprocessing = 0; 74 | }; 75 | /* End PBXFrameworksBuildPhase section */ 76 | 77 | /* Begin PBXGroup section */ 78 | 4C40D38E29E8480D00A8CF25 = { 79 | isa = PBXGroup; 80 | children = ( 81 | 4C40D39929E8480D00A8CF25 /* Examples */, 82 | 4C40D3AA29E8480F00A8CF25 /* ExamplesTests */, 83 | 4C40D3B429E8480F00A8CF25 /* ExamplesUITests */, 84 | 4C40D39829E8480D00A8CF25 /* Products */, 85 | 4C40D3C429E8482300A8CF25 /* Frameworks */, 86 | ); 87 | sourceTree = ""; 88 | }; 89 | 4C40D39829E8480D00A8CF25 /* Products */ = { 90 | isa = PBXGroup; 91 | children = ( 92 | 4C40D39729E8480D00A8CF25 /* Examples.app */, 93 | 4C40D3A729E8480F00A8CF25 /* ExamplesTests.xctest */, 94 | 4C40D3B129E8480F00A8CF25 /* ExamplesUITests.xctest */, 95 | ); 96 | name = Products; 97 | sourceTree = ""; 98 | }; 99 | 4C40D39929E8480D00A8CF25 /* Examples */ = { 100 | isa = PBXGroup; 101 | children = ( 102 | 4C40D39A29E8480D00A8CF25 /* ExamplesApp.swift */, 103 | 4C40D39C29E8480D00A8CF25 /* ContentView.swift */, 104 | 4CFF0F9229EA5EA2008FD8D5 /* LoadablePicker.swift */, 105 | 4C40D39E29E8480F00A8CF25 /* Assets.xcassets */, 106 | 4C40D3A029E8480F00A8CF25 /* Preview Content */, 107 | ); 108 | path = Examples; 109 | sourceTree = ""; 110 | }; 111 | 4C40D3A029E8480F00A8CF25 /* Preview Content */ = { 112 | isa = PBXGroup; 113 | children = ( 114 | 4C40D3A129E8480F00A8CF25 /* Preview Assets.xcassets */, 115 | ); 116 | path = "Preview Content"; 117 | sourceTree = ""; 118 | }; 119 | 4C40D3AA29E8480F00A8CF25 /* ExamplesTests */ = { 120 | isa = PBXGroup; 121 | children = ( 122 | 4C40D3AB29E8480F00A8CF25 /* ExamplesTests.swift */, 123 | ); 124 | path = ExamplesTests; 125 | sourceTree = ""; 126 | }; 127 | 4C40D3B429E8480F00A8CF25 /* ExamplesUITests */ = { 128 | isa = PBXGroup; 129 | children = ( 130 | 4C40D3B529E8480F00A8CF25 /* ExamplesUITests.swift */, 131 | 4C40D3B729E8480F00A8CF25 /* ExamplesUITestsLaunchTests.swift */, 132 | ); 133 | path = ExamplesUITests; 134 | sourceTree = ""; 135 | }; 136 | 4C40D3C429E8482300A8CF25 /* Frameworks */ = { 137 | isa = PBXGroup; 138 | children = ( 139 | ); 140 | name = Frameworks; 141 | sourceTree = ""; 142 | }; 143 | /* End PBXGroup section */ 144 | 145 | /* Begin PBXNativeTarget section */ 146 | 4C40D39629E8480D00A8CF25 /* Examples */ = { 147 | isa = PBXNativeTarget; 148 | buildConfigurationList = 4C40D3BB29E8480F00A8CF25 /* Build configuration list for PBXNativeTarget "Examples" */; 149 | buildPhases = ( 150 | 4C40D39329E8480D00A8CF25 /* Sources */, 151 | 4C40D39429E8480D00A8CF25 /* Frameworks */, 152 | 4C40D39529E8480D00A8CF25 /* Resources */, 153 | ); 154 | buildRules = ( 155 | ); 156 | dependencies = ( 157 | ); 158 | name = Examples; 159 | packageProductDependencies = ( 160 | 4C40D3C729E8670500A8CF25 /* Loadable */, 161 | ); 162 | productName = Examples; 163 | productReference = 4C40D39729E8480D00A8CF25 /* Examples.app */; 164 | productType = "com.apple.product-type.application"; 165 | }; 166 | 4C40D3A629E8480F00A8CF25 /* ExamplesTests */ = { 167 | isa = PBXNativeTarget; 168 | buildConfigurationList = 4C40D3BE29E8480F00A8CF25 /* Build configuration list for PBXNativeTarget "ExamplesTests" */; 169 | buildPhases = ( 170 | 4C40D3A329E8480F00A8CF25 /* Sources */, 171 | 4C40D3A429E8480F00A8CF25 /* Frameworks */, 172 | 4C40D3A529E8480F00A8CF25 /* Resources */, 173 | ); 174 | buildRules = ( 175 | ); 176 | dependencies = ( 177 | 4C40D3A929E8480F00A8CF25 /* PBXTargetDependency */, 178 | ); 179 | name = ExamplesTests; 180 | productName = ExamplesTests; 181 | productReference = 4C40D3A729E8480F00A8CF25 /* ExamplesTests.xctest */; 182 | productType = "com.apple.product-type.bundle.unit-test"; 183 | }; 184 | 4C40D3B029E8480F00A8CF25 /* ExamplesUITests */ = { 185 | isa = PBXNativeTarget; 186 | buildConfigurationList = 4C40D3C129E8480F00A8CF25 /* Build configuration list for PBXNativeTarget "ExamplesUITests" */; 187 | buildPhases = ( 188 | 4C40D3AD29E8480F00A8CF25 /* Sources */, 189 | 4C40D3AE29E8480F00A8CF25 /* Frameworks */, 190 | 4C40D3AF29E8480F00A8CF25 /* Resources */, 191 | ); 192 | buildRules = ( 193 | ); 194 | dependencies = ( 195 | 4C40D3B329E8480F00A8CF25 /* PBXTargetDependency */, 196 | ); 197 | name = ExamplesUITests; 198 | productName = ExamplesUITests; 199 | productReference = 4C40D3B129E8480F00A8CF25 /* ExamplesUITests.xctest */; 200 | productType = "com.apple.product-type.bundle.ui-testing"; 201 | }; 202 | /* End PBXNativeTarget section */ 203 | 204 | /* Begin PBXProject section */ 205 | 4C40D38F29E8480D00A8CF25 /* Project object */ = { 206 | isa = PBXProject; 207 | attributes = { 208 | BuildIndependentTargetsInParallel = 1; 209 | LastSwiftUpdateCheck = 1430; 210 | LastUpgradeCheck = 1430; 211 | TargetAttributes = { 212 | 4C40D39629E8480D00A8CF25 = { 213 | CreatedOnToolsVersion = 14.3; 214 | }; 215 | 4C40D3A629E8480F00A8CF25 = { 216 | CreatedOnToolsVersion = 14.3; 217 | TestTargetID = 4C40D39629E8480D00A8CF25; 218 | }; 219 | 4C40D3B029E8480F00A8CF25 = { 220 | CreatedOnToolsVersion = 14.3; 221 | TestTargetID = 4C40D39629E8480D00A8CF25; 222 | }; 223 | }; 224 | }; 225 | buildConfigurationList = 4C40D39229E8480D00A8CF25 /* Build configuration list for PBXProject "Examples" */; 226 | compatibilityVersion = "Xcode 14.0"; 227 | developmentRegion = en; 228 | hasScannedForEncodings = 0; 229 | knownRegions = ( 230 | en, 231 | Base, 232 | ); 233 | mainGroup = 4C40D38E29E8480D00A8CF25; 234 | productRefGroup = 4C40D39829E8480D00A8CF25 /* Products */; 235 | projectDirPath = ""; 236 | projectRoot = ""; 237 | targets = ( 238 | 4C40D39629E8480D00A8CF25 /* Examples */, 239 | 4C40D3A629E8480F00A8CF25 /* ExamplesTests */, 240 | 4C40D3B029E8480F00A8CF25 /* ExamplesUITests */, 241 | ); 242 | }; 243 | /* End PBXProject section */ 244 | 245 | /* Begin PBXResourcesBuildPhase section */ 246 | 4C40D39529E8480D00A8CF25 /* Resources */ = { 247 | isa = PBXResourcesBuildPhase; 248 | buildActionMask = 2147483647; 249 | files = ( 250 | 4C40D3A229E8480F00A8CF25 /* Preview Assets.xcassets in Resources */, 251 | 4C40D39F29E8480F00A8CF25 /* Assets.xcassets in Resources */, 252 | ); 253 | runOnlyForDeploymentPostprocessing = 0; 254 | }; 255 | 4C40D3A529E8480F00A8CF25 /* Resources */ = { 256 | isa = PBXResourcesBuildPhase; 257 | buildActionMask = 2147483647; 258 | files = ( 259 | ); 260 | runOnlyForDeploymentPostprocessing = 0; 261 | }; 262 | 4C40D3AF29E8480F00A8CF25 /* Resources */ = { 263 | isa = PBXResourcesBuildPhase; 264 | buildActionMask = 2147483647; 265 | files = ( 266 | ); 267 | runOnlyForDeploymentPostprocessing = 0; 268 | }; 269 | /* End PBXResourcesBuildPhase section */ 270 | 271 | /* Begin PBXSourcesBuildPhase section */ 272 | 4C40D39329E8480D00A8CF25 /* Sources */ = { 273 | isa = PBXSourcesBuildPhase; 274 | buildActionMask = 2147483647; 275 | files = ( 276 | 4CFF0F9329EA5EA2008FD8D5 /* LoadablePicker.swift in Sources */, 277 | 4C40D39D29E8480D00A8CF25 /* ContentView.swift in Sources */, 278 | 4C40D39B29E8480D00A8CF25 /* ExamplesApp.swift in Sources */, 279 | ); 280 | runOnlyForDeploymentPostprocessing = 0; 281 | }; 282 | 4C40D3A329E8480F00A8CF25 /* Sources */ = { 283 | isa = PBXSourcesBuildPhase; 284 | buildActionMask = 2147483647; 285 | files = ( 286 | 4C40D3AC29E8480F00A8CF25 /* ExamplesTests.swift in Sources */, 287 | ); 288 | runOnlyForDeploymentPostprocessing = 0; 289 | }; 290 | 4C40D3AD29E8480F00A8CF25 /* Sources */ = { 291 | isa = PBXSourcesBuildPhase; 292 | buildActionMask = 2147483647; 293 | files = ( 294 | 4C40D3B829E8480F00A8CF25 /* ExamplesUITestsLaunchTests.swift in Sources */, 295 | 4C40D3B629E8480F00A8CF25 /* ExamplesUITests.swift in Sources */, 296 | ); 297 | runOnlyForDeploymentPostprocessing = 0; 298 | }; 299 | /* End PBXSourcesBuildPhase section */ 300 | 301 | /* Begin PBXTargetDependency section */ 302 | 4C40D3A929E8480F00A8CF25 /* PBXTargetDependency */ = { 303 | isa = PBXTargetDependency; 304 | target = 4C40D39629E8480D00A8CF25 /* Examples */; 305 | targetProxy = 4C40D3A829E8480F00A8CF25 /* PBXContainerItemProxy */; 306 | }; 307 | 4C40D3B329E8480F00A8CF25 /* PBXTargetDependency */ = { 308 | isa = PBXTargetDependency; 309 | target = 4C40D39629E8480D00A8CF25 /* Examples */; 310 | targetProxy = 4C40D3B229E8480F00A8CF25 /* PBXContainerItemProxy */; 311 | }; 312 | /* End PBXTargetDependency section */ 313 | 314 | /* Begin XCBuildConfiguration section */ 315 | 4C40D3B929E8480F00A8CF25 /* Debug */ = { 316 | isa = XCBuildConfiguration; 317 | buildSettings = { 318 | ALWAYS_SEARCH_USER_PATHS = NO; 319 | CLANG_ANALYZER_NONNULL = YES; 320 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 321 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 322 | CLANG_ENABLE_MODULES = YES; 323 | CLANG_ENABLE_OBJC_ARC = YES; 324 | CLANG_ENABLE_OBJC_WEAK = YES; 325 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 326 | CLANG_WARN_BOOL_CONVERSION = YES; 327 | CLANG_WARN_COMMA = YES; 328 | CLANG_WARN_CONSTANT_CONVERSION = YES; 329 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 330 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 331 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 332 | CLANG_WARN_EMPTY_BODY = YES; 333 | CLANG_WARN_ENUM_CONVERSION = YES; 334 | CLANG_WARN_INFINITE_RECURSION = YES; 335 | CLANG_WARN_INT_CONVERSION = YES; 336 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 337 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 338 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 339 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 340 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 341 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 342 | CLANG_WARN_STRICT_PROTOTYPES = YES; 343 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 344 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 345 | CLANG_WARN_UNREACHABLE_CODE = YES; 346 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 347 | COPY_PHASE_STRIP = NO; 348 | DEBUG_INFORMATION_FORMAT = dwarf; 349 | ENABLE_STRICT_OBJC_MSGSEND = YES; 350 | ENABLE_TESTABILITY = YES; 351 | GCC_C_LANGUAGE_STANDARD = gnu11; 352 | GCC_DYNAMIC_NO_PIC = NO; 353 | GCC_NO_COMMON_BLOCKS = YES; 354 | GCC_OPTIMIZATION_LEVEL = 0; 355 | GCC_PREPROCESSOR_DEFINITIONS = ( 356 | "DEBUG=1", 357 | "$(inherited)", 358 | ); 359 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 360 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 361 | GCC_WARN_UNDECLARED_SELECTOR = YES; 362 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 363 | GCC_WARN_UNUSED_FUNCTION = YES; 364 | GCC_WARN_UNUSED_VARIABLE = YES; 365 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 366 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 367 | MTL_FAST_MATH = YES; 368 | ONLY_ACTIVE_ARCH = YES; 369 | SDKROOT = iphoneos; 370 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 371 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 372 | }; 373 | name = Debug; 374 | }; 375 | 4C40D3BA29E8480F00A8CF25 /* Release */ = { 376 | isa = XCBuildConfiguration; 377 | buildSettings = { 378 | ALWAYS_SEARCH_USER_PATHS = NO; 379 | CLANG_ANALYZER_NONNULL = YES; 380 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 381 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 382 | CLANG_ENABLE_MODULES = YES; 383 | CLANG_ENABLE_OBJC_ARC = YES; 384 | CLANG_ENABLE_OBJC_WEAK = YES; 385 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 386 | CLANG_WARN_BOOL_CONVERSION = YES; 387 | CLANG_WARN_COMMA = YES; 388 | CLANG_WARN_CONSTANT_CONVERSION = YES; 389 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 390 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 391 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 392 | CLANG_WARN_EMPTY_BODY = YES; 393 | CLANG_WARN_ENUM_CONVERSION = YES; 394 | CLANG_WARN_INFINITE_RECURSION = YES; 395 | CLANG_WARN_INT_CONVERSION = YES; 396 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 397 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 398 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 399 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 400 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 401 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 402 | CLANG_WARN_STRICT_PROTOTYPES = YES; 403 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 404 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 405 | CLANG_WARN_UNREACHABLE_CODE = YES; 406 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 407 | COPY_PHASE_STRIP = NO; 408 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 409 | ENABLE_NS_ASSERTIONS = NO; 410 | ENABLE_STRICT_OBJC_MSGSEND = YES; 411 | GCC_C_LANGUAGE_STANDARD = gnu11; 412 | GCC_NO_COMMON_BLOCKS = YES; 413 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 414 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 415 | GCC_WARN_UNDECLARED_SELECTOR = YES; 416 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 417 | GCC_WARN_UNUSED_FUNCTION = YES; 418 | GCC_WARN_UNUSED_VARIABLE = YES; 419 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 420 | MTL_ENABLE_DEBUG_INFO = NO; 421 | MTL_FAST_MATH = YES; 422 | SDKROOT = iphoneos; 423 | SWIFT_COMPILATION_MODE = wholemodule; 424 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 425 | VALIDATE_PRODUCT = YES; 426 | }; 427 | name = Release; 428 | }; 429 | 4C40D3BC29E8480F00A8CF25 /* Debug */ = { 430 | isa = XCBuildConfiguration; 431 | buildSettings = { 432 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 433 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 434 | CODE_SIGN_STYLE = Automatic; 435 | CURRENT_PROJECT_VERSION = 1; 436 | DEVELOPMENT_ASSET_PATHS = "\"Examples/Preview Content\""; 437 | DEVELOPMENT_TEAM = QWY654725W; 438 | ENABLE_PREVIEWS = YES; 439 | GENERATE_INFOPLIST_FILE = YES; 440 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 441 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 442 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 443 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 444 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 445 | LD_RUNPATH_SEARCH_PATHS = ( 446 | "$(inherited)", 447 | "@executable_path/Frameworks", 448 | ); 449 | MARKETING_VERSION = 1.0; 450 | PRODUCT_BUNDLE_IDENTIFIER = com.hhe.Examples; 451 | PRODUCT_NAME = "$(TARGET_NAME)"; 452 | SWIFT_EMIT_LOC_STRINGS = YES; 453 | SWIFT_VERSION = 5.0; 454 | TARGETED_DEVICE_FAMILY = "1,2"; 455 | }; 456 | name = Debug; 457 | }; 458 | 4C40D3BD29E8480F00A8CF25 /* Release */ = { 459 | isa = XCBuildConfiguration; 460 | buildSettings = { 461 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 462 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 463 | CODE_SIGN_STYLE = Automatic; 464 | CURRENT_PROJECT_VERSION = 1; 465 | DEVELOPMENT_ASSET_PATHS = "\"Examples/Preview Content\""; 466 | DEVELOPMENT_TEAM = QWY654725W; 467 | ENABLE_PREVIEWS = YES; 468 | GENERATE_INFOPLIST_FILE = YES; 469 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 470 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 471 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 472 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 473 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 474 | LD_RUNPATH_SEARCH_PATHS = ( 475 | "$(inherited)", 476 | "@executable_path/Frameworks", 477 | ); 478 | MARKETING_VERSION = 1.0; 479 | PRODUCT_BUNDLE_IDENTIFIER = com.hhe.Examples; 480 | PRODUCT_NAME = "$(TARGET_NAME)"; 481 | SWIFT_EMIT_LOC_STRINGS = YES; 482 | SWIFT_VERSION = 5.0; 483 | TARGETED_DEVICE_FAMILY = "1,2"; 484 | }; 485 | name = Release; 486 | }; 487 | 4C40D3BF29E8480F00A8CF25 /* Debug */ = { 488 | isa = XCBuildConfiguration; 489 | buildSettings = { 490 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 491 | BUNDLE_LOADER = "$(TEST_HOST)"; 492 | CODE_SIGN_STYLE = Automatic; 493 | CURRENT_PROJECT_VERSION = 1; 494 | DEVELOPMENT_TEAM = QWY654725W; 495 | GENERATE_INFOPLIST_FILE = YES; 496 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 497 | MARKETING_VERSION = 1.0; 498 | PRODUCT_BUNDLE_IDENTIFIER = com.hhe.ExamplesTests; 499 | PRODUCT_NAME = "$(TARGET_NAME)"; 500 | SWIFT_EMIT_LOC_STRINGS = NO; 501 | SWIFT_VERSION = 5.0; 502 | TARGETED_DEVICE_FAMILY = "1,2"; 503 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Examples.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Examples"; 504 | }; 505 | name = Debug; 506 | }; 507 | 4C40D3C029E8480F00A8CF25 /* Release */ = { 508 | isa = XCBuildConfiguration; 509 | buildSettings = { 510 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 511 | BUNDLE_LOADER = "$(TEST_HOST)"; 512 | CODE_SIGN_STYLE = Automatic; 513 | CURRENT_PROJECT_VERSION = 1; 514 | DEVELOPMENT_TEAM = QWY654725W; 515 | GENERATE_INFOPLIST_FILE = YES; 516 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 517 | MARKETING_VERSION = 1.0; 518 | PRODUCT_BUNDLE_IDENTIFIER = com.hhe.ExamplesTests; 519 | PRODUCT_NAME = "$(TARGET_NAME)"; 520 | SWIFT_EMIT_LOC_STRINGS = NO; 521 | SWIFT_VERSION = 5.0; 522 | TARGETED_DEVICE_FAMILY = "1,2"; 523 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Examples.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Examples"; 524 | }; 525 | name = Release; 526 | }; 527 | 4C40D3C229E8480F00A8CF25 /* Debug */ = { 528 | isa = XCBuildConfiguration; 529 | buildSettings = { 530 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 531 | CODE_SIGN_STYLE = Automatic; 532 | CURRENT_PROJECT_VERSION = 1; 533 | DEVELOPMENT_TEAM = QWY654725W; 534 | GENERATE_INFOPLIST_FILE = YES; 535 | MARKETING_VERSION = 1.0; 536 | PRODUCT_BUNDLE_IDENTIFIER = com.hhe.ExamplesUITests; 537 | PRODUCT_NAME = "$(TARGET_NAME)"; 538 | SWIFT_EMIT_LOC_STRINGS = NO; 539 | SWIFT_VERSION = 5.0; 540 | TARGETED_DEVICE_FAMILY = "1,2"; 541 | TEST_TARGET_NAME = Examples; 542 | }; 543 | name = Debug; 544 | }; 545 | 4C40D3C329E8480F00A8CF25 /* Release */ = { 546 | isa = XCBuildConfiguration; 547 | buildSettings = { 548 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 549 | CODE_SIGN_STYLE = Automatic; 550 | CURRENT_PROJECT_VERSION = 1; 551 | DEVELOPMENT_TEAM = QWY654725W; 552 | GENERATE_INFOPLIST_FILE = YES; 553 | MARKETING_VERSION = 1.0; 554 | PRODUCT_BUNDLE_IDENTIFIER = com.hhe.ExamplesUITests; 555 | PRODUCT_NAME = "$(TARGET_NAME)"; 556 | SWIFT_EMIT_LOC_STRINGS = NO; 557 | SWIFT_VERSION = 5.0; 558 | TARGETED_DEVICE_FAMILY = "1,2"; 559 | TEST_TARGET_NAME = Examples; 560 | }; 561 | name = Release; 562 | }; 563 | /* End XCBuildConfiguration section */ 564 | 565 | /* Begin XCConfigurationList section */ 566 | 4C40D39229E8480D00A8CF25 /* Build configuration list for PBXProject "Examples" */ = { 567 | isa = XCConfigurationList; 568 | buildConfigurations = ( 569 | 4C40D3B929E8480F00A8CF25 /* Debug */, 570 | 4C40D3BA29E8480F00A8CF25 /* Release */, 571 | ); 572 | defaultConfigurationIsVisible = 0; 573 | defaultConfigurationName = Release; 574 | }; 575 | 4C40D3BB29E8480F00A8CF25 /* Build configuration list for PBXNativeTarget "Examples" */ = { 576 | isa = XCConfigurationList; 577 | buildConfigurations = ( 578 | 4C40D3BC29E8480F00A8CF25 /* Debug */, 579 | 4C40D3BD29E8480F00A8CF25 /* Release */, 580 | ); 581 | defaultConfigurationIsVisible = 0; 582 | defaultConfigurationName = Release; 583 | }; 584 | 4C40D3BE29E8480F00A8CF25 /* Build configuration list for PBXNativeTarget "ExamplesTests" */ = { 585 | isa = XCConfigurationList; 586 | buildConfigurations = ( 587 | 4C40D3BF29E8480F00A8CF25 /* Debug */, 588 | 4C40D3C029E8480F00A8CF25 /* Release */, 589 | ); 590 | defaultConfigurationIsVisible = 0; 591 | defaultConfigurationName = Release; 592 | }; 593 | 4C40D3C129E8480F00A8CF25 /* Build configuration list for PBXNativeTarget "ExamplesUITests" */ = { 594 | isa = XCConfigurationList; 595 | buildConfigurations = ( 596 | 4C40D3C229E8480F00A8CF25 /* Debug */, 597 | 4C40D3C329E8480F00A8CF25 /* Release */, 598 | ); 599 | defaultConfigurationIsVisible = 0; 600 | defaultConfigurationName = Release; 601 | }; 602 | /* End XCConfigurationList section */ 603 | 604 | /* Begin XCSwiftPackageProductDependency section */ 605 | 4C40D3C729E8670500A8CF25 /* Loadable */ = { 606 | isa = XCSwiftPackageProductDependency; 607 | productName = Loadable; 608 | }; 609 | /* End XCSwiftPackageProductDependency section */ 610 | }; 611 | rootObject = 4C40D38F29E8480D00A8CF25 /* Project object */; 612 | } 613 | -------------------------------------------------------------------------------- /Examples/Examples/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 | -------------------------------------------------------------------------------- /Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/Examples/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Examples/ContentView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Loadable 3 | import SwiftUI 4 | 5 | @available(iOS 16, *) 6 | struct App: Reducer { 7 | @ObservableState 8 | struct State: Equatable { 9 | var int: LoadableState = .notRequested 10 | var orientation: IsLoadingOrientation = .horizontal() 11 | 12 | var currentOrientation: String { 13 | switch orientation { 14 | case let .horizontal(horizontal): 15 | switch horizontal { 16 | case .leading: 17 | return "Horizontal Leading" 18 | case .trailing: 19 | return "Horizontal Trailing" 20 | } 21 | case let .vertical(vertical): 22 | switch vertical { 23 | case .above: 24 | return "Vertical Above" 25 | case .below: 26 | return "Vertical Below" 27 | } 28 | } 29 | } 30 | } 31 | 32 | @CasePathable 33 | enum Action: Equatable { 34 | case int(LoadableAction) 35 | case toggleHorizontalOrVertical 36 | case toggleSecondaryOrientation 37 | } 38 | 39 | @Dependency(\.continuousClock) var clock; 40 | var body: some ReducerOf { 41 | Reduce { state, action in 42 | switch action { 43 | case .int(.load): 44 | return .run { send in 45 | await send(.int(.receiveLoaded( 46 | TaskResult { 47 | /// sleep to act like data is loading from a remote. 48 | try await clock.sleep(for: .seconds(2)) 49 | return 42 50 | } 51 | ))) 52 | } 53 | case .int: 54 | return .none 55 | case .toggleHorizontalOrVertical: 56 | switch state.orientation { 57 | case .horizontal: 58 | state.orientation = .vertical() 59 | case .vertical: 60 | state.orientation = .horizontal() 61 | } 62 | return .none 63 | 64 | case .toggleSecondaryOrientation: 65 | switch state.orientation { 66 | case .horizontal(.leading): 67 | state.orientation = .horizontal(.trailing) 68 | case .horizontal(.trailing): 69 | state.orientation = .horizontal(.leading) 70 | case .vertical(.above): 71 | state.orientation = .vertical(.below) 72 | case .vertical(.below): 73 | state.orientation = .vertical(.above) 74 | } 75 | return .none 76 | } 77 | } 78 | .loadable(state: \.int, action: \.int) 79 | } 80 | } 81 | 82 | struct ContentView: View { 83 | let store: StoreOf 84 | 85 | var body: some View { 86 | VStack { 87 | Text("Toggle orientation and press reload button.") 88 | .padding(.bottom, 40) 89 | 90 | LoadableView( 91 | store: store.scope(state: \.int, action: \.int), 92 | orientation: store.orientation 93 | ) { state in 94 | Text("Loaded: \(state)") 95 | } 96 | Button(action: { store.send(.int(.load)) }) { 97 | Text("Reload") 98 | } 99 | .buttonStyle(.borderedProminent) 100 | .padding() 101 | 102 | 103 | Text("Current Progress View Orientation") 104 | .font(.callout) 105 | 106 | Text("\(store.currentOrientation)") 107 | .font(.caption) 108 | .foregroundStyle(Color.secondary) 109 | .padding(.bottom) 110 | 111 | Button(action: { store.send(.toggleHorizontalOrVertical) }) { 112 | Text("Toggle primary orientation") 113 | } 114 | Button(action: { store.send(.toggleSecondaryOrientation) }) { 115 | Text("Toggle secondary orientation") 116 | } 117 | } 118 | .padding() 119 | } 120 | } 121 | 122 | struct ContentView_Previews: PreviewProvider { 123 | static var previews: some View { 124 | ContentView( 125 | store: .init( 126 | initialState: App.State(), 127 | reducer: App.init 128 | ) 129 | ) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Examples/Examples/ExamplesApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct ExamplesApp: SwiftUI.App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView( 8 | store: .init(initialState: App.State(), reducer: App.init) 9 | ) 10 | // LoadablePicker_Previews.previews 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/Examples/LoadablePicker.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Loadable 3 | import SwiftUI 4 | 5 | struct User: Equatable, Identifiable { 6 | let id: Int 7 | let name: String 8 | 9 | static let mocks: [Self] = [ 10 | .init(id: 1, name: "Blob"), 11 | .init(id: 2, name: "Blob Jr."), 12 | .init(id: 3, name: "Blob Sr.") 13 | ] 14 | } 15 | 16 | extension IdentifiedArray where Element == User, ID == Int { 17 | static var mocks = Self.init(uniqueElements: User.mocks) 18 | } 19 | 20 | @Reducer 21 | struct LoadablePicker { 22 | 23 | @ObservableState 24 | struct State: Equatable { 25 | var selection: Int? = nil 26 | var users: LoadableState> = .notRequested 27 | } 28 | 29 | @CasePathable 30 | enum Action: BindableAction, Equatable { 31 | case binding(BindingAction) 32 | case users(LoadableAction>) 33 | } 34 | 35 | @Dependency(\.continuousClock) var clock; 36 | 37 | var body: some Reducer { 38 | BindingReducer() 39 | Reduce { state, action in 40 | switch action { 41 | case .binding: 42 | return .none 43 | 44 | case .users(.load): 45 | return .run { send in 46 | await send(.users(.receiveLoaded( 47 | TaskResult { 48 | try await clock.sleep(for: .milliseconds(300)) 49 | return IdentifiedArray.mocks 50 | } 51 | ))) 52 | } 53 | 54 | case .users: 55 | return .none 56 | } 57 | } 58 | .loadable(state: \.users, action: \.users) 59 | } 60 | } 61 | 62 | struct LoadablePickerView: View { 63 | @Perception.Bindable var store: StoreOf 64 | 65 | var body: some View { 66 | LoadableView(store: store.scope(state: \.users, action: \.users)) { state in 67 | Picker("User", selection: $store.selection) { 68 | ForEach(state) { user in 69 | Text(user.name) 70 | .tag(Optional(user.id)) 71 | 72 | } 73 | } 74 | } 75 | } 76 | } 77 | 78 | #Preview { 79 | LoadablePickerView( 80 | store: Store(initialState: LoadablePicker.State()) { 81 | LoadablePicker()._printChanges() 82 | } 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /Examples/Examples/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/ExamplesTests/ExamplesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExamplesTests.swift 3 | // ExamplesTests 4 | // 5 | // Created by Michael Housh on 4/13/23. 6 | // 7 | 8 | import XCTest 9 | @testable import Examples 10 | 11 | final class ExamplesTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | // Any test you write for XCTest can be annotated as throws and async. 25 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. 26 | // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. 27 | } 28 | 29 | func testPerformanceExample() throws { 30 | // This is an example of a performance test case. 31 | self.measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Examples/ExamplesUITests/ExamplesUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExamplesUITests.swift 3 | // ExamplesUITests 4 | // 5 | // Created by Michael Housh on 4/13/23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class ExamplesUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use XCTAssert and related functions to verify your tests produce the correct results. 31 | } 32 | 33 | func testLaunchPerformance() throws { 34 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 35 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Examples/ExamplesUITests/ExamplesUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExamplesUITestsLaunchTests.swift 3 | // ExamplesUITests 4 | // 5 | // Created by Michael Housh on 4/13/23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class ExamplesUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Examples/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | 3 | import PackageDescription 4 | 5 | // Do not edit, this here so it does not show in Xcode 6 | let package = Package( 7 | name: "swift-tca-loadable-examples", 8 | products: [ 9 | ], 10 | dependencies: [ 11 | ], 12 | targets: [ 13 | ] 14 | ) 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Michael Housh. 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 | -------------------------------------------------------------------------------- /Loadable.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 15 | 16 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Loadable.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN_PATH = $(shell swift build --show-bin-path) 2 | XCTEST_PATH = $(shell find "$(BIN_PATH)" -name '*.xctest') 3 | COV_BIN = "$(XCTEST_PATH)"/Contents/MacOs/$(shell basename "$(XCTEST_PATH)" .xctest) 4 | 5 | PLATFORM_IOS = iOS Simulator,name=iPhone 14 Pro 6 | PLATFORM_MACOS = macOS 7 | PLATFORM_MAC_CATALYST = macOS,variant=Mac Catalyst 8 | PLATFORM_TVOS = tvOS Simulator,name=Apple TV 9 | PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 8 (45mm) 10 | 11 | CONFIG := debug 12 | 13 | clean: 14 | rm -rf .build 15 | 16 | test-macos: clean 17 | set -o pipefail && \ 18 | xcodebuild test \ 19 | -skipMacroValidation \ 20 | -scheme swift-tca-loadable-Package \ 21 | -configuration "$(CONFIG)" \ 22 | -destination platform="$(PLATFORM_MACOS)" 23 | 24 | test-ios: clean 25 | set -o pipefail && \ 26 | xcodebuild test \ 27 | -skipMacroValidation \ 28 | -scheme swift-tca-loadable-Package \ 29 | -configuration "$(CONFIG)" \ 30 | -destination platform="$(PLATFORM_IOS)" 31 | 32 | test-mac-catalyst: clean 33 | set -o pipefail && \ 34 | xcodebuild test \ 35 | -skipMacroValidation \ 36 | -scheme swift-tca-loadable-Package \ 37 | -configuration "$(CONFIG)" \ 38 | -destination platform="$(PLATFORM_MAC_CATALYST)" 39 | 40 | test-tvos: clean 41 | set -o pipefail && \ 42 | xcodebuild test \ 43 | -skipMacroValidation \ 44 | -scheme swift-tca-loadable-Package \ 45 | -configuration "$(CONFIG)" \ 46 | -destination platform="$(PLATFORM_TVOS)" 47 | 48 | test-watchos: clean 49 | set -o pipefail && \ 50 | xcodebuild test \ 51 | -skipMacroValidation \ 52 | -scheme swift-tca-loadable-Package \ 53 | -configuration "$(CONFIG)" \ 54 | -destination platform="$(PLATFORM_WATCHOS)" 55 | 56 | test-swift: 57 | swift test --enable-code-coverage 58 | 59 | test-library: test-macos test-ios test-mac-catalyst test-tvos test-watchos 60 | 61 | code-cov-report: 62 | @xcrun llvm-cov report \ 63 | $(COV_BIN) \ 64 | -instr-profile=.build/debug/codecov/default.profdata \ 65 | -ignore-filename-regex=".build|Tests" \ 66 | -use-color 67 | 68 | format: 69 | swift format \ 70 | --ignore-unparsable-files \ 71 | --in-place \ 72 | --recursive \ 73 | ./Package.swift \ 74 | ./Sources 75 | 76 | build-documentation: 77 | swift package \ 78 | --allow-writing-to-directory ./docs \ 79 | generate-documentation \ 80 | --target Loadable \ 81 | --disable-indexing \ 82 | --transform-for-static-hosting \ 83 | --hosting-base-path swift-tca-loadable \ 84 | --output-path ./docs 85 | 86 | preview-documentation: 87 | swift package \ 88 | --disable-sandbox \ 89 | preview-documentation \ 90 | --target Loadable 91 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "combine-schedulers", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/combine-schedulers", 7 | "state" : { 8 | "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", 9 | "version" : "1.0.0" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-case-paths", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/pointfreeco/swift-case-paths", 16 | "state" : { 17 | "revision" : "79623dbe2c7672f5e450d8325613d231454390b3", 18 | "version" : "1.3.2" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-clocks", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-clocks", 25 | "state" : { 26 | "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", 27 | "version" : "1.0.2" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-collections", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-collections", 34 | "state" : { 35 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 36 | "version" : "1.0.4" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-composable-architecture", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/pointfreeco/swift-composable-architecture.git", 43 | "state" : { 44 | "revision" : "899a68ce3c4eca42a42e6bad2c925b82dcab5187", 45 | "version" : "1.9.3" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-concurrency-extras", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 52 | "state" : { 53 | "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", 54 | "version" : "1.1.0" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-custom-dump", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 61 | "state" : { 62 | "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", 63 | "version" : "1.3.0" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-dependencies", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/pointfreeco/swift-dependencies", 70 | "state" : { 71 | "revision" : "d3a5af3038a09add4d7682f66555d6212058a3c0", 72 | "version" : "1.2.2" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-docc-plugin", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/apple/swift-docc-plugin.git", 79 | "state" : { 80 | "revision" : "9b1258905c21fc1b97bf03d1b4ca12c4ec4e5fda", 81 | "version" : "1.2.0" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-docc-symbolkit", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/apple/swift-docc-symbolkit", 88 | "state" : { 89 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 90 | "version" : "1.0.0" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-identified-collections", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 97 | "state" : { 98 | "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", 99 | "version" : "1.0.0" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-perception", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/pointfreeco/swift-perception", 106 | "state" : { 107 | "revision" : "052bd30a2079d9eb2400f0bc66707cc93c015152", 108 | "version" : "1.1.5" 109 | } 110 | }, 111 | { 112 | "identity" : "swift-syntax", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/apple/swift-syntax", 115 | "state" : { 116 | "revision" : "fa8f95c2d536d6620cc2f504ebe8a6167c9fc2dd", 117 | "version" : "510.0.1" 118 | } 119 | }, 120 | { 121 | "identity" : "swiftui-navigation", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/pointfreeco/swiftui-navigation", 124 | "state" : { 125 | "revision" : "2ec6c3a15293efff6083966b38439a4004f25565", 126 | "version" : "1.3.0" 127 | } 128 | }, 129 | { 130 | "identity" : "xctest-dynamic-overlay", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 133 | "state" : { 134 | "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", 135 | "version" : "1.1.2" 136 | } 137 | } 138 | ], 139 | "version" : 2 140 | } 141 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swift-tca-loadable", 7 | platforms: [ 8 | .iOS(.v16), 9 | .macOS(.v13), 10 | .tvOS(.v16), 11 | .watchOS(.v9), 12 | ], 13 | products: [ 14 | .library(name: "Loadable", targets: ["Loadable"]) 15 | ], 16 | dependencies: [ 17 | .package( 18 | url: "https://github.com/pointfreeco/swift-composable-architecture.git", 19 | from: "1.0.0" 20 | ), 21 | .package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.0.0"), 22 | .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"), 23 | ], 24 | targets: [ 25 | .target( 26 | name: "Loadable", 27 | dependencies: [ 28 | .product(name: "CasePaths", package: "swift-case-paths"), 29 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 30 | ] 31 | ), 32 | .testTarget( 33 | name: "LoadableTests", 34 | dependencies: [ 35 | "Loadable" 36 | ] 37 | ), 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/m-housh/swift-tca-loadable/actions/workflows/ci.yml/badge.svg)](https://github.com/m-housh/swift-tca-loadable/actions/workflows/ci.yml) 2 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fm-housh%2Fswift-tca-loadable%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/m-housh/swift-tca-loadable) 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fm-housh%2Fswift-tca-loadable%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/m-housh/swift-tca-loadable) 4 | 5 | # swift-tca-loadable 6 | 7 | A swift package for handling loadable items using [The Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture). 8 | 9 | - [Installation](#installation) 10 | - [Basic Usage](#basic-usage) 11 | - [Documenation](#documenation) 12 | 13 | ## Installation 14 | ------------------- 15 | Install this package in your project using `swift package manager`. 16 | 17 | ```swift 18 | let package = Package( 19 | ... 20 | dependencies: [ 21 | ... 22 | .package(url: "https://github.com/m-housh/swift-tca-loadable.git", from: "0.2.0") 23 | ] 24 | ... 25 | ) 26 | 27 | ``` 28 | 29 | ## Notes 30 | ---------- 31 | 32 | Version 0.3.+ brings breaking changes from the previous versions. Version `0.3.*` updates to using the 33 | `Reducer` from the composable architecture. 34 | 35 | ## Basic Usage 36 | ---------------- 37 | 38 | This package provides a `LoadableView` and several types that are used inside of your `Reducer` 39 | implementations. 40 | 41 | ### LoadableView 42 | 43 | Below shows an example `Reducer` and uses the `LoadableView`. 44 | 45 | ```swift 46 | import ComposableArchitecture 47 | import Loadable 48 | import SwiftUI 49 | 50 | struct App: Reducer { 51 | struct State: Equatable { 52 | @LoadableState var int: Int? 53 | } 54 | 55 | enum Action: Equatable, LoadableAction { 56 | case loadable(LoadingAction) 57 | } 58 | 59 | @Dependency(\.continuousClock) var clock; 60 | 61 | var body: some ReducerOf { 62 | Reduce { state, action in 63 | switch action { 64 | case .loadable(.load): 65 | return .load { 66 | /// sleep to act like data is loading from a remote. 67 | try await clock.sleep(for: .seconds(2)) 68 | return 42 69 | } 70 | case .loadable: 71 | return .none 72 | } 73 | } 74 | .loadable(state: \.$int) 75 | } 76 | } 77 | 78 | struct ContentView: View { 79 | let store: StoreOf 80 | var body: some View { 81 | VStack { 82 | LoadableView(store: store.scope(state: \.$int, action: Preview.Action.int)) { 83 | WithViewStore($0, observe: { $0 }) { viewStore in 84 | Text("Loaded: \(viewStore.state)") 85 | } 86 | Button(action: { ViewStore(store).send(.load) }) { 87 | Text("Reload") 88 | } 89 | .padding(.top) 90 | } 91 | .padding() 92 | } 93 | } 94 | ``` 95 | 96 | The above uses the default `ProgressView`'s when the items are in a `notRequested` or 97 | `isLoading` state, but you can override each view. 98 | 99 | ```swift 100 | struct ContentView: View { 101 | 102 | let store: StoreOf 103 | 104 | var body: some View { 105 | LoadableView( 106 | store: store.scope(state: \.$score, action: App.Action.int) 107 | ) { scoreStore in 108 | // The view when we have loaded content. 109 | WithViewStore(scoreStore) { viewStore in 110 | Text("Your score is: \(viewStore.state)") 111 | } 112 | } isLoading: { (isLoadingStore: Store) in 113 | MyCustomIsLoadingView(store: isLoadingStore) 114 | } notRequested: { (notRequestedStore: Store) in 115 | MyCustomNotRequestedView(store: notRequestedStore) 116 | } 117 | } 118 | } 119 | ``` 120 | 121 | ## Documentation 122 | --------------------- 123 | 124 | You can view the api documentation on the 125 | [here](https://m-housh.github.io/swift-tca-loadable/documentation/loadable/). 126 | 127 | ![Example Screenshot](https://github.com/m-housh/TCALoadable/blob/main/TCALoadable_Example.gif) 128 | -------------------------------------------------------------------------------- /Sources/Loadable/Documentation.docc/Articles/ErrorHandling.md: -------------------------------------------------------------------------------- 1 | # Error Handling 2 | 3 | This article describes error handling for loadable views. 4 | 5 | ## Overview 6 | 7 | If an error is thrown in the task that loads your content they are ignored by default. 8 | You can handle the error in your reducer logic by matching on the ``LoadableAction/receiveLoaded(_:)`` 9 | for your loadable item. 10 | 11 | This allows you to display an alert or a different view based on the error that is thrown. 12 | 13 | ## Example 14 | ```swift 15 | @Reducer 16 | struct App { 17 | 18 | struct State { 19 | var int: LoadableState = .notRequested 20 | var error: Error? 21 | } 22 | 23 | enum Action: Equatable { 24 | case int(LoadableAction) 25 | } 26 | 27 | @Dependency(\.continuousClock) var clock; 28 | 29 | var body: some ReducerOf { 30 | Reduce { state, action in 31 | switch action { 32 | // Handle the error here. 33 | case .int(.receiveLoaded(.failure(let error))): 34 | state.error = error 35 | return .none 36 | 37 | case .int: 38 | return .none 39 | } 40 | } 41 | .loadable(state: \.int, action: \.int) { 42 | /// sleep to act like data is loading from a remote. 43 | try await clock.sleep(for: .seconds(2)) 44 | return 42 45 | } 46 | } 47 | } 48 | ``` 49 | -------------------------------------------------------------------------------- /Sources/Loadable/Documentation.docc/Articles/GeneralUsage.md: -------------------------------------------------------------------------------- 1 | # General Usage 2 | 3 | This article describes the basic general usage of this package. 4 | 5 | ## Overview 6 | 7 | This article describes the basic general usage of this package. 8 | 9 | ## Modeling State 10 | 11 | Loadable state is modeled with the ``LoadableState`` property type. 12 | 13 | #### Example 14 | 15 | ```swift 16 | @Reducer 17 | struct AppReducer { 18 | @ObservableState 19 | struct State: Equatable { 20 | var users: LoadableState> = .notRequested 21 | ... 22 | } 23 | ... 24 | 25 | } 26 | ``` 27 | 28 | You can access the loaded state using the ``LoadableState/rawValue`` property on the ``LoadableState``. 29 | 30 | The ``LoadableState/rawValue`` will be non-nil when it has been loaded from a remote / external source. 31 | 32 | ## Modeling Actions 33 | 34 | You model your actions as a case in your action that accepts a ``LoadableAction``. Then enhance your 35 | reducer with one of the ``ComposableArchitecture/Reducer/loadable(state:action:on:operation:)`` modifiers. 36 | 37 | #### Example 38 | ```swift 39 | @Reducer 40 | struct AppReducer { 41 | ... 42 | enum Action: Equatable, LoadableAction { 43 | case users(LoadableAction>) 44 | case task 45 | } 46 | 47 | var body: some ReducerOf { 48 | Reduce { state, action in 49 | switch action { 50 | ... 51 | case .users: 52 | return .none 53 | 54 | case .task: 55 | return .none 56 | ... 57 | } 58 | } 59 | .loadable(state: \.users, action: \.users, on: \.task) { 60 | // The operation that is called to load the users when 61 | // the trigger action of `.task` is received by the parent. 62 | try await loadUsers() 63 | } 64 | } 65 | } 66 | ``` 67 | 68 | ## Reducers 69 | 70 | The reducers that are shipped with the library handle setting the ``LoadableState`` variable 71 | correctly, but you need to handle the ``LoadableAction/load`` in your reducer to actually load 72 | the data from an external source. 73 | 74 | Upon a successful result the reducer will set the loading state to ``LoadableState/loaded(_:)`` 75 | and the ``LoadableState/rawValue`` property will be non-nil. 76 | 77 | You can handle the failed result by matching on the ``LoadableAction/receiveLoaded(_:)`` case. 78 | See 79 | 80 | ## Loadable View 81 | 82 | The library ships with a ``LoadableView`` that can be used to handle a piece of loadable 83 | state, giving you control of the views based on the given state. 84 | 85 | #### Example 86 | ```swift 87 | struct ContentView: View { 88 | let store: StoreOf 89 | 90 | var body: some View { 91 | VStack { 92 | LoadableView( 93 | self.store.scope(state: \.users, action: \.users) 94 | ) { users in 95 | // Show your loaded view 96 | UsersView(users) 97 | } 98 | Button(action: { store.send(.users(.load)) }) { 99 | Text("Reload") 100 | } 101 | .padding(.top) 102 | } 103 | } 104 | } 105 | 106 | ``` 107 | 108 | The most basic / default initializers of the view will show a `ProgressView` when the 109 | state is ``LoadableState/notRequested`` until that state changes to ``LoadableState/loaded(_:)``. 110 | 111 | If the `load` action gets called when the state has been previously loaded then the `NotRequested` 112 | view (`ProgressView` by default) will show along with the previously loaded value. The default 113 | is to show the `ProgressView` in an `HStack` with the previously loaded view, but you can specify 114 | a `vertical` orientation in the initializer if that fits your use case better. 115 | 116 | ```swift 117 | LoadableView( 118 | store.scope(...), 119 | orientation: .vertical(.above) 120 | ) 121 | ``` 122 | 123 | The default is to call the `load` action when a view appears, however you can control that by 124 | specifying an ``Autoload`` value during initialization of the view. 125 | 126 | ```swift 127 | LoadableView( 128 | store.scope(...), 129 | autoload: .never, // or .always or .whenNotRequested (default) 130 | orientation: .vertical(.above) 131 | ) 132 | ``` 133 | 134 | See ``LoadableView`` for more initializers. 135 | 136 | ## Related Articles 137 | 138 | - 139 | -------------------------------------------------------------------------------- /Sources/Loadable/Documentation.docc/Loadable.md: -------------------------------------------------------------------------------- 1 | # ``Loadable`` 2 | 3 | A swift package for handling loadable items using `The Composable Architecture`. 4 | 5 | ## Installation 6 | ------------------- 7 | Install this package in your project using `swift package manager`. 8 | 9 | ```swift 10 | let package = Package( 11 | ... 12 | dependencies: [ 13 | ... 14 | .package(url: "https://github.com/m-housh/swift-tca-loadable.git", from: "0.4.0") 15 | ] 16 | ... 17 | ) 18 | 19 | ``` 20 | 21 | ## Notes 22 | ---------- 23 | 24 | Version `0.4.*` brings breaking changes from the previous versions. Version `0.4.*` updates to using the 25 | `Reducer` macro from the composable architecture. 26 | 27 | ## Basic Usage 28 | ---------------- 29 | 30 | This package provides a `LoadableView` and several types that are used inside of your `Reducer` 31 | implementations. 32 | 33 | ### LoadableView 34 | 35 | Below shows an example `Reducer` and uses the `LoadableView`. 36 | 37 | ```swift 38 | import ComposableArchitecture 39 | import Loadable 40 | import SwiftUI 41 | 42 | @Reducer 43 | struct App { 44 | struct State: Equatable { 45 | var int: LoadableState = .notRequested 46 | } 47 | 48 | enum Action: Equatable, LoadableAction { 49 | case int(LoadableAction) 50 | } 51 | 52 | @Dependency(\.continuousClock) var clock; 53 | 54 | var body: some ReducerOf { 55 | Reduce { state, action in 56 | switch action { 57 | 58 | case .loadable: 59 | return .none 60 | } 61 | } 62 | .loadable(state: \.int, action: \.int) { 63 | /// sleep to act like data is loading from a remote. 64 | try await clock.sleep(for: .seconds(2)) 65 | return 42 66 | } 67 | } 68 | } 69 | 70 | struct ContentView: View { 71 | let store: StoreOf 72 | var body: some View { 73 | VStack { 74 | LoadableView(store: store.scope(state: \.int, action: \.int)) { int in 75 | Text("Loaded: \(int)") 76 | } 77 | Button(action: { store.send(.int(.load)) }) { 78 | Text("Reload") 79 | } 80 | .padding(.top) 81 | } 82 | .padding() 83 | } 84 | } 85 | ``` 86 | 87 | The above uses the default `ProgressView`'s when the items are in a `notRequested` or 88 | `isLoading` state, but you can override each view. 89 | 90 | ```swift 91 | struct ContentView: View { 92 | 93 | let store: StoreOf 94 | 95 | var body: some View { 96 | LoadableView( 97 | store: store.scope(state: \.int, action: \.int) 98 | ) { score in 99 | // The view when we have loaded content. 100 | Text("Your score is: \(score)") 101 | } isLoading: { optionalLastScore in 102 | MyCustomIsLoadingView(optionalLastScore) 103 | } notRequested: { 104 | MyCustomNotRequestedView() 105 | } 106 | } 107 | } 108 | ``` 109 | 110 | ## Articles 111 | - 112 | - 113 | 114 | ## Topics 115 | 116 | ### State 117 | - ``LoadableState`` 118 | 119 | ### Actions 120 | - ``LoadableAction`` 121 | 122 | ### Reducers 123 | - ``ComposableArchitecture/Reducer/loadable(state:action:)`` 124 | - ``ComposableArchitecture/Reducer/loadable(state:action:operation:)`` 125 | - ``ComposableArchitecture/Reducer/loadable(state:action:on:operation:)`` 126 | 127 | ### Effects 128 | - ``ComposableArchitecture/Effect/load(_:operation:)`` 129 | 130 | ### Views 131 | - ``LoadableView`` 132 | - ``Autoload`` 133 | - ``IsLoadingOrientation`` 134 | - ``IsLoadingView`` 135 | -------------------------------------------------------------------------------- /Sources/Loadable/Loadable.swift: -------------------------------------------------------------------------------- 1 | import CasePaths 2 | import ComposableArchitecture 3 | import CustomDump 4 | import Foundation 5 | 6 | /// Represents different states of a loadable value. 7 | /// 8 | @CasePathable 9 | @ObservableState 10 | @dynamicMemberLookup 11 | public enum LoadableState { 12 | 13 | /// Set when the value has not been requested / loaded yet. 14 | case notRequested 15 | 16 | /// Set when the value is loading, but has been requested previously. 17 | case isLoading(previous: Value?) 18 | 19 | /// Set when the value is loaded. 20 | case loaded(Value) 21 | 22 | /// Access the loaded value if it's been set. 23 | public var rawValue: Value? { 24 | get { 25 | switch self { 26 | case .notRequested: 27 | return nil 28 | case .isLoading(previous: let last): 29 | return last 30 | case .loaded(let value): 31 | return value 32 | } 33 | } 34 | set { 35 | guard let value = newValue else { 36 | self = .notRequested 37 | return 38 | } 39 | self = .loaded(value) 40 | } 41 | } 42 | 43 | public subscript(dynamicMember keyPath: WritableKeyPath) -> T? { 44 | get { self.rawValue?[keyPath: keyPath] } 45 | set { 46 | guard let newValue else { return } 47 | self.rawValue?[keyPath: keyPath] = newValue 48 | } 49 | } 50 | } 51 | extension LoadableState: Equatable where Value: Equatable {} 52 | 53 | extension LoadableState: Hashable where Value: Hashable {} 54 | 55 | extension LoadableState: Decodable where Value: Decodable { 56 | public init(from decoder: Decoder) throws { 57 | do { 58 | let decoded = try decoder.singleValueContainer().decode(Value.self) 59 | self = .loaded(decoded) 60 | } catch { 61 | let decoded = try Value.init(from: decoder) 62 | self = .loaded(decoded) 63 | } 64 | } 65 | } 66 | extension LoadableState: Encodable where Value: Encodable { 67 | public func encode(to encoder: Encoder) throws { 68 | do { 69 | var container = encoder.singleValueContainer() 70 | try container.encode(self.rawValue) 71 | } catch { 72 | try self.rawValue?.encode(to: encoder) 73 | } 74 | } 75 | } 76 | 77 | /// Represents the actions for a loadable value. 78 | @CasePathable 79 | public enum LoadableAction { 80 | 81 | /// Represents when the value should be loaded from a remote source. 82 | case load 83 | 84 | /// Receive a loaded value from a remote source. 85 | case receiveLoaded(TaskResult) 86 | 87 | } 88 | extension LoadableAction: Equatable where State: Equatable {} 89 | 90 | extension Reducer { 91 | 92 | /// Enhances a reducer with the default ``LoadableAction`` implementations. 93 | /// 94 | /// The default implementation will handle setting the ``LoadableState`` appropriately 95 | /// when a value has been loaded from a remote and use the `loadOperation` passed in 96 | /// to load the value when the `triggerAction` is received. 97 | /// 98 | /// > Note: The default implementation does not handle failures during loading, to handle errors 99 | /// > your parent reducer should handle the `.receiveLoaded(.failure(let error))`. 100 | /// 101 | /// 102 | /// - Parameters: 103 | /// - toLoadableState: The key path from the parent state to a ``LoadableState`` instance. 104 | /// - toLoadableAction: The case path from the parent action to a ``LoadableAction`` case. 105 | /// - triggerAction: The case path from the parent action that triggers loading the value. 106 | /// - loadOperation: The operation used to load the value when the `.load` action is received. 107 | public func loadable( 108 | state toLoadableState: WritableKeyPath>, 109 | action toLoadableAction: CaseKeyPath>, 110 | on triggerAction: CaseKeyPath, 111 | operation loadOperation: @Sendable @escaping () async throws -> Value 112 | ) -> _LoadableReducer { 113 | .init( 114 | parent: self, 115 | toLoadableState: toLoadableState, 116 | toLoadableAction: AnyCasePath(toLoadableAction), 117 | loadOperation: loadOperation, 118 | triggerAction: AnyCasePath(triggerAction) 119 | ) 120 | } 121 | 122 | /// Enhances a reducer with the default ``LoadableAction`` implementations. 123 | /// 124 | /// The default implementation will handle setting the ``LoadableState`` appropriately 125 | /// when a value has been loaded from a remote and calls the ``LoadableAction/load`` action 126 | /// to load the value when the `triggerAction` is received. 127 | /// 128 | /// > Note: The default implementation does not handle failures during loading, to handle errors 129 | /// > your parent reducer should handle the `.receiveLoaded(.failure(let error))`. 130 | /// 131 | /// 132 | /// - Parameters: 133 | /// - toLoadableState: The key path from the parent state to a ``LoadableState`` instance. 134 | /// - toLoadableAction: The case path from the parent action to a ``LoadableAction`` case. 135 | /// - triggerAction: The case path from the parent action that triggers loading the value. 136 | public func loadable( 137 | state toLoadableState: WritableKeyPath>, 138 | action toLoadableAction: CaseKeyPath>, 139 | on triggerAction: CaseKeyPath 140 | ) -> _LoadableReducer { 141 | .init( 142 | parent: self, 143 | toLoadableState: toLoadableState, 144 | toLoadableAction: AnyCasePath(toLoadableAction), 145 | loadOperation: nil, 146 | triggerAction: AnyCasePath(triggerAction) 147 | ) 148 | } 149 | 150 | /// Enhances a reducer with the default ``LoadableAction`` implementations. Requires 151 | /// manually handling the loadable actions in the parent reducer. 152 | /// 153 | /// 154 | /// The default implementation will handle setting the ``LoadableState`` appropriately 155 | /// when a value has been loaded from a remote. This overload requires you to manage calling 156 | /// the `.load` action from the parent reducer 157 | /// 158 | /// > Note: The default implementation does not handle failures during loading, to handle errors 159 | /// > your parent reducer should handle the `.receiveLoaded(.failure(let error))`. 160 | /// 161 | /// 162 | /// - Parameters: 163 | /// - toLoadableState: The key path from the parent state to a ``LoadableState`` instance. 164 | /// - toLoadableAction: The case path from the parent action to a ``LoadableAction`` case. 165 | /// - loadOperation: The operation used to load the value when the `.load` action is received. 166 | public func loadable( 167 | state toLoadableState: WritableKeyPath>, 168 | action toLoadableAction: CaseKeyPath>, 169 | operation loadOperation: @Sendable @escaping () async throws -> Value 170 | ) -> _LoadableReducer> { 171 | .init( 172 | parent: self, 173 | toLoadableState: toLoadableState, 174 | toLoadableAction: AnyCasePath(toLoadableAction), 175 | loadOperation: loadOperation, 176 | triggerAction: nil 177 | ) 178 | } 179 | 180 | /// Enhances a reducer with the default ``LoadableAction`` implementations. Requires 181 | /// manually handling the loadable actions in the parent reducer. 182 | /// 183 | /// 184 | /// The default implementation will handle setting the ``LoadableState`` appropriately 185 | /// when a value has been loaded from a remote. This overload requires you to manage calling 186 | /// the `.load` action from the parent reducer as well as supplying the operation to load the 187 | /// value when the `.load` action is called. 188 | /// 189 | /// > Note: The default implementation does not handle failures during loading, to handle errors 190 | /// > your parent reducer should handle the `.receiveLoaded(.failure(let error))`. 191 | /// 192 | /// 193 | /// - Parameters: 194 | /// - toLoadableState: The key path from the parent state to a ``LoadableState`` instance. 195 | /// - toLoadableAction: The case path from the parent action to a ``LoadableAction`` case. 196 | public func loadable( 197 | state toLoadableState: WritableKeyPath>, 198 | action toLoadableAction: CaseKeyPath> 199 | ) -> _LoadableReducer> { 200 | .init( 201 | parent: self, 202 | toLoadableState: toLoadableState, 203 | toLoadableAction: AnyCasePath(toLoadableAction), 204 | loadOperation: nil, 205 | triggerAction: nil 206 | ) 207 | } 208 | } 209 | 210 | extension Effect { 211 | 212 | /// A convenience extension to call a ``LoadableAction/receiveLoaded(_:)`` with the given 213 | /// operation. 214 | /// 215 | /// This is useful if you are managing the ``LoadableAction`` in the parent reducer or using one of 216 | /// the more basic ``ComposableArchitecture/Reducer/loadable(state:action:)`` modifiers. 217 | /// 218 | /// **Example** 219 | /// ```swift 220 | /// @Reducer 221 | /// struct AppReducer { 222 | /// struct State { 223 | /// var int: LoadableState = .notRequested 224 | /// } 225 | /// 226 | /// enum Action { 227 | /// case int(LoadableAction) 228 | /// case task 229 | /// } 230 | /// 231 | /// var body: some ReducerOf { 232 | /// Reduce { state, action in 233 | /// switch action { 234 | /// case .int: 235 | /// return .none 236 | /// case .task: 237 | /// return .load(\.int) { 238 | /// try await myIntLoader() 239 | /// } 240 | /// } 241 | /// } 242 | /// .loadable(state: \.int, action: \.int) 243 | /// } 244 | /// ``` 245 | /// 246 | /// - Parameters: 247 | /// - toLoadableAction: The loadable action to call the `receiveLoaded` on. 248 | /// - operation: The operation used to load the value. 249 | @inlinable 250 | public static func load( 251 | _ toLoadableAction: CaseKeyPath>, 252 | operation: @Sendable @escaping () async throws -> Value 253 | ) -> Self { 254 | .load(AnyCasePath(toLoadableAction), operation) 255 | } 256 | 257 | @usableFromInline 258 | static func load( 259 | _ toLoadableAction: AnyCasePath>, 260 | _ operation: @Sendable @escaping () async throws -> Value 261 | ) -> Self { 262 | .run { send in 263 | await send( 264 | toLoadableAction.embed( 265 | .receiveLoaded( 266 | TaskResult { try await operation() } 267 | )) 268 | ) 269 | } 270 | } 271 | } 272 | 273 | /// The concrete reducer used for the default loading implementation. 274 | /// 275 | /// This should not be used directly, instead use the ``Reducer/loadable``. 276 | /// 277 | public struct _LoadableReducer: Reducer { 278 | 279 | @usableFromInline 280 | let parent: Parent 281 | 282 | @usableFromInline 283 | let toLoadableState: WritableKeyPath> 284 | 285 | @usableFromInline 286 | let toLoadableAction: AnyCasePath> 287 | 288 | @usableFromInline 289 | let loadOperation: (@Sendable () async throws -> Value)? 290 | 291 | @usableFromInline 292 | let triggerAction: AnyCasePath? 293 | 294 | @inlinable 295 | public func reduce( 296 | into state: inout Parent.State, 297 | action: Parent.Action 298 | ) -> Effect { 299 | 300 | let parentEffects: Effect = self.parent.reduce(into: &state, action: action) 301 | 302 | // Short circuit if we are handling the trigger action. 303 | if let triggerAction, 304 | triggerAction.extract(from: action) != nil 305 | { 306 | return .merge( 307 | .send(toLoadableAction.embed(.load)), 308 | parentEffects 309 | ) 310 | } 311 | 312 | // Handle default loadable actions, setting the loadable state 313 | // appropriately for the different actions. 314 | let currentState = state[keyPath: toLoadableState] 315 | var childEffects: Effect = .none 316 | 317 | if let loadableAction = toLoadableAction.extract(from: action) { 318 | switch (currentState.rawValue, loadableAction) { 319 | case let (childState, .load): 320 | state[keyPath: toLoadableState] = .isLoading(previous: childState) 321 | if let loadOperation { 322 | childEffects = .load(toLoadableAction, loadOperation) 323 | } 324 | case let (_, .receiveLoaded(.success(childState))): 325 | state[keyPath: toLoadableState] = .loaded(childState) 326 | case (_, .receiveLoaded): 327 | break 328 | } 329 | } 330 | 331 | return .merge(childEffects, parentEffects) 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /Sources/Loadable/LoadableView.swift: -------------------------------------------------------------------------------- 1 | @_spi(Reflection) import CasePaths 2 | import ComposableArchitecture 3 | import SwiftUI 4 | 5 | /// A view that can handle loadable items using the `ComposableArchitecture` pattern. 6 | /// 7 | /// Although this looks pretty gnarly, it is not that bad from the call-site and allows customization of the views for each state of a ``LoadableState`` 8 | /// item, and also includes default views (i.e. `ProgressView`'s) for when the item(s) are loading. 9 | /// 10 | /// **Example**: 11 | /// ``` swift 12 | /// @Reducer 13 | /// struct App { 14 | /// @ObservableState 15 | /// struct State: Equatable { 16 | /// var int: LoadableState = .notRequested 17 | /// } 18 | /// 19 | /// enum Action: Equatable { 20 | /// case int(LoadableAction) 21 | /// } 22 | /// 23 | /// @Dependency(\.continuousClock) var clock; 24 | /// var body: some ReducerOf { 25 | /// Reduce { state, action in 26 | /// switch action { 27 | /// case .int(.load): 28 | /// return .run { send in 29 | /// await send(.int(.receiveLoaded( 30 | /// TaskResult { 31 | /// /// sleep to act like data is loading from a remote. 32 | /// try await clock.sleep(for: .seconds(2)) 33 | /// return 42 34 | /// } 35 | /// ))) 36 | /// } 37 | /// case .int: 38 | /// return .none 39 | /// } 40 | /// } 41 | /// .loadable(state: \.int, action: \.int) 42 | /// } 43 | /// } 44 | /// 45 | /// struct ContentView: View { 46 | /// let store: StoreOf 47 | /// var body: some View { 48 | /// VStack { 49 | /// LoadableView(store: store.scope(state: \.int, action: \.int)) { int in 50 | /// Text("Loaded: \(int)") 51 | /// } notRequested: { 52 | /// ProgressView() 53 | /// } isLoading: { optionalInt in 54 | /// if let int = optionalInt { 55 | /// // Show this view if we have loaded a value in the past. 56 | /// VStack { 57 | /// ProgressView() 58 | /// .padding() 59 | /// Text("Loading...") 60 | /// } 61 | /// } else: { 62 | /// // Show this view when we have not loaded a value in the past, but our state `.isLoading` 63 | /// ProgressView() 64 | /// } 65 | /// } 66 | /// Button(action: { store.send(.int(.load)) }) { 67 | /// Text("Reload") 68 | /// } 69 | /// .padding(.top) 70 | /// } 71 | /// .padding() 72 | /// } 73 | /// } 74 | ///``` 75 | /// 76 | /// 77 | public struct LoadableView< 78 | State: Equatable, 79 | NotRequested: View, 80 | Loaded: View, 81 | IsLoading: View 82 | >: View { 83 | 84 | private let autoload: Autoload 85 | 86 | private let isLoading: (State?) -> IsLoading 87 | 88 | private let loaded: (State) -> Loaded 89 | 90 | private let notRequested: () -> NotRequested 91 | 92 | @Perception.Bindable private var store: Store, LoadableAction> 93 | 94 | /// Create a ``LoadableView`` without any default view implementations for the ``LoadableState``. 95 | /// 96 | /// - Parameters: 97 | /// - store: The store of the ``LoadableState`` and ``LoadableAction`` 98 | /// - autoload: A flag for if we should call ``LoadableAction/load`` when the view appears. 99 | /// - loaded: The view to show when the state is ``LoadableState/loaded(_:)`` 100 | /// - notRequested: The view to show when the state is ``LoadableState/notRequested`` 101 | /// - isLoading: The view to show when the state is ``LoadableState/isLoading(previous:)`` 102 | public init( 103 | store: Store, LoadableAction>, 104 | autoload: Autoload = .whenNotRequested, 105 | @ViewBuilder loaded: @escaping (State) -> Loaded, 106 | @ViewBuilder notRequested: @escaping () -> NotRequested, 107 | @ViewBuilder isLoading: @escaping (State?) -> IsLoading 108 | ) { 109 | self.autoload = autoload 110 | self.store = store 111 | self.notRequested = notRequested 112 | self.isLoading = isLoading 113 | self.loaded = loaded 114 | } 115 | 116 | public var body: some View { 117 | Group { 118 | switch store.state { 119 | case let .loaded(state): 120 | self.loaded(state) 121 | case let .isLoading(previous: previous): 122 | self.isLoading(previous) 123 | case .notRequested: 124 | self.notRequested() 125 | } 126 | } 127 | .onAppear { 128 | if self.autoload.shouldLoad(store.state) { 129 | store.send(.load) 130 | } 131 | } 132 | } 133 | } 134 | 135 | /// Represents when / if we should call the ``LoadableAction/load`` when a view appears. 136 | /// 137 | public enum Autoload: Equatable { 138 | 139 | /// Always call load when a view appears. 140 | case always 141 | 142 | /// Never call load when a view appears. 143 | case never 144 | 145 | /// Only call load when the state is ``LoadableState/notRequested``. 146 | case whenNotRequested 147 | 148 | func shouldLoad(_ state: LoadableState) -> Bool { 149 | switch self { 150 | case .always: 151 | return true 152 | case .never: 153 | return false 154 | case .whenNotRequested: 155 | return state == .notRequested 156 | } 157 | } 158 | } 159 | 160 | @available(iOS 14.0, macOS 11, tvOS 14, watchOS 7, *) 161 | extension LoadableView where NotRequested == ProgressView { 162 | 163 | /// Create a ``LoadableView`` that uses a `ProgressView` for the ``LoadableState/notRequested`` state, 164 | /// allowing customization of the other view's for ``LoadableState``. 165 | /// 166 | public init( 167 | store: Store, LoadableAction>, 168 | autoload: Autoload = .whenNotRequested, 169 | @ViewBuilder isLoading: @escaping (State?) -> IsLoading, 170 | @ViewBuilder loaded: @escaping (State) -> Loaded 171 | ) { 172 | self.autoload = autoload 173 | self.store = store 174 | self.notRequested = { ProgressView() } 175 | self.isLoading = isLoading 176 | self.loaded = loaded 177 | } 178 | } 179 | 180 | @available(iOS 14.0, macOS 11, tvOS 14, watchOS 7, *) 181 | extension LoadableView 182 | where 183 | NotRequested == ProgressView 184 | { 185 | 186 | /// Create a ``LoadableView`` that uses the default `ProgressView` for when an item is ``LoadableState/notRequested``. 187 | /// And uses an `HStack` or a `VStack` with the `ProgressView` and the `LoadedView` for when an 188 | /// item is in the ``LoadableState/isLoading(previous:)`` state. 189 | /// 190 | /// With this initializer overload, you can specify the `NotRequested` view by using a closure that get's passed a 191 | /// boolean, which is `false` when the loading state is ``LoadableState/notRequested`` or `true` when 192 | /// the loading state is ``LoadableState/isLoading(previous:)``. 193 | /// 194 | /// **Example** 195 | /// ```swift 196 | /// struct ContentView: View { 197 | /// let store: StoreOf 198 | /// var body: some View { 199 | /// VStack { 200 | /// WithViewStore(store, observe: { $0 }) { viewStore in 201 | /// LoadableView( 202 | /// store: store.scope(state: \.$int, action: Preview.Action.int), 203 | /// orientation: .vertical 204 | /// ) { 205 | /// WithViewStore($0, observe: { $0 }) { viewStore in 206 | /// Text("Loaded: \(viewStore.state)") 207 | /// } 208 | /// } notRequested: { isLoading in 209 | /// ProgressView() 210 | /// .scaleEffect(x: isLoading ? 1 : 2, y: isLoading ? 1 : 2, anchor: .center) 211 | /// } 212 | /// Button(action: { viewStore.send(.int(.load)) }) { 213 | /// Text("Reload") 214 | /// } 215 | /// .padding(.top) 216 | /// } 217 | /// } 218 | /// .padding() 219 | /// } 220 | /// } 221 | /// ``` 222 | /// 223 | /// - Parameters: 224 | /// - store: The store of the ``LoadableState`` and ``LoadableAction`` 225 | /// - autoload: A flag for if we should call ``LoadableAction/load`` when the view appears. 226 | /// - isLoadingOrientation: A flag for whether to show the not requested view in an `HStack` or a `VStack`. 227 | /// - loaded: The view to show when the state is ``LoadableState/loaded(_:)`` 228 | /// - notRequested: The view to show when the state is ``LoadableState/notRequested`` 229 | public init( 230 | store: Store, LoadableAction>, 231 | autoload: Autoload = .whenNotRequested, 232 | orientation isLoadingOrientation: IsLoadingOrientation = .horizontal(), 233 | @ViewBuilder loaded: @escaping (State) -> Loaded, 234 | @ViewBuilder notRequested: @escaping (Bool) -> NotRequested 235 | ) 236 | where IsLoading == IsLoadingView { 237 | self.autoload = autoload 238 | self.store = store 239 | self.notRequested = { notRequested(false) } 240 | self.isLoading = { 241 | IsLoadingView( 242 | state: $0, 243 | orientation: isLoadingOrientation, 244 | notRequested: notRequested, 245 | loaded: loaded 246 | ) 247 | } 248 | self.loaded = loaded 249 | } 250 | 251 | /// Create a ``LoadableView`` that uses the default `ProgressView` for when an item is ``LoadableState/notRequested``. 252 | /// And uses an `HStack` or a `VStack` with the `ProgressView` along with the `LoadedView` for when an 253 | /// item is in the ``LoadableState/isLoading(previous:)`` state. 254 | /// 255 | /// ```swift 256 | /// struct ContentView: View { 257 | /// let store: StoreOf 258 | /// var body: some View { 259 | /// VStack { 260 | /// WithViewStore(store, observe: { $0 }) { viewStore in 261 | /// LoadableView(store: store.scope(state: \.$int, action: Preview.Action.int)) { 262 | /// WithViewStore($0, observe: { $0 }) { viewStore in 263 | /// Text("Loaded: \(viewStore.state)") 264 | /// } 265 | /// } 266 | /// Button(action: { viewStore.send(.int(.load)) }) { 267 | /// Text("Reload") 268 | /// } 269 | /// .padding(.top) 270 | /// } 271 | /// } 272 | /// .padding() 273 | /// } 274 | /// } 275 | /// ``` 276 | /// - Parameters: 277 | /// - store: The store of the ``LoadableState`` and ``LoadableAction`` 278 | /// - autoload: A flag for if we should call ``LoadableAction/load`` when the view appears. 279 | /// - isLoadingOrientation: A flag for whether to show the not requested view in an `HStack` or a `VStack`. 280 | /// - loaded: The view to show when the state is ``LoadableState/loaded(_:)`` 281 | /// 282 | public init( 283 | store: Store, LoadableAction>, 284 | autoload: Autoload = .whenNotRequested, 285 | orientation isLoadingOrientation: IsLoadingOrientation = .horizontal(), 286 | @ViewBuilder loaded: @escaping (State) -> Loaded 287 | ) 288 | where IsLoading == IsLoadingView { 289 | let notRequested = { (_: Bool) in ProgressView() } 290 | self.autoload = autoload 291 | self.store = store 292 | self.notRequested = { notRequested(false) } 293 | self.isLoading = { 294 | IsLoadingView( 295 | state: $0, 296 | orientation: isLoadingOrientation, 297 | notRequested: notRequested, 298 | loaded: loaded 299 | ) 300 | } 301 | self.loaded = loaded 302 | } 303 | } 304 | 305 | /// Represents the orentation of an ``IsLoadingView``, and embeds a 306 | /// previously loaded value in either an `HStack` or a `VStack` when the loading state 307 | /// is ``LoadableState/isLoading(previous:)``. 308 | /// 309 | public enum IsLoadingOrientation: Equatable { 310 | 311 | /// Embeds previously loaded values in an `HStack` when the state is ``LoadableState/isLoading(previous:)`` 312 | case horizontal(Horizontal = .leading) 313 | 314 | /// Embeds previously loaded values in a `VStack` when the state is ``LoadableState/isLoading(previous:)`` 315 | case vertical(Vertical = .above) 316 | 317 | /// Represents the orientation of the not requested view in relation to the loaded view, when shown in an `HStack`. 318 | public enum Horizontal: Equatable { 319 | case leading, trailing 320 | } 321 | 322 | /// Represents the orientation of the not requested view in relation to the loaded view, when shown in a `VStack`. 323 | public enum Vertical: Equatable { 324 | case above, below 325 | } 326 | 327 | } 328 | 329 | /// A view that will show the `NotRequested` and `Loaded` views in an `HStack` or a `VStack` based on if a 330 | /// ``LoadableState`` when there has been a previous value loaded, otherwise it will show the `NotRequested` view. 331 | /// 332 | /// This is generally not interacted with directly, but is used for the default for a ``LoadableView/init(store:autoload:isLoadingOrientation:loaded:)`` 333 | /// 334 | public struct IsLoadingView: View { 335 | 336 | private let orientation: IsLoadingOrientation 337 | private let state: State? 338 | private let notRequested: (Bool) -> NotRequested 339 | private let loaded: (State) -> Loaded 340 | 341 | public init( 342 | state: State?, 343 | orientation: IsLoadingOrientation, 344 | @ViewBuilder notRequested: @escaping (Bool) -> NotRequested, 345 | @ViewBuilder loaded: @escaping (State) -> Loaded 346 | ) { 347 | self.state = state 348 | self.notRequested = notRequested 349 | self.loaded = loaded 350 | self.orientation = orientation 351 | } 352 | 353 | public var body: some View { 354 | if let state { 355 | self.buildView(state) 356 | } else { 357 | self.notRequested(false) 358 | } 359 | } 360 | 361 | @ViewBuilder 362 | func buildView( 363 | _ state: State 364 | ) -> some View { 365 | switch self.orientation { 366 | case let .horizontal(orientation): 367 | HStack(spacing: 20) { 368 | switch orientation { 369 | case .leading: 370 | notRequested(true) 371 | loaded(state) 372 | case .trailing: 373 | loaded(state) 374 | notRequested(true) 375 | } 376 | } 377 | case let .vertical(orientation): 378 | VStack(spacing: 10) { 379 | switch orientation { 380 | case .above: 381 | notRequested(true) 382 | loaded(state) 383 | case .below: 384 | loaded(state) 385 | notRequested(true) 386 | } 387 | } 388 | } 389 | } 390 | 391 | } 392 | -------------------------------------------------------------------------------- /TCALoadable_Example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m-housh/swift-tca-loadable/6dfa068f8689a046479f11f11e0e0a627f538e77/TCALoadable_Example.gif -------------------------------------------------------------------------------- /Tests/LoadableTests/LoadableTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import ComposableArchitecture 3 | import Loadable 4 | 5 | struct User: Codable, Identifiable, Equatable { 6 | let id: UUID 7 | let name: String 8 | 9 | static var mock: Self { 10 | @Dependency(\.uuid) var uuid; 11 | return Self.init( 12 | id: uuid(), 13 | name: "Blob" 14 | ) 15 | } 16 | } 17 | 18 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 19 | @Reducer 20 | struct EnvisionedUsage { 21 | struct State: Codable, Equatable { 22 | var user: LoadableState = .notRequested 23 | } 24 | 25 | enum Action: Equatable { 26 | case user(LoadableAction) 27 | case task 28 | } 29 | 30 | @Dependency(\.continuousClock) var clock 31 | 32 | var body: some Reducer { 33 | Reduce { state, action in 34 | switch action { 35 | case .user: 36 | return .none 37 | 38 | case .task: 39 | return .none 40 | } 41 | 42 | } 43 | .loadable(state: \.user, action: \.user, on: \.task) { 44 | try await clock.sleep(for: .seconds(1)) 45 | return User.mock 46 | } 47 | } 48 | } 49 | 50 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 51 | @Reducer 52 | struct TriggerActionReducer { 53 | struct State: Codable, Equatable { 54 | var user: LoadableState = .notRequested 55 | } 56 | 57 | enum Action: Equatable { 58 | case user(LoadableAction) 59 | case task 60 | } 61 | 62 | @Dependency(\.continuousClock) var clock 63 | 64 | var body: some Reducer { 65 | Reduce { state, action in 66 | switch action { 67 | case .user(.load): 68 | return .load(\.user) { 69 | try await clock.sleep(for: .seconds(1)) 70 | return User.mock 71 | } 72 | case .user: 73 | return .none 74 | 75 | case .task: 76 | return .none 77 | } 78 | 79 | } 80 | .loadable(state: \.user, action: \.user, on: \.task) 81 | } 82 | } 83 | 84 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 85 | @Reducer 86 | struct LoadOnlyReducer { 87 | struct State: Codable, Equatable { 88 | var user: LoadableState = .notRequested 89 | } 90 | 91 | enum Action: Equatable { 92 | case user(LoadableAction) 93 | case task 94 | } 95 | 96 | @Dependency(\.continuousClock) var clock 97 | 98 | var body: some Reducer { 99 | Reduce { state, action in 100 | switch action { 101 | case .user: 102 | return .none 103 | 104 | case .task: 105 | return .send(.user(.load)) 106 | } 107 | 108 | } 109 | .loadable(state: \.user, action: \.user) { 110 | try await clock.sleep(for: .seconds(1)) 111 | return User.mock 112 | } 113 | } 114 | } 115 | 116 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 117 | @Reducer 118 | struct ParentOnlyReducer { 119 | struct State: Codable, Equatable { 120 | var user: LoadableState = .notRequested 121 | } 122 | 123 | enum Action: Equatable { 124 | case user(LoadableAction) 125 | case task 126 | } 127 | 128 | @Dependency(\.continuousClock) var clock 129 | 130 | var body: some Reducer { 131 | Reduce { state, action in 132 | switch action { 133 | case .user(.load): 134 | return .load(\.user) { 135 | try await clock.sleep(for: .seconds(1)) 136 | return User.mock 137 | } 138 | case .user: 139 | return .none 140 | 141 | case .task: 142 | return .send(.user(.load)) 143 | } 144 | 145 | } 146 | .loadable(state: \.user, action: \.user) 147 | } 148 | } 149 | 150 | final class TCA_LoadableTests: XCTestCase { 151 | 152 | override func invokeTest() { 153 | withDependencies { 154 | $0.uuid = .incrementing 155 | $0.continuousClock = ImmediateClock() 156 | } operation: { 157 | super.invokeTest() 158 | } 159 | } 160 | 161 | @MainActor 162 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 163 | func test_loadable() async { 164 | 165 | let store = TestStore( 166 | initialState: EnvisionedUsage.State(), 167 | reducer: EnvisionedUsage.init 168 | ) 169 | 170 | let mock = User(id: UUID(0), name: "Blob") 171 | 172 | await store.send(.task) 173 | await store.receive(.user(.load)) { 174 | $0.user = .isLoading(previous: nil) 175 | } 176 | await store.receive(.user(.receiveLoaded(.success(mock)))) { 177 | $0.user = .loaded(mock) 178 | } 179 | await store.send(.user(.load)) { 180 | $0.user = .isLoading(previous: mock) 181 | } 182 | 183 | let mock2 = User(id: UUID(1), name: "Blob") 184 | await store.receive(.user(.receiveLoaded(.success(mock2)))) { 185 | $0.user = .loaded(mock2) 186 | } 187 | } 188 | 189 | @MainActor 190 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 191 | func test_loadable_trigger() async { 192 | 193 | let store = TestStore( 194 | initialState: TriggerActionReducer.State(), 195 | reducer: TriggerActionReducer.init 196 | ) 197 | 198 | let mock = User(id: UUID(0), name: "Blob") 199 | 200 | await store.send(.task) 201 | await store.receive(.user(.load)) { 202 | $0.user = .isLoading(previous: nil) 203 | } 204 | await store.receive(.user(.receiveLoaded(.success(mock)))) { 205 | $0.user = .loaded(mock) 206 | } 207 | await store.send(.user(.load)) { 208 | $0.user = .isLoading(previous: mock) 209 | } 210 | 211 | let mock2 = User(id: UUID(1), name: "Blob") 212 | await store.receive(.user(.receiveLoaded(.success(mock2)))) { 213 | $0.user = .loaded(mock2) 214 | } 215 | } 216 | @MainActor 217 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 218 | func test_load_only_handler() async { 219 | 220 | let store = TestStore( 221 | initialState: LoadOnlyReducer.State(), 222 | reducer: LoadOnlyReducer.init 223 | ) 224 | 225 | let mock = User(id: UUID(0), name: "Blob") 226 | 227 | await store.send(.task) 228 | await store.receive(.user(.load)) { 229 | $0.user = .isLoading(previous: nil) 230 | } 231 | await store.receive(.user(.receiveLoaded(.success(mock)))) { 232 | $0.user = .loaded(mock) 233 | } 234 | await store.send(.user(.load)) { 235 | $0.user = .isLoading(previous: mock) 236 | } 237 | 238 | let mock2 = User(id: UUID(1), name: "Blob") 239 | await store.receive(.user(.receiveLoaded(.success(mock2)))) { 240 | $0.user = .loaded(mock2) 241 | } 242 | } 243 | 244 | @MainActor 245 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 246 | func test_parent_only_handler() async { 247 | 248 | let store = TestStore( 249 | initialState: ParentOnlyReducer.State(), 250 | reducer: ParentOnlyReducer.init 251 | ) 252 | 253 | let mock = User(id: UUID(0), name: "Blob") 254 | 255 | await store.send(.task) 256 | await store.receive(.user(.load)) { 257 | $0.user = .isLoading(previous: nil) 258 | } 259 | await store.receive(.user(.receiveLoaded(.success(mock)))) { 260 | $0.user = .loaded(mock) 261 | } 262 | await store.send(.user(.load)) { 263 | $0.user = .isLoading(previous: mock) 264 | } 265 | 266 | let mock2 = User(id: UUID(1), name: "Blob") 267 | await store.receive(.user(.receiveLoaded(.success(mock2)))) { 268 | $0.user = .loaded(mock2) 269 | } 270 | } 271 | 272 | @MainActor 273 | func test_codable() throws { 274 | let json = """ 275 | { 276 | "user" : { 277 | "id" : "00000000-0000-0000-0000-000000000000", 278 | "name" : "Blob" 279 | } 280 | } 281 | """ 282 | let decoded = try JSONDecoder() 283 | .decode(EnvisionedUsage.State.self, from: Data(json.utf8)) 284 | 285 | let mock = withDependencies { 286 | $0.uuid = .incrementing 287 | } operation: { 288 | User.mock 289 | } 290 | 291 | let state = EnvisionedUsage.State(user: .loaded(mock)) 292 | XCTAssertEqual(decoded, state) 293 | 294 | let encoder = JSONEncoder() 295 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 296 | let encoded = try encoder.encode(state) 297 | 298 | let string = String(data: encoded, encoding: .utf8)! 299 | XCTAssertEqual(string, json) 300 | } 301 | } 302 | --------------------------------------------------------------------------------