├── .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 | [](https://github.com/m-housh/swift-tca-loadable/actions/workflows/ci.yml)
2 | [](https://swiftpackageindex.com/m-housh/swift-tca-loadable)
3 | [](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 | 
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 |
--------------------------------------------------------------------------------