├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── App
├── .swiftpm
│ └── xcode
│ │ └── package.xcworkspace
│ │ └── contents.xcworkspacedata
├── DogBreeds.xcodeproj
│ ├── project.pbxproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ │ └── xcschemes
│ │ └── DogBreeds.xcscheme
├── DogBreeds
│ ├── Assets.xcassets
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ └── Contents.json
│ │ └── Contents.json
│ ├── DogBreedsApp.swift
│ ├── Info.plist
│ └── Preview Content
│ │ └── Preview Assets.xcassets
│ │ └── Contents.json
└── Package.swift
├── DogBreeds.xcworkspace
├── contents.xcworkspacedata
└── xcshareddata
│ ├── IDEWorkspaceChecks.plist
│ └── swiftpm
│ └── Package.resolved
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
└── DogBreedsComponent
│ ├── App
│ ├── Models
│ │ └── Dog.swift
│ ├── Store
│ │ ├── AppAction.swift
│ │ ├── AppState+Reducer.swift
│ │ ├── AppState.swift
│ │ └── SharedStates
│ │ │ └── DogsSharedState.swift
│ └── Views
│ │ └── RootView.swift
│ ├── Breed
│ ├── Store
│ │ ├── BreedAction.swift
│ │ ├── BreedEnvironment.swift
│ │ ├── BreedState+Reducer.swift
│ │ └── BreedState.swift
│ └── Views
│ │ ├── BreedView+ViewAction.swift
│ │ ├── BreedView+ViewState.swift
│ │ └── BreedView.swift
│ ├── Dogs
│ ├── Store
│ │ ├── DogsAction.swift
│ │ ├── DogsEnvironment.swift
│ │ ├── DogsState+Reducer.swift
│ │ └── DogsState.swift
│ └── Views
│ │ ├── DogsView+ViewAction.swift
│ │ ├── DogsView+ViewState.swift
│ │ └── DogsView.swift
│ └── Utils
│ ├── KFImage+Header.swift
│ └── String+Capitalized.swift
├── Tests
└── DogBreedsComponentTests
│ ├── App
│ └── Store
│ │ └── AppStoreTests.swift
│ ├── Breed
│ ├── Store
│ │ └── BreedStoreTests.swift
│ └── Views
│ │ └── BreedViewStateConverterTests.swift
│ └── Dogs
│ ├── Store
│ └── DogsStoreTests.swift
│ └── Views
│ └── DogsViewStateConverterTests.swift
└── resources
├── AppDemo0.gif
├── Breeds
└── Final.png
├── Dogs
├── Final.gif
├── Loaded.png
└── Loading.png
└── setup
├── Step0.png
└── Step1.png
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/App/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/App/DogBreeds.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 52;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 5A26E660263182E300F75084 /* DogBreedsComponent in Frameworks */ = {isa = PBXBuildFile; productRef = 5A26E65F263182E300F75084 /* DogBreedsComponent */; };
11 | 5A2DA2E026317B7B001066FD /* DogBreedsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2DA2DF26317B7B001066FD /* DogBreedsApp.swift */; };
12 | 5A2DA2E426317B7F001066FD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A2DA2E326317B7F001066FD /* Assets.xcassets */; };
13 | 5A2DA2E726317B7F001066FD /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A2DA2E626317B7F001066FD /* Preview Assets.xcassets */; };
14 | /* End PBXBuildFile section */
15 |
16 | /* Begin PBXFileReference section */
17 | 5A2DA2DC26317B7B001066FD /* DogBreeds.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DogBreeds.app; sourceTree = BUILT_PRODUCTS_DIR; };
18 | 5A2DA2DF26317B7B001066FD /* DogBreedsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DogBreedsApp.swift; sourceTree = ""; };
19 | 5A2DA2E326317B7F001066FD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
20 | 5A2DA2E626317B7F001066FD /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
21 | 5A2DA2E826317B7F001066FD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
22 | /* End PBXFileReference section */
23 |
24 | /* Begin PBXFrameworksBuildPhase section */
25 | 5A2DA2D926317B7B001066FD /* Frameworks */ = {
26 | isa = PBXFrameworksBuildPhase;
27 | buildActionMask = 2147483647;
28 | files = (
29 | 5A26E660263182E300F75084 /* DogBreedsComponent in Frameworks */,
30 | );
31 | runOnlyForDeploymentPostprocessing = 0;
32 | };
33 | /* End PBXFrameworksBuildPhase section */
34 |
35 | /* Begin PBXGroup section */
36 | 5A2DA2D326317B7B001066FD = {
37 | isa = PBXGroup;
38 | children = (
39 | 5A2DA2DE26317B7B001066FD /* DogBreeds */,
40 | 5A2DA2DD26317B7B001066FD /* Products */,
41 | 5A9755B926317E3600CB59AB /* Frameworks */,
42 | );
43 | sourceTree = "";
44 | };
45 | 5A2DA2DD26317B7B001066FD /* Products */ = {
46 | isa = PBXGroup;
47 | children = (
48 | 5A2DA2DC26317B7B001066FD /* DogBreeds.app */,
49 | );
50 | name = Products;
51 | sourceTree = "";
52 | };
53 | 5A2DA2DE26317B7B001066FD /* DogBreeds */ = {
54 | isa = PBXGroup;
55 | children = (
56 | 5A2DA2DF26317B7B001066FD /* DogBreedsApp.swift */,
57 | 5A2DA2E326317B7F001066FD /* Assets.xcassets */,
58 | 5A2DA2E826317B7F001066FD /* Info.plist */,
59 | 5A2DA2E526317B7F001066FD /* Preview Content */,
60 | );
61 | path = DogBreeds;
62 | sourceTree = "";
63 | };
64 | 5A2DA2E526317B7F001066FD /* Preview Content */ = {
65 | isa = PBXGroup;
66 | children = (
67 | 5A2DA2E626317B7F001066FD /* Preview Assets.xcassets */,
68 | );
69 | path = "Preview Content";
70 | sourceTree = "";
71 | };
72 | 5A9755B926317E3600CB59AB /* Frameworks */ = {
73 | isa = PBXGroup;
74 | children = (
75 | );
76 | name = Frameworks;
77 | sourceTree = "";
78 | };
79 | /* End PBXGroup section */
80 |
81 | /* Begin PBXNativeTarget section */
82 | 5A2DA2DB26317B7B001066FD /* DogBreeds */ = {
83 | isa = PBXNativeTarget;
84 | buildConfigurationList = 5A2DA2EB26317B7F001066FD /* Build configuration list for PBXNativeTarget "DogBreeds" */;
85 | buildPhases = (
86 | 5A2DA2D826317B7B001066FD /* Sources */,
87 | 5A2DA2D926317B7B001066FD /* Frameworks */,
88 | 5A2DA2DA26317B7B001066FD /* Resources */,
89 | );
90 | buildRules = (
91 | );
92 | dependencies = (
93 | );
94 | name = DogBreeds;
95 | packageProductDependencies = (
96 | 5A26E65F263182E300F75084 /* DogBreedsComponent */,
97 | );
98 | productName = DogsBreeds;
99 | productReference = 5A2DA2DC26317B7B001066FD /* DogBreeds.app */;
100 | productType = "com.apple.product-type.application";
101 | };
102 | /* End PBXNativeTarget section */
103 |
104 | /* Begin PBXProject section */
105 | 5A2DA2D426317B7B001066FD /* Project object */ = {
106 | isa = PBXProject;
107 | attributes = {
108 | LastSwiftUpdateCheck = 1240;
109 | LastUpgradeCheck = 1240;
110 | TargetAttributes = {
111 | 5A2DA2DB26317B7B001066FD = {
112 | CreatedOnToolsVersion = 12.4;
113 | };
114 | };
115 | };
116 | buildConfigurationList = 5A2DA2D726317B7B001066FD /* Build configuration list for PBXProject "DogBreeds" */;
117 | compatibilityVersion = "Xcode 9.3";
118 | developmentRegion = en;
119 | hasScannedForEncodings = 0;
120 | knownRegions = (
121 | en,
122 | Base,
123 | );
124 | mainGroup = 5A2DA2D326317B7B001066FD;
125 | productRefGroup = 5A2DA2DD26317B7B001066FD /* Products */;
126 | projectDirPath = "";
127 | projectRoot = "";
128 | targets = (
129 | 5A2DA2DB26317B7B001066FD /* DogBreeds */,
130 | );
131 | };
132 | /* End PBXProject section */
133 |
134 | /* Begin PBXResourcesBuildPhase section */
135 | 5A2DA2DA26317B7B001066FD /* Resources */ = {
136 | isa = PBXResourcesBuildPhase;
137 | buildActionMask = 2147483647;
138 | files = (
139 | 5A2DA2E726317B7F001066FD /* Preview Assets.xcassets in Resources */,
140 | 5A2DA2E426317B7F001066FD /* Assets.xcassets in Resources */,
141 | );
142 | runOnlyForDeploymentPostprocessing = 0;
143 | };
144 | /* End PBXResourcesBuildPhase section */
145 |
146 | /* Begin PBXSourcesBuildPhase section */
147 | 5A2DA2D826317B7B001066FD /* Sources */ = {
148 | isa = PBXSourcesBuildPhase;
149 | buildActionMask = 2147483647;
150 | files = (
151 | 5A2DA2E026317B7B001066FD /* DogBreedsApp.swift in Sources */,
152 | );
153 | runOnlyForDeploymentPostprocessing = 0;
154 | };
155 | /* End PBXSourcesBuildPhase section */
156 |
157 | /* Begin XCBuildConfiguration section */
158 | 5A2DA2E926317B7F001066FD /* Debug */ = {
159 | isa = XCBuildConfiguration;
160 | buildSettings = {
161 | ALWAYS_SEARCH_USER_PATHS = NO;
162 | CLANG_ANALYZER_NONNULL = YES;
163 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
164 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
165 | CLANG_CXX_LIBRARY = "libc++";
166 | CLANG_ENABLE_MODULES = YES;
167 | CLANG_ENABLE_OBJC_ARC = YES;
168 | CLANG_ENABLE_OBJC_WEAK = YES;
169 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
170 | CLANG_WARN_BOOL_CONVERSION = YES;
171 | CLANG_WARN_COMMA = YES;
172 | CLANG_WARN_CONSTANT_CONVERSION = YES;
173 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
174 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
175 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
176 | CLANG_WARN_EMPTY_BODY = YES;
177 | CLANG_WARN_ENUM_CONVERSION = YES;
178 | CLANG_WARN_INFINITE_RECURSION = YES;
179 | CLANG_WARN_INT_CONVERSION = YES;
180 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
181 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
182 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
183 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
184 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
185 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
186 | CLANG_WARN_STRICT_PROTOTYPES = YES;
187 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
188 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
189 | CLANG_WARN_UNREACHABLE_CODE = YES;
190 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
191 | COPY_PHASE_STRIP = NO;
192 | DEBUG_INFORMATION_FORMAT = dwarf;
193 | ENABLE_STRICT_OBJC_MSGSEND = YES;
194 | ENABLE_TESTABILITY = YES;
195 | GCC_C_LANGUAGE_STANDARD = gnu11;
196 | GCC_DYNAMIC_NO_PIC = NO;
197 | GCC_NO_COMMON_BLOCKS = YES;
198 | GCC_OPTIMIZATION_LEVEL = 0;
199 | GCC_PREPROCESSOR_DEFINITIONS = (
200 | "DEBUG=1",
201 | "$(inherited)",
202 | );
203 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
204 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
205 | GCC_WARN_UNDECLARED_SELECTOR = YES;
206 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
207 | GCC_WARN_UNUSED_FUNCTION = YES;
208 | GCC_WARN_UNUSED_VARIABLE = YES;
209 | IPHONEOS_DEPLOYMENT_TARGET = 14.4;
210 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
211 | MTL_FAST_MATH = YES;
212 | ONLY_ACTIVE_ARCH = YES;
213 | SDKROOT = iphoneos;
214 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
215 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
216 | };
217 | name = Debug;
218 | };
219 | 5A2DA2EA26317B7F001066FD /* Release */ = {
220 | isa = XCBuildConfiguration;
221 | buildSettings = {
222 | ALWAYS_SEARCH_USER_PATHS = NO;
223 | CLANG_ANALYZER_NONNULL = YES;
224 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
225 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
226 | CLANG_CXX_LIBRARY = "libc++";
227 | CLANG_ENABLE_MODULES = YES;
228 | CLANG_ENABLE_OBJC_ARC = YES;
229 | CLANG_ENABLE_OBJC_WEAK = YES;
230 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
231 | CLANG_WARN_BOOL_CONVERSION = YES;
232 | CLANG_WARN_COMMA = YES;
233 | CLANG_WARN_CONSTANT_CONVERSION = YES;
234 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
235 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
236 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
237 | CLANG_WARN_EMPTY_BODY = YES;
238 | CLANG_WARN_ENUM_CONVERSION = YES;
239 | CLANG_WARN_INFINITE_RECURSION = YES;
240 | CLANG_WARN_INT_CONVERSION = YES;
241 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
242 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
243 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
244 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
245 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
246 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
247 | CLANG_WARN_STRICT_PROTOTYPES = YES;
248 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
249 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
250 | CLANG_WARN_UNREACHABLE_CODE = YES;
251 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
252 | COPY_PHASE_STRIP = NO;
253 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
254 | ENABLE_NS_ASSERTIONS = NO;
255 | ENABLE_STRICT_OBJC_MSGSEND = YES;
256 | GCC_C_LANGUAGE_STANDARD = gnu11;
257 | GCC_NO_COMMON_BLOCKS = YES;
258 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
259 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
260 | GCC_WARN_UNDECLARED_SELECTOR = YES;
261 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
262 | GCC_WARN_UNUSED_FUNCTION = YES;
263 | GCC_WARN_UNUSED_VARIABLE = YES;
264 | IPHONEOS_DEPLOYMENT_TARGET = 14.4;
265 | MTL_ENABLE_DEBUG_INFO = NO;
266 | MTL_FAST_MATH = YES;
267 | SDKROOT = iphoneos;
268 | SWIFT_COMPILATION_MODE = wholemodule;
269 | SWIFT_OPTIMIZATION_LEVEL = "-O";
270 | VALIDATE_PRODUCT = YES;
271 | };
272 | name = Release;
273 | };
274 | 5A2DA2EC26317B7F001066FD /* Debug */ = {
275 | isa = XCBuildConfiguration;
276 | buildSettings = {
277 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
278 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
279 | CODE_SIGN_STYLE = Automatic;
280 | DEVELOPMENT_ASSET_PATHS = "\"DogBreeds/Preview Content\"";
281 | ENABLE_PREVIEWS = YES;
282 | INFOPLIST_FILE = DogBreeds/Info.plist;
283 | IPHONEOS_DEPLOYMENT_TARGET = 14.0;
284 | LD_RUNPATH_SEARCH_PATHS = (
285 | "$(inherited)",
286 | "@executable_path/Frameworks",
287 | );
288 | PRODUCT_BUNDLE_IDENTIFIER = nope.DogBreeds;
289 | PRODUCT_NAME = "$(TARGET_NAME)";
290 | SWIFT_VERSION = 5.0;
291 | TARGETED_DEVICE_FAMILY = "1,2";
292 | };
293 | name = Debug;
294 | };
295 | 5A2DA2ED26317B7F001066FD /* Release */ = {
296 | isa = XCBuildConfiguration;
297 | buildSettings = {
298 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
299 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
300 | CODE_SIGN_STYLE = Automatic;
301 | DEVELOPMENT_ASSET_PATHS = "\"DogBreeds/Preview Content\"";
302 | ENABLE_PREVIEWS = YES;
303 | INFOPLIST_FILE = DogBreeds/Info.plist;
304 | IPHONEOS_DEPLOYMENT_TARGET = 14.0;
305 | LD_RUNPATH_SEARCH_PATHS = (
306 | "$(inherited)",
307 | "@executable_path/Frameworks",
308 | );
309 | PRODUCT_BUNDLE_IDENTIFIER = nope.DogBreeds;
310 | PRODUCT_NAME = "$(TARGET_NAME)";
311 | SWIFT_VERSION = 5.0;
312 | TARGETED_DEVICE_FAMILY = "1,2";
313 | };
314 | name = Release;
315 | };
316 | /* End XCBuildConfiguration section */
317 |
318 | /* Begin XCConfigurationList section */
319 | 5A2DA2D726317B7B001066FD /* Build configuration list for PBXProject "DogBreeds" */ = {
320 | isa = XCConfigurationList;
321 | buildConfigurations = (
322 | 5A2DA2E926317B7F001066FD /* Debug */,
323 | 5A2DA2EA26317B7F001066FD /* Release */,
324 | );
325 | defaultConfigurationIsVisible = 0;
326 | defaultConfigurationName = Release;
327 | };
328 | 5A2DA2EB26317B7F001066FD /* Build configuration list for PBXNativeTarget "DogBreeds" */ = {
329 | isa = XCConfigurationList;
330 | buildConfigurations = (
331 | 5A2DA2EC26317B7F001066FD /* Debug */,
332 | 5A2DA2ED26317B7F001066FD /* Release */,
333 | );
334 | defaultConfigurationIsVisible = 0;
335 | defaultConfigurationName = Release;
336 | };
337 | /* End XCConfigurationList section */
338 |
339 | /* Begin XCSwiftPackageProductDependency section */
340 | 5A26E65F263182E300F75084 /* DogBreedsComponent */ = {
341 | isa = XCSwiftPackageProductDependency;
342 | productName = DogBreedsComponent;
343 | };
344 | /* End XCSwiftPackageProductDependency section */
345 | };
346 | rootObject = 5A2DA2D426317B7B001066FD /* Project object */;
347 | }
348 |
--------------------------------------------------------------------------------
/App/DogBreeds.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/App/DogBreeds.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/App/DogBreeds.xcodeproj/xcshareddata/xcschemes/DogBreeds.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
43 |
45 |
51 |
52 |
53 |
54 |
60 |
62 |
68 |
69 |
70 |
71 |
73 |
74 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/App/DogBreeds/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 |
--------------------------------------------------------------------------------
/App/DogBreeds/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "scale" : "1x",
46 | "size" : "20x20"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "scale" : "2x",
51 | "size" : "20x20"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "scale" : "1x",
56 | "size" : "29x29"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "scale" : "2x",
61 | "size" : "29x29"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "scale" : "1x",
66 | "size" : "40x40"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "scale" : "2x",
71 | "size" : "40x40"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "scale" : "1x",
76 | "size" : "76x76"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "scale" : "2x",
81 | "size" : "76x76"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "scale" : "2x",
86 | "size" : "83.5x83.5"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "scale" : "1x",
91 | "size" : "1024x1024"
92 | }
93 | ],
94 | "info" : {
95 | "author" : "xcode",
96 | "version" : 1
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/App/DogBreeds/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/App/DogBreeds/DogBreedsApp.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import DogBreedsComponent
3 | import SwiftUI
4 |
5 | @main
6 | struct DogBreedsApp: App {
7 | var body: some Scene {
8 | WindowGroup {
9 | NavigationView {
10 | RootView(
11 | store: Store(
12 | initialState: .initial,
13 | reducer: AppState.reducer,
14 | environment: ()
15 | )
16 | )
17 | }
18 | }
19 | }
20 | }
21 |
22 | #if DEBUG
23 | struct DogBreedsApp_Previews: PreviewProvider {
24 | static var previews: some View {
25 | NavigationView {
26 | RootView(
27 | store: Store(
28 | initialState: .initial,
29 | reducer: AppState.reducer,
30 | environment: ()
31 | )
32 | )
33 | }
34 | }
35 | }
36 | #endif
37 |
--------------------------------------------------------------------------------
/App/DogBreeds/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 |
28 | UIApplicationSupportsIndirectInputEvents
29 |
30 | UILaunchScreen
31 |
32 | UIRequiredDeviceCapabilities
33 |
34 | armv7
35 |
36 | UISupportedInterfaceOrientations
37 |
38 | UIInterfaceOrientationPortrait
39 | UIInterfaceOrientationLandscapeLeft
40 | UIInterfaceOrientationLandscapeRight
41 |
42 | UISupportedInterfaceOrientations~ipad
43 |
44 | UIInterfaceOrientationPortrait
45 | UIInterfaceOrientationPortraitUpsideDown
46 | UIInterfaceOrientationLandscapeLeft
47 | UIInterfaceOrientationLandscapeRight
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/App/DogBreeds/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/App/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 |
3 | // Leave blank. This is only here so that Xcode doesn't display it.
4 |
5 | import PackageDescription
6 |
7 | let package = Package(
8 | name: "client",
9 | products: [],
10 | targets: []
11 | )
12 |
--------------------------------------------------------------------------------
/DogBreeds.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/DogBreeds.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/DogBreeds.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "combine-schedulers",
6 | "repositoryURL": "https://github.com/pointfreeco/combine-schedulers",
7 | "state": {
8 | "branch": null,
9 | "revision": "c37e5ae8012fb654af776cc556ff8ae64398c841",
10 | "version": "0.5.0"
11 | }
12 | },
13 | {
14 | "package": "Kingfisher",
15 | "repositoryURL": "https://github.com/onevcat/Kingfisher",
16 | "state": {
17 | "branch": null,
18 | "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8",
19 | "version": "6.2.1"
20 | }
21 | },
22 | {
23 | "package": "swift-case-paths",
24 | "repositoryURL": "https://github.com/pointfreeco/swift-case-paths",
25 | "state": {
26 | "branch": null,
27 | "revision": "a313f0cc10e07bb5ce7e2ff5da600cce7efa8e8a",
28 | "version": "0.2.0"
29 | }
30 | },
31 | {
32 | "package": "swift-composable-architecture",
33 | "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture.git",
34 | "state": {
35 | "branch": null,
36 | "revision": "84b9a004384eb8dcd2fd817a5bc61d0152a47422",
37 | "version": "0.17.0"
38 | }
39 | },
40 | {
41 | "package": "xctest-dynamic-overlay",
42 | "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay",
43 | "state": {
44 | "branch": null,
45 | "revision": "603974e3909ad4b48ba04aad7e0ceee4f077a518",
46 | "version": "0.1.0"
47 | }
48 | }
49 | ]
50 | },
51 | "version": 1
52 | }
53 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "combine-schedulers",
6 | "repositoryURL": "https://github.com/pointfreeco/combine-schedulers",
7 | "state": {
8 | "branch": null,
9 | "revision": "c37e5ae8012fb654af776cc556ff8ae64398c841",
10 | "version": "0.5.0"
11 | }
12 | },
13 | {
14 | "package": "Kingfisher",
15 | "repositoryURL": "https://github.com/onevcat/Kingfisher",
16 | "state": {
17 | "branch": null,
18 | "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8",
19 | "version": "6.2.1"
20 | }
21 | },
22 | {
23 | "package": "swift-case-paths",
24 | "repositoryURL": "https://github.com/pointfreeco/swift-case-paths",
25 | "state": {
26 | "branch": null,
27 | "revision": "1aa1bf7c4069d9ba2f7edd36dbfc96ff1c58cbff",
28 | "version": "0.1.3"
29 | }
30 | },
31 | {
32 | "package": "swift-composable-architecture",
33 | "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture.git",
34 | "state": {
35 | "branch": null,
36 | "revision": "84b9a004384eb8dcd2fd817a5bc61d0152a47422",
37 | "version": "0.17.0"
38 | }
39 | },
40 | {
41 | "package": "xctest-dynamic-overlay",
42 | "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay",
43 | "state": {
44 | "branch": null,
45 | "revision": "603974e3909ad4b48ba04aad7e0ceee4f077a518",
46 | "version": "0.1.0"
47 | }
48 | }
49 | ]
50 | },
51 | "version": 1
52 | }
53 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "DogBreedsComponent",
8 | platforms: [.iOS(.v14)],
9 | products: [
10 | .library(
11 | name: "DogBreedsComponent",
12 | targets: ["DogBreedsComponent"]
13 | ),
14 | ],
15 | dependencies: [
16 | .package(
17 | name: "swift-composable-architecture",
18 | url: "https://github.com/pointfreeco/swift-composable-architecture.git",
19 | .exact("0.17.0")
20 | ),
21 | .package(
22 | name: "Kingfisher",
23 | url: "https://github.com/onevcat/Kingfisher",
24 | .exact("6.2.1"))
25 | ],
26 | targets: [
27 | .target(
28 | name: "DogBreedsComponent",
29 | dependencies: [
30 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
31 | .product(name: "Kingfisher", package: "Kingfisher")
32 | ]
33 | ),
34 | .testTarget(
35 | name: "DogBreedsComponentTests",
36 | dependencies: [
37 | "DogBreedsComponent",
38 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture")
39 | ]
40 | ),
41 | ]
42 | )
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Composable Architecture tutorial
2 |
3 | > _Disclaimer_: This tutorial uses **Xcode 12.4**, **Swift 5.3**, and **iOS 14**.
4 |
5 | > You can find the source code of this tutorial [here](https://github.com/Atimca/TCA-tutorial). If you're so eager to try it that you want to skip the tutorial, just launch `RootView.swift` preview.
6 |
7 | ## Introduction
8 |
9 | Today we are going to build a simple app, consisting of 2 screens, using `the composable architecture` (TCA for short). `TCA` is a variant of an [unidirectional architecture](https://medium.com/@atimca/how-to-cook-reactive-programming-part-1-unidirectional-architectures-introduction-5c73f3f7793d) built upon [reactive programming principles](https://medium.com/atimca/what-is-reactive-programming-43e60cc4c0f?source=friends_link&sk=4ab8aa82f6e669bad59be42cba67e0ef), created by PointFree. They provide extensive documentation about it and its creation process. You can check it out [here](https://www.pointfree.co/collections/composable-architecture). Interesting stuff and highly recommended if you like to understand every concept as intended by its creators.
10 |
11 | The scope of this tutorial is to help onboard the TCA concepts as soon as possible. Which makes it a handy tutorial for people that need to work with these concepts right away.
12 |
13 | This is what we are aiming for:
14 |
15 | 
16 |
17 | For this tutorial, we are going to use an open [Dogs API](https://dog.ceo/dog-api/documentation/
18 | ) which doesn't require a token and perfectly suits the needs of this article. Let's take a look at the app structure.
19 |
20 | - **Main screen:** List of dog breeds with a possibility to filter by name.
21 | - **Detailed screen:** A screen with a particular dog's breed as a title, random dog image + a list of dog's sub-breeds if available.
22 |
23 | Both screens are going to be separate modules that don't know about each other, so you can see modularization techniques of `TCA` as well. However, let's start with a bit of theoretical knowledge so we know what we’re doing here.
24 |
25 | ## What is The Composable Architecture
26 |
27 | `TCA` is one of the variations of [unidirectional architectures](https://medium.com/@atimca/how-to-cook-reactive-programming-part-1-unidirectional-architectures-introduction-5c73f3f7793d) family, such as `Redux`, `RxFeedback`, `Flux` etc. Let's just copy some explanation from the [official GitHub](https://github.com/pointfreeco/swift-composable-architecture).
28 |
29 | This library provides a few core tools you can use to build applications of varying purposes and complexity. It provides compelling stories that you can follow to solve many day-to-day problems you encounter when building applications, such as:
30 |
31 | * **State management**
32 |
How to manage the state of your application using simple value types, and share the state across many screens. This way, you can see mutations in one screen immediately in another.
33 |
34 | * **Composition**
35 |
How to break down large features into smaller components that can be extracted to their own, isolated modules. And that you can easily glued back together to form the feature.
36 |
37 | * **Side effects**
38 |
How to let certain parts of the application talk to the outside world in the most testable and understandable way possible.
39 |
40 | * **Testing**
41 |
How to test a feature built in the architecture, but also write integration tests for features that have been composed of many parts. Also: how to write end-to-end tests to understand how side effects influence your application. This allows you to make strong guarantees that your business logic is running in the way you expect it to.
42 |
43 | * **Ergonomics**
44 |
How to accomplish all of the above in a simple API with as few concepts and moving parts as possible.
45 |
46 | To build a feature using the Composable Architecture, let's define some types and values that model a domain:
47 |
48 | * **State**
49 |
A type that describes the data your feature needs to perform its logic and render its UI.
50 |
51 | * **Action**
52 |
A type that represents all actions that can happen in your features, such as user actions, notifications, event sources et cetera.
53 |
54 | * **Environment**
55 |
A type that holds any dependencies the feature needs, like API clients, analytics clients and so on.
56 |
57 | * **Reducer**
58 |
A function that describes how to evolve the current state of the app to the next state given an action. The reducer is also responsible for returning any effects that should be run, such as API requests, which can be done by returning an Effect value.
59 |
60 | * **Store**
61 |
The runtime that actually drives your feature. You send all user actions to the store so that the store can run the reducer and effects. You can also check state changes in the store so that you can update UI.
62 |
63 | Now that the introduction is done, let's get to the action 🚀
64 |
65 | ## The Setup
66 |
67 | Usually, every iOS tutorial starts with the project creation. But now, since it’s 2021 and all, you only need [SPM](https://swift.org/package-manager/) to build this small app. Let's do a little bit of [RW](https://www.raywenderlich.com) style.
68 |
69 | In Xcode, select **File ▸ New ▸ Swift Package…**. Then set the **Product Name** to **DogBreedsComponent**.
70 |
71 | 
72 |
73 | Click **Create** et voilà. Easy peasy right?
74 |
75 | 
76 |
77 | ### Adding necessary dependencies to the Project
78 |
79 | Let's update the `Package.swift` file by adding necessary dependencies that we need for this tutorial. The final result will look like this:
80 |
81 | ```swift
82 | // swift-tools-version:5.3
83 | // The swift-tools-version declares the minimum version of Swift required to build this package.
84 |
85 | import PackageDescription
86 |
87 | let package = Package(
88 | name: "DogBreedsComponent",
89 | platforms: [.iOS(.v14)],
90 | products: [
91 | .library(
92 | name: "DogBreedsComponent",
93 | targets: ["DogBreedsComponent"]
94 | ),
95 | ],
96 | dependencies: [
97 | // 1.
98 | .package(
99 | name: "swift-composable-architecture",
100 | url: "https://github.com/pointfreeco/swift-composable-architecture.git",
101 | .exact("0.17.0")
102 | ),
103 | // 2.
104 | .package(
105 | name: "Kingfisher",
106 | url: "https://github.com/onevcat/Kingfisher",
107 | .exact("6.2.1")
108 | )
109 | ],
110 | targets: [
111 | .target(
112 | name: "DogBreedsComponent",
113 | dependencies: [
114 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
115 | .product(name: "Kingfisher", package: "Kingfisher")
116 | ]
117 | ),
118 | .testTarget(
119 | name: "DogBreedsComponentTests",
120 | dependencies: [
121 | "DogBreedsComponent",
122 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture")
123 | ]
124 | ),
125 | ]
126 | )
127 |
128 | ```
129 |
130 | We've added 2 dependencies to the package:
131 |
132 | 1. `TCA` framework itself
133 | 2. `Kingfisher` library, that we’re going to use for async image loading
134 |
135 | ### Extras
136 |
137 | For the implementation of this tutorial there are 2 simple extensions
138 |
139 | #### `KFImage+Header.swift`
140 |
141 | An extension that helps to build a proper layout with **KFImage**. That’s an **Image** type from the `Kingfisher` library, with a possibility to load async images:
142 |
143 | ```swift
144 | import Kingfisher
145 | import SwiftUI
146 |
147 | extension KFImage {
148 | func header() -> some View {
149 | GeometryReader { geometry in
150 | resizable()
151 | .placeholder {
152 | ProgressView()
153 | .frame(height: 240)
154 | }
155 | .aspectRatio(contentMode: .fill)
156 | .frame(width: geometry.size.width, height: 240)
157 | .clipped()
158 | }
159 | .frame(height: 240)
160 | }
161 | }
162 | ```
163 |
164 | #### `String+Capitalized.swift`
165 |
166 | An extension we're going to use for styling strings.
167 |
168 | ```swift
169 | extension String {
170 | var capitalizedFirstLetter: String {
171 | prefix(1).capitalized + dropFirst()
172 | }
173 | }
174 | ```
175 |
176 | Please copy these 2 extensions into your project, to make sure your journey goes as smooth as butter. 🧈
177 |
178 | ## Dogs module
179 |
180 | First, we're going to build the Dogs module. Basically the main and start module of this tutorial. With TCA it's usually possible to start with 2 directions: business or layout. You could even split this work between 2 people via splitting business-logic-based `State` from view/layout-based `ViewState`.
181 |
182 | ### Build the Dogs screen
183 |
184 | In this tutorial, we’ll start with the layout for the main **Dogs** screen. Here's what we're going to build:
185 |
186 | **Loading screen**, while loading dog breeds:
187 | 
188 |
189 | **Loaded screen**, with loaded dog breeds and a filtering field:
190 | 
191 |
192 | Let's create a `DogsView` itself. The result is supposed to look like this:
193 |
194 | ```swift
195 | import SwiftUI
196 |
197 | struct DogsView: View {
198 | var body: some View {
199 | Text("Hello, World!")
200 | }
201 | }
202 |
203 | struct SwiftUIView_Previews: PreviewProvider {
204 | static var previews: some View {
205 | DogsView()
206 | }
207 | }
208 | ```
209 |
210 | Nothing fancy, just a `SwiftUI` view from a template. However, we're going to use `DogsView` as a namespace for a further journey.
211 |
212 | ### ViewState
213 |
214 | Just a small reminder that unidirectional architectures are state-based with a data-driven model. In a data-driven approach, the life cycle will be triggered whenever a piece of data changes. As opposed to an event-driven model where a life cycle will be triggered whenever an event occurs. So, let's build this "model" `ViewState` for **Dogs** screen.
215 |
216 | ```swift
217 | extension DogsView {
218 | // 1.
219 | struct ViewState: Equatable {
220 | // 2.
221 | let filterText: String
222 | // 3.
223 | let loadingState: LoadingState
224 | }
225 | }
226 |
227 | // MARK: - Loading
228 | // 3.
229 | extension DogsView.ViewState {
230 | enum LoadingState: Equatable {
231 | case loaded(breeds: [String])
232 | case loading
233 |
234 | var breeds: [String] {
235 | guard case .loaded(let breeds) = self else { return [] }
236 | return breeds
237 | }
238 |
239 | var isLoading: Bool { self == .loading }
240 | }
241 | }
242 | ```
243 |
244 | Here’s what happened:
245 |
246 | 1. A `struct` **ViewState** which is going to represent **DogsView** layout.
247 | 2. A `filterText` property speaks for itself.
248 | 3. A `loadingState` state property which is a simple `enum` **LoadingState**. It's pretty important to have our **ViewState** consistent, so it's not possible to end up in a situation where the screen is loading and showing dogs at the same time (if that's not our intention, of course).
249 |
250 | ### ViewAction
251 |
252 | **ViewAction** only represents events from the user or view life cycle. Let's build every needed action or event for **DogsView** as an enumeration:
253 |
254 | ```swift
255 | extension DogsView {
256 | enum ViewAction: Equatable {
257 | case cellWasSelected(breed: String)
258 | case onAppear
259 | case filterTextChanged(String)
260 | }
261 | }
262 | ```
263 | For **DogsView.ViewAction** we basically listed every action the **DogsView** is interested in. You'll see how this is used soon.
264 |
265 | ### Layout
266 |
267 | Now let's build a `SwiftUI` body for the **DogsView** as presented in the GIF earlier:
268 |
269 | ```swift
270 | import ComposableArchitecture
271 | import SwiftUI
272 |
273 | struct DogsView: View {
274 |
275 | // 1.
276 | let store: Store
277 |
278 | var body: some View {
279 | // 2.
280 | WithViewStore(store) { viewStore in
281 | VStack {
282 |
283 | // 3.
284 | if viewStore.loadingState.isLoading {
285 | ProgressView()
286 | } else {
287 | searchBar(for: viewStore)
288 | breedsList(for: viewStore)
289 | }
290 |
291 | }
292 | .navigationBarTitle("Dogs")
293 | .padding()
294 | // 4.
295 | .onAppear { viewStore.send(.onAppear) }
296 | }
297 | }
298 | }
299 | ```
300 |
301 | Here's what happened:
302 |
303 | 1. **DogsView** owns its own **Store** based on **ViewState** and **ViewAction**.
304 | 2. `SwiftUI` `TCA` based implementation uses `WithViewStore` that returns a **View** and has a closure with **ViewStore** type inside. **ViewStore** is basically just a wrapper that allows you to have direct access to **State** and ability to send **Action**s.
305 | 3. Conditional check whether the `ViewState.LoadingState` is `.loading`. If it is, we show a progress view, or else a search bar and a breeds list. What's hidden under `searchBar` and `breedsList` you'll see in a moment.
306 | 4. In this particular scenario, it's interesting to know when the view has appeared via sending `ViewAction.onAppear` to the **Store**. We’ll cover the handling of these events in a bit.
307 |
308 | Now after the main layout was built, let's take a look at the guts of the `searchBar` function.
309 |
310 | ```swift
311 | @ViewBuilder
312 | private func searchBar(for viewStore: ViewStore) -> some View {
313 | HStack {
314 | Image(systemName: "magnifyingglass")
315 | TextField(
316 | "Filter breeds",
317 | // 1.
318 | text: viewStore.binding(
319 | get: \.filterText,
320 | send: ViewAction.filterTextChanged
321 | )
322 | )
323 | .textFieldStyle(RoundedBorderTextFieldStyle())
324 | .autocapitalization(.none)
325 | .disableAutocorrection(true)
326 | }
327 | }
328 | ```
329 |
330 | Here's only one thing that’s interesting. For the `SwiftUI`'s text field you need to use **Binding** (A type that can read and write a value at the same time). However, the nature of unidirectional architectures allows mutating (write) values only via **Reducer**. That's why TCA provides a helper function `binding` on the **ViewStore**, that reads data from **State** and as a function sends data to the **Store** via Reducer with the given action `ViewAction.filterTextChanged`.
331 |
332 | In other words, this:
333 |
334 | ```swift
335 | viewStore.binding(
336 | get: \.filterText,
337 | send: ViewAction.filterTextChanged
338 | )
339 | ```
340 |
341 | Is equivalent to:
342 |
343 | ```swift
344 | Binding(
345 | get: { viewStore.filterText },
346 | set: { viewStore.send(.filterTextChanged($0)) }
347 | )
348 | ```
349 |
350 | Let’s have a look at `breedsList` function real quick:
351 |
352 | ``` swift
353 | @ViewBuilder
354 | private func breedsList(for viewStore: ViewStore) -> some View {
355 | ScrollView {
356 | // 1.
357 | ForEach(viewStore.loadingState.breeds, id: \.self) { breed in
358 | VStack {
359 | // 2.
360 | Button(action: { viewStore.send(.cellWasSelected(breed: breed)) }) {
361 | HStack {
362 | Text(breed)
363 | Spacer()
364 | Image(systemName: "chevron.right")
365 | }
366 | }
367 | Divider()
368 | }
369 | .foregroundColor(.primary)
370 | }
371 | }
372 | .padding()
373 | }
374 | ```
375 |
376 | Here's what happened:
377 |
378 | 1. By using `ForEach` we're going through the `ViewState.breeds` array and drawing cells for every breed.
379 | 2. The cell itself is presented via **Button** and when pressing on we send a `ViewAction.cellWasSelected` action to the **Store**.
380 |
381 | ### Layout Testing
382 |
383 | What’s so great about `SwiftUI`? The previews are (when they work at least 😅). `TCA`'s data driven approach offers super simple ways to test our layout. Here's an example for **DogsView**:
384 |
385 | ```swift
386 | struct DogsView_Previews: PreviewProvider {
387 | static var previews: some View {
388 | NavigationView {
389 | DogsView(
390 | store: Store(
391 | initialState: DogsView.ViewState(
392 | filterText: "",
393 | loadingState: .loaded(
394 | breeds: [
395 | "affenpinscher",
396 | "african",
397 | "airedale",
398 | "akita",
399 | "appenzeller",
400 | "australian",
401 | "basenji",
402 | "beagle",
403 | "bluetick"
404 | ]
405 | )
406 | ),
407 | reducer: .empty,
408 | environment: ()
409 | )
410 | )
411 | }
412 |
413 | NavigationView {
414 | DogsView(
415 | store: Store(
416 | initialState: DogsView.ViewState(
417 | filterText: "",
418 | loadingState: .loading
419 | ),
420 | reducer: .empty,
421 | environment: ()
422 | )
423 | )
424 | }
425 | }
426 | }
427 | ```
428 |
429 | As you can see, by simply stubbing layout data, we can easily test 2 main scenarios (loading and loaded states) for this view. We're going to cover how to use a reducer and an environment in this scenario later on.
430 |
431 | ## Build Dogs business logic
432 |
433 | After we've finished the layout, we’re going to add some business logic. I'm a fan of separating layout and business logic domains. So, let's build business logic for the Dogs module.
434 |
435 | ### State
436 |
437 | Let's describe the data of Dogs module feature that needs to perform its logic:
438 |
439 | ```swift
440 | struct DogsState: Equatable {
441 | var filterQuery: String
442 | var dogs: [Dog]
443 |
444 | static let initial = DogsState(filterQuery: "", dogs: [])
445 | }
446 |
447 | public struct Dog: Equatable {
448 | let breed: String
449 | let subBreeds: [String]
450 | }
451 | ```
452 |
453 | As you can see, **DogsState** is pretty similar to the **DogsView.ViewState** but not quite the same. This separation approach gives us a possibility to completely split the layout from business logic. By using this approach, it's also much easier to reuse the same **State** for several screens at the same time or for different layouts for iPhone and iPad.
454 |
455 | ### Action
456 |
457 | Alright, it’s time to build an action type that represents all of the actions that can happen in the Dogs module:
458 |
459 | ```swift
460 | public enum DogsAction: Equatable {
461 | case breedWasSelected(name: String)
462 | case dogsLoaded([Dog])
463 | case filterQueryChanged(String)
464 | case loadDogs
465 | }
466 | ```
467 |
468 | Because the Dogs module itself is in charge of loading dogs, **DogsAction** has actions related to loading.
469 |
470 | ### Environment
471 |
472 | I already pointed out that we need to load dogs somehow. As you may remember, **Environment** is a type that holds all dependencies.
473 |
474 | ```swift
475 | struct DogsEnvironment {
476 | var loadDogs: () -> Effect<[Dog], Never>
477 | }
478 | ```
479 |
480 | Here you can see **DogsEnvironment**, which holds `loadDogs` dependency. The return type of this dependency is an **Effect**. I can’t phrase it any better than the way it’s stated in the official documentation:
481 |
482 | > The `Effect` type encapsulates a unit of work that can be run in the outside world, and can feed data back to the `Store`. It is the perfect place to do side effects, such as network requests, saving/loading from disk, creating timers, interacting with dependencies, and more. Effects are returned from reducers so that the `Store` can perform the effects after the reducer is done running. It is important to note that `Store` is not thread safe, and so all effects must receive values on the same thread, **and** if the store is being used to drive UI then it must receive values on the main thread. An effect simply wraps a `Publisher` (Combine framework) value and provides some convenience initializers for
483 | constructing some common types of effects.
484 |
485 | ### Reducer
486 |
487 | Let's put everything together in the dogs reducer:
488 |
489 | ```swift
490 | extension DogsState {
491 | // 1.
492 | static let reducer = Reducer { state, action, environment in
493 | switch action {
494 | // 2.
495 | case .breedWasSelected:
496 | return .none
497 | // 3.
498 | case .dogsLoaded(let dogs):
499 | state.dogs = dogs
500 | return .none
501 | // 4.
502 | case .filterQueryChanged(let query):
503 | state.filterQuery = query
504 | return .none
505 | // 5.
506 | case .loadDogs:
507 | return environment
508 | .loadDogs()
509 | .map(DogsAction.dogsLoaded)
510 | }
511 | }
512 | }
513 | ```
514 |
515 | Here's what happened:
516 |
517 | First of all, let's take a look at every return value in this switch. Side effects in `TCA` provided by `Effect` as mentioned earlier. `TCA` framework provides a helper for `Effect` as well - `.none` which basically means that none of the work is going to be performed. This `.none` helper is used for every switch case, but one, which we'll get to right now.
518 |
519 | 1. **Reducer** type in `TCA` is actually a wrapper structure over the function known as `Reducer` in unidirectional architectures. This type provides some helper functions that we'll cover soon.
520 | 2. `DogsAction.breedWasSelected` is not handled at this moment, because we're going to extract navigation from the Dogs module itself.
521 | 3. `DogsAction.dogsLoaded` is an action used for **DogsState** to update with new dogs when they‘re loaded.
522 | 4. `DogsAction.filterQueryChanged` is an action that’s called when the user changes the filter query.
523 | 5. `DogsAction.loadDogs` is an action called for dogs request. There’s only one place for this reducer, which returns a side effect via effect. As you can see, we map returned data from environment and map it back into **DogsAction**. Which is basically a great example for a unidirectional approach. After the side effect performed `DogsAction.dogsLoaded`, action will be called.
524 |
525 | ## Finalize Dogs module
526 |
527 | In the previous steps, we've managed to create layout and business logic setups. Now it's time to put everything together and make it work.
528 |
529 | ### ViewState conversion from State
530 |
531 | First, we need to bring our business logic world into Dogs screen layout. We're going to use [converters](https://en.wikipedia.org/wiki/Adapter_pattern) for it.
532 |
533 | ```swift
534 | extension DogsView.ViewState {
535 | // 1.
536 | static func convert(from state: DogsState) -> Self {
537 | .init(
538 | filterText: state.filterQuery,
539 | loadingState: loadingState(from: state)
540 | )
541 | }
542 |
543 | private static func loadingState(from state: DogsState) -> LoadingState {
544 | if state.dogs.isEmpty { return .loading }
545 |
546 | // 2.
547 | var breeds = state.dogs.map(\.breed.capitalizedFirstLetter)
548 | if !state.filterQuery.isEmpty {
549 | // 3.
550 | breeds = breeds.filter {
551 | $0.lowercased().contains(state.filterQuery.lowercased())
552 | }
553 | }
554 |
555 | return .loaded(breeds: breeds)
556 | }
557 | }
558 | ```
559 |
560 | Here's what happened:
561 |
562 | 1. A simple function which converts **DogsState** into **DogsView.ViewState**.
563 | 2. Here you see the necessity of having separate states for business and layout domains. We don't care about formatting in our business logic, but it's important for the visual part.
564 | 3. Filtering logic is also just part of state conversion. We don't perform any special logic for filtering.
565 |
566 | This approach helps with testing a lot too, since it makes our views as dumb as possible. Every view just renders simple data and sends actions back.
567 |
568 | Here are some tests that should prove it:.
569 |
570 | ```swift
571 | class DogsViewStateConverterTest: XCTestCase {
572 | func testViewStateFilterTextGoesFromStateFilterQuery() {
573 | // Given
574 | let filterQuery = "filter"
575 | // When
576 | let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: filterQuery, dogs: []))
577 | // Then
578 | XCTAssertEqual(viewState.filterText, filterQuery)
579 | }
580 |
581 | func testViewStateLoadingStateIsLoadingIfStateDogsAndFilterQueryIsEmpty() {
582 | // When
583 | let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: "", dogs: []))
584 | // Then
585 | XCTAssertEqual(viewState.loadingState, .loading)
586 | }
587 |
588 | func testViewStateLoadingStateIsLoadedGoesFirstLetterCapitalizedStateDogsBreedWithEmptyFilterQuery() {
589 | // Given
590 | let dogs = [Dog(breed: "breed0", subBreeds: []), Dog(breed: "breed1", subBreeds: [])]
591 | // When
592 | let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: "", dogs: dogs))
593 | // Then
594 | XCTAssertEqual(viewState.loadingState, .loaded(breeds: ["Breed0", "Breed1"]))
595 | }
596 |
597 | func testViewStateLoadingStateIsLoadedGoesFirstLetterCapitalizedStateDogsBreedFilteredContainsByFilterQuery() {
598 | // Given
599 | let breed0 = "Abc0"
600 | let breed1 = "Abc1"
601 | let breed2 = "Def0"
602 | let breed3 = "Def1"
603 | let dogs = [breed0, breed1, breed2, breed3].map { Dog(breed: $0, subBreeds: []) }
604 | // When
605 | let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: "abc", dogs: dogs))
606 | // Then
607 | XCTAssertEqual(viewState.loadingState, .loaded(breeds: [breed0, breed1]))
608 |
609 | }
610 | }
611 | ```
612 |
613 | We're going to use this converter inside an extension of **DogsState** and we'll see how to use it later.
614 |
615 | ```swift
616 | extension DogsState {
617 | var view: DogsView.ViewState {
618 | DogsView.ViewState.convert(from: self)
619 | }
620 | }
621 | ```
622 |
623 | ### Action conversion from ViewState
624 |
625 | Despite state conversions for actions, it's necessary to convert in the other direction.
626 |
627 | ```swift
628 | extension DogsAction {
629 | static func view(_ localAction: DogsView.ViewAction) -> Self {
630 | switch localAction {
631 | case .cellWasSelected(let breed):
632 | return .breedWasSelected(name: breed)
633 | case .onAppear:
634 | return .loadDogs
635 | case .filterTextChanged(let newValue):
636 | return .filterQueryChanged(newValue)
637 | }
638 | }
639 | }
640 | ```
641 |
642 | ### Showtime
643 |
644 | Before we'll show you the final result, we’re just going to add something small to the **DogsEnvironment**:
645 |
646 | ```swift
647 | extension DogsEnvironment {
648 | static let fake = DogsEnvironment(
649 | loadDogs: {
650 | Effect(
651 | value: [
652 | Dog(breed: "affenpinscher", subBreeds: []),
653 | Dog(breed: "bulldog", subBreeds: ["boston", "english", "french"])
654 | ]
655 | )
656 | .delay(for: .seconds(2), scheduler: DispatchQueue.main)
657 | .eraseToEffect()
658 | }
659 | )
660 | }
661 | ```
662 |
663 | I really admire this way of treating dependencies, because with just a simple extension we’re able to test the whole logic without a real network call. If you'd like to learn more, there is a [series](https://www.pointfree.co/collections/dependencies) from pointfree. To mimic some real network call behavior, we’ve added a small delay to see a transition from `loading` to `loaded` states.
664 |
665 | Let's add another **DogsView** preview and see what we’ve got:
666 |
667 | ```swift
668 | NavigationView {
669 | DogsView(
670 | // 1.
671 | store: Store(
672 | initialState: DogsState.initial,
673 | reducer: DogsState.reducer,
674 | environment: DogsEnvironment.fake
675 | )
676 | // 2.
677 | .scope(
678 | state: \.view,
679 | action: DogsAction.view
680 | )
681 | )
682 | }
683 | ```
684 |
685 | Here's what happened:
686 |
687 | 1. The first time we've only been interested in the layout, but here we used a business logic **Store**.
688 | 2. Here is a [scope](https://github.com/pointfreeco/swift-composable-architecture/blob/4ea3dfed61f7e60859b888050233dac4243715e0/Sources/ComposableArchitecture/Store.swift#L163-L178) function to "scope" business domain into layout view one.
689 |
690 | Let's take a look at the result:
691 |
692 | 
693 |
694 | And it works even without any real API on board! Pretty cool right?
695 |
696 | ## Dogs module testing
697 |
698 | We've already had the whole working Dogs module. But what about testing? There’s no need to copy the official readme, so here's a [link](https://github.com/pointfreeco/swift-composable-architecture#testing) on it. In short, the whole `State`/`Action` based approach really helps with testing.
699 |
700 | ### TestStore
701 |
702 | If you're already finished reading how [testing and debugging](https://github.com/pointfreeco/swift-composable-architecture#testing) works in `TCA`, you probably know what **TestStore** is. Let's implement it for Dogs module:
703 |
704 | ```swift
705 | class DogsStoreTests: XCTestCase {
706 | func testStore(initialState: DogsState = .initial)
707 | -> TestStore {
708 | TestStore(
709 | initialState: initialState,
710 | reducer: DogsState.reducer,
711 | environment: .failing
712 | )
713 | }
714 | }
715 | ```
716 |
717 | Nothing fancy, just a helper function that takes the same values as a normal **Store** but provides some testing functionality for us. **TestStore** provides `send` and `receive` functions that mimic real app/user behavior. But wait a second... What's the `environment: .failing` here?
718 |
719 | ```swift
720 | extension DogsEnvironment {
721 | static let failing = DogsEnvironment(
722 | loadDogs: { .failing("DogsEnvironment.loadDogs") }
723 | )
724 | }
725 | ```
726 |
727 | Remember, how cool we've managed to create a `fake` implementation for **DogsEnvironment**? This is the same situation. But in this case we'd like to fail if anything that used `loadDogs` dependency was not expected. Remember what I’ve said in the beginning, that **Effect** is just a **Publisher** with helpers? This `Effect.failing` is one of them.
728 |
729 | Let's build our first test for dogs loading logic:
730 |
731 | ```swift
732 | extension DogsStoreTests {
733 | func testDogsLoad() {
734 |
735 | // 1.
736 | let store = testStore()
737 | let expectedDogs = [Dog(breed: "dog", subBreeds: [])]
738 | // 2.
739 | store.environment.loadDogs = {
740 | Effect(value: expectedDogs)
741 | }
742 |
743 | // 3.
744 | store.send(.loadDogs)
745 | // 4.
746 | store.receive(.dogsLoaded(expectedDogs)) {
747 | // 5.
748 | $0.dogs = expectedDogs
749 | }
750 | }
751 | }
752 | ```
753 |
754 | Here's what happened:
755 |
756 | 1. Just a `store: TestStore` property, which we’ll use for testing.
757 | 2. Mock of `loadDogs` dependency.
758 | 3. Sending an action `DogsAction.loadDogs` for a request of dogs loading.
759 | 4. Expecting to receive dogs response as `DogsAction.dogsLoaded`.
760 | 5. Here's an assertion that **DogsState** mutated as expected.
761 |
762 | If you'd like to see how TCA helps with highlighting test fails, please comment on our 2-5 points of the listed code.
763 |
764 | Here's another filtering behavior that would be nice to test.
765 |
766 | ```swift
767 | extension DogsStoreTests {
768 | func testFilterQueryChanged() {
769 |
770 | let store = testStore()
771 | let query = "buhund"
772 |
773 | store.send(.filterQueryChanged(query)) {
774 | $0.filterQuery = query
775 | }
776 | }
777 | }
778 | ```
779 |
780 | Nothing complicated, just state mutation when an action is sent.
781 |
782 | Basically, that's it for the Dogs module. Congratulations! We still have stuff to do though.
783 |
784 | ## Breeds module
785 |
786 | The Breeds module is going to be similar with regards to architectural principles. That's why it’s a great opportunity to train your new skills.
787 |
788 | Here are requirements for this screen:
789 | - Use `Dogs.breed` as a title for screen.
790 | - Download a random picture for breed, according to this [documentation](https://dog.ceo/dog-api/documentation/breed) and place it as a header.
791 | - Create a list with cells and use `Dogs.subBreeds` as a title for each cell.
792 |
793 | The final result would look like this:
794 |
795 | 
796 |
797 | After you finish or if just want to skip this exercise, check this proposed code:
798 |
799 |
800 | Breeds module is here
801 |
802 |
803 | #### `BreedView.swift`
804 |
805 | ```swift
806 | import ComposableArchitecture
807 | import Kingfisher
808 | import SwiftUI
809 |
810 | struct BreedView: View {
811 |
812 | let store: Store
813 |
814 | var body: some View {
815 | WithViewStore(store) { viewStore in
816 | ScrollView {
817 |
818 | KFImage(viewStore.imageURL)
819 | .header()
820 |
821 | viewStore
822 | .subtitle
823 | .flatMap(Text.init)
824 | .font(.title)
825 |
826 | ForEach(viewStore.subBreeds, id: \.self) { breed in
827 | VStack {
828 | HStack {
829 | Text(breed)
830 | Spacer()
831 | }
832 | Divider()
833 | }
834 | .foregroundColor(.primary)
835 | }
836 | .padding()
837 |
838 | }
839 | .navigationBarTitle(viewStore.title)
840 | .onAppear { viewStore.send(.onAppear) }
841 | }
842 | }
843 | }
844 |
845 | #if DEBUG
846 | struct BreedView_Previews: PreviewProvider {
847 | static var previews: some View {
848 | NavigationView {
849 | BreedView(
850 | store: Store(
851 | initialState: BreedView.ViewState(
852 | title: "hound",
853 | subtitle: "sub-breeds",
854 | subBreeds: [
855 | "afghan",
856 | "basset",
857 | "blood",
858 | "english",
859 | "ibizan",
860 | "plott",
861 | "walker"
862 | ],
863 | imageURL: URL(string: "https://images.dog.ceo/breeds/hound-basset/n02088238_9351.jpg")
864 | ),
865 | reducer: .empty,
866 | environment: ()
867 | )
868 | )
869 | }
870 | }
871 | }
872 | #endif
873 | ```
874 |
875 | #### `BreedView+ViewAction.swift`
876 |
877 | ```swift
878 | extension BreedView {
879 | enum ViewAction: Equatable {
880 | case onAppear
881 | }
882 | }
883 | ```
884 |
885 | #### `BreedView+ViewState.swift`
886 |
887 | ```swift
888 | import Foundation
889 |
890 | extension BreedView {
891 | struct ViewState: Equatable {
892 | let title: String
893 | let subtitle: String?
894 | let subBreeds: [String]
895 | let imageURL: URL?
896 | }
897 | }
898 |
899 | // MARK: - Converter
900 | extension BreedView.ViewState {
901 | static func convert(from state: BreedState) -> Self {
902 | .init(
903 | title: state.dog.breed.capitalizedFirstLetter,
904 | subtitle: state.dog.subBreeds.isEmpty ? nil : "Sub-breeds",
905 | subBreeds: state.dog.subBreeds.map(\.capitalizedFirstLetter),
906 | imageURL: state.imageURL
907 | )
908 | }
909 | }
910 | ```
911 |
912 | #### `BreedState.swift`
913 |
914 | ```swift
915 | import Foundation
916 |
917 | struct BreedState: Equatable {
918 | let dog: Dog
919 | var imageURL: URL?
920 | }
921 |
922 | // MARK: - Scope
923 | extension BreedState {
924 | var view: BreedView.ViewState {
925 | BreedView.ViewState.convert(from: self)
926 | }
927 | }
928 | ```
929 |
930 | #### `BreedAction.swift`
931 |
932 | ```swift
933 | import Foundation
934 |
935 | public enum BreedAction: Equatable {
936 | case breedImageURLReceived(URL?)
937 | case getBreedImageURL
938 | }
939 |
940 | // MARK: - Scope
941 | extension BreedAction {
942 | static func view(_ localAction: BreedView.ViewAction) -> Self {
943 | switch localAction {
944 | case .onAppear:
945 | return .getBreedImageURL
946 | }
947 | }
948 | }
949 | ```
950 |
951 | #### `BreedEnvironment.swift`
952 |
953 | ```swift
954 | import ComposableArchitecture
955 |
956 | struct BreedEnvironment {
957 | var loadDogImage: (_ breed: String) -> Effect
958 | }
959 |
960 | #if DEBUG
961 | extension BreedEnvironment {
962 | static let failing = BreedEnvironment(
963 | loadDogImage: { _ in .failing("DogsEnvironment.loadDogImage") }
964 | )
965 | }
966 |
967 | extension BreedEnvironment {
968 | static let fake = BreedEnvironment(
969 | loadDogImage: { _ in
970 | Effect(value: URL(string: "https://images.dog.ceo/breeds/hound-blood/n02088466_9069.jpg"))
971 | .delay(for: .seconds(2), scheduler: DispatchQueue.main)
972 | .eraseToEffect()
973 | }
974 | )
975 | }
976 | #endif
977 | ```
978 |
979 | #### `BreedState+Reducer.swift`
980 |
981 | ```swift
982 | import ComposableArchitecture
983 |
984 | extension BreedState {
985 | static let reducer = Reducer { state, action, environment in
986 | switch action {
987 | case .breedImageURLReceived(let url):
988 | state.imageURL = url
989 | return .none
990 | case .getBreedImageURL:
991 | return environment
992 | .loadDogImage(state.dog.breed)
993 | .map(BreedAction.breedImageURLReceived)
994 | }
995 | }
996 | }
997 | ```
998 |
999 | #### `BreedViewStateConverterTests.swift`
1000 |
1001 | ```swift
1002 | @testable import DogBreedsComponent
1003 | import XCTest
1004 |
1005 | class BreedViewStateConverterTest: XCTestCase {
1006 |
1007 | func testViewStateTitleGoesFromFirstLetterCapitalizedStatesDogsBreed() {
1008 | // Given
1009 | let dog = Dog(breed: "dog", subBreeds: [])
1010 | // When
1011 | let viewState = BreedView.ViewState.convert(from: BreedState(dog: dog, imageURL: nil))
1012 | // Then
1013 | XCTAssertEqual(viewState.title, "Dog")
1014 | }
1015 |
1016 | func testViewStateSubTitleIsSubBreedsIfStatesSubBreedsArrayIsNotEmpty() {
1017 | // Given
1018 | let dog = Dog(breed: "", subBreeds: ["0"])
1019 | // When
1020 | let viewState = BreedView.ViewState.convert(from: BreedState(dog: dog, imageURL: nil))
1021 | // Then
1022 | XCTAssertEqual(viewState.subtitle, "Sub-breeds")
1023 | }
1024 |
1025 | func testViewStateSubBreedsGoFromFirstLetterCapitalizedStatesSubBreeds() {
1026 | // Given
1027 | let dog = Dog(breed: "", subBreeds: ["abc", "def"])
1028 | // When
1029 | let viewState = BreedView.ViewState.convert(from: BreedState(dog: dog, imageURL: nil))
1030 | // Then
1031 | XCTAssertEqual(viewState.subBreeds, ["Abc", "Def"])
1032 | }
1033 |
1034 | func testViewStateImageURLGoesFromStatesImageURL() {
1035 | // Given
1036 | let url = URL(string: "dog.com")
1037 | // When
1038 | let viewState = BreedView.ViewState.convert(from: BreedState(dog: Dog(breed: "", subBreeds: []), imageURL: url))
1039 | // Then
1040 | XCTAssertEqual(viewState.imageURL, url)
1041 | }
1042 | }
1043 | ```
1044 |
1045 | #### `BreedStoreTests.swift`
1046 |
1047 | ```swift
1048 | @testable import DogBreedsComponent
1049 | import ComposableArchitecture
1050 | import XCTest
1051 |
1052 | class BreedStoreTests: XCTestCase {
1053 | func testStore(initialState: BreedState)
1054 | -> TestStore {
1055 | TestStore(
1056 | initialState: initialState,
1057 | reducer: BreedState.reducer,
1058 | environment: .failing
1059 | )
1060 | }
1061 | }
1062 |
1063 | // MARK: - Image Loading
1064 | extension BreedStoreTests {
1065 | func testBreedImageLoad() {
1066 |
1067 | let breedName = "Breed"
1068 | let initialState = BreedState(dog: Dog(breed: breedName, subBreeds: []), imageURL: nil)
1069 | let store = testStore(initialState: initialState)
1070 |
1071 | let expectedURL = URL(string: "breed.com")
1072 |
1073 | store.environment.loadDogImage = {
1074 | XCTAssertEqual($0, breedName)
1075 | return Effect(value: expectedURL)
1076 | }
1077 |
1078 | store.send(.getBreedImageURL)
1079 | store.receive(.breedImageURLReceived(expectedURL)) {
1080 | $0.imageURL = expectedURL
1081 | }
1082 | }
1083 | }
1084 | ```
1085 |
1086 |
1087 | ## App module
1088 |
1089 | So far we've accomplished 2 modules: Dogs and Breeds. However, they are completely independent from each other. Also: there's no way to navigate from Dogs to Breeds. Let's try to solve it with modularization techniques in `TCA`. If you’d like to know more details, watch this [pointfree collection](https://www.pointfree.co/collections/composable-architecture/modularity) about modularity.
1090 |
1091 | Navigation is still a topic under discussion and there's no clear approach for it yet. I’m going to show you my vision on it. Well, one of my visions actually.
1092 | For the App module there is no need to create a separate **ViewState** and **State** because the App module is going to serve only as a mediator with navigation over Dogs and Breeds modules.
1093 |
1094 | ### AppState
1095 |
1096 | First of all, it’s good to remember that `TCA` is a part of a state-based unidirectional architectures family. In these architectures, there is usually just **ONE** state for the whole application. However, `TCA` framework provides some helpers for state composition. In other words, we can create several independent modules with independent states, which connect inside a single bigger state.
1097 |
1098 | Let's start with the heart of the module **AppState**:
1099 |
1100 | ```swift
1101 | public struct AppState: Equatable {
1102 | // 1.
1103 | var dogs: [Dog]
1104 | // 2.
1105 | var dogsInternal = DogsInternalState()
1106 | // 3.
1107 | var breedState: BreedState?
1108 |
1109 | public static let initial = AppState(dogs: [], breedState: nil)
1110 | }
1111 | ```
1112 |
1113 |
1114 | Here's what happened:
1115 |
1116 | 1. In this example, a `dogs` property is a shared property within the whole application. For other scenarios, it could be a **User** property, which you'll need access to throughout the app.
1117 | 2. **DogsInternalState** we're going to cover the next step. You could treat it as a Dog module state inside the **AppState**. It's not optional, because the Dogs module basically exists within the whole app life cycle.
1118 | 3. **BreedState?** is an optional state for the Breeds module. This state is optional because we use it for our detailed screen aka Breeds module.
1119 |
1120 | But what's this **DogsInternalState** thing? Let's take a look:
1121 |
1122 | ```swift
1123 | // 1.
1124 | struct DogsInternalState: Equatable {
1125 | var filterQuery: String
1126 |
1127 | init() {
1128 | self.filterQuery = ""
1129 | }
1130 |
1131 | init(state: DogsState) {
1132 | self.filterQuery = state.filterQuery
1133 | }
1134 | }
1135 |
1136 | // 2.
1137 | extension DogsState {
1138 | init(
1139 | internalState: DogsInternalState,
1140 | dogs: [Dog]
1141 | ) {
1142 |
1143 | self.init(
1144 | filterQuery: internalState.filterQuery,
1145 | dogs: dogs
1146 | )
1147 | }
1148 | }
1149 |
1150 | // 3.
1151 | extension AppState {
1152 | var dogsState: DogsState {
1153 | get {
1154 | DogsState(internalState: dogsInternal, dogs: dogs)
1155 | }
1156 | set {
1157 | dogsInternal = .init(state: newValue)
1158 | dogs = newValue.dogs
1159 | }
1160 | }
1161 | }
1162 | ```
1163 |
1164 | Here's what happened:
1165 |
1166 | 1. You may remember **DogsState** contains 2 properties: `filterQuery` and `dogs`. However, `dogs` is shared within the whole application. So, **DogsInternalState** is a helper entity, which consists of every property inside **DogsState** that are not shared with an upper module **AppState**. **DogsInternalState** has 2 initializers: one as an initial for **AppState** and a second one as a helper for initialization from **DogsState**.
1167 | 2. This extension is just a convenience initializer from **DogsInternalState**, which contains any not shared property and the shared property itself.
1168 | 3. A computed property `dogsState` which actually is a **DogsState** part of **AppState**.
1169 |
1170 | ### AppAction
1171 |
1172 | ```swift
1173 | public enum AppAction: Equatable {
1174 | case breed(BreedAction)
1175 | case breedsDisappeared
1176 | case dogs(DogsAction)
1177 | }
1178 | ```
1179 |
1180 | **AppAction** as **AppState** is a composition of **BreedAction** and **DogsAction**. However, there's one extra action **AppAction.breedsDisappeared** which speaks for itself.
1181 |
1182 | ### AppReducer
1183 |
1184 | Let's now try to build a reducer for App module:
1185 |
1186 | ```swift
1187 | extension AppState {
1188 | static let reducerCore = Reducer { state, action, _ in
1189 | switch action {
1190 | // 1.
1191 | case .breed:
1192 | return .none
1193 | // 2.
1194 | case .breedsDisappeared:
1195 | state.breedState = nil
1196 | return .none
1197 | // 3.
1198 | case .dogs(.breedWasSelected(let breed)):
1199 | guard let dog = state.dogs.first(where: { $0.breed.lowercased() == breed.lowercased() }) else { fatalError() }
1200 | state.breedState = BreedState(dog: dog, imageURL: nil)
1201 | return .none
1202 | // 4.
1203 | case .dogs:
1204 | return .none
1205 | }
1206 | }
1207 | }
1208 | ```
1209 |
1210 | Here's what happened:
1211 |
1212 | 1. Breed module actions inside **AppReducer**, in which we have no interest whatsoever.
1213 | 2. `AppAction.breedsDisappeared` an action where we need to clear **BreedState** after a screen disappeared.
1214 | 3. `DogsAction.breedWasSelected` was abandoned inside **DogsReducer**. Now it's time to handle this action. We simply create a new **BreedState** that serves as a state for the Breed module. We'll show you how this state change will reflect navigation in the next steps.
1215 | 4. Because we are interested in just one action from the Dogs module, all other actions can be safely ignored.
1216 |
1217 | But wait! If the **AppReducer** ignores almost every action from Dogs and Breed modules, how ‘s this thing supposed to work at all? 🤔 The answer is simple. We've mentioned that **Reducer** is basically a wrapper over a reducer function + some handy helpers, right?
1218 |
1219 | Before the big reveal, let's implement prod or live versions for our environments.
1220 |
1221 | ```swift
1222 | extension DogsEnvironment {
1223 |
1224 | private struct DogsResponse: Codable {
1225 | let message: [String: [String]]
1226 | }
1227 |
1228 | static let live = DogsEnvironment(
1229 | loadDogs: {
1230 | URLSession
1231 | .shared
1232 | .dataTaskPublisher(for: URL(string: "https://dog.ceo/api/breeds/list/all")!)
1233 | .map(\.data)
1234 | .decode(type: DogsResponse.self, decoder: JSONDecoder())
1235 | .map { response in
1236 | response
1237 | .message
1238 | .map(Dog.init)
1239 | .sorted { $0.breed < $1.breed }
1240 | }
1241 | .replaceError(with: [])
1242 | .receive(on: DispatchQueue.main)
1243 | .eraseToEffect()
1244 | }
1245 | )
1246 | }
1247 | ```
1248 |
1249 | ```swift
1250 | extension BreedEnvironment {
1251 |
1252 | private struct BreedImageResponse: Codable {
1253 | let message: String?
1254 | }
1255 |
1256 | static let live = BreedEnvironment(
1257 | loadDogImage: { breed in
1258 | URLSession
1259 | .shared
1260 | .dataTaskPublisher(for: URL(string: "https://dog.ceo/api/breed/\(breed)/images/random")!)
1261 | .map(\.data)
1262 | .decode(type: BreedImageResponse.self, decoder: JSONDecoder())
1263 | .compactMap(\.message)
1264 | .map(URL.init(string:))
1265 | .replaceError(with: nil)
1266 | .receive(on: DispatchQueue.main)
1267 | .eraseToEffect()
1268 | }
1269 | )
1270 | }
1271 | ```
1272 |
1273 | Nothing really interesting here, just simple backing **URLSession** calls into needed dependencies. But have you already noticed we have 3 different implementations (failing, fake, and live) of environments without using protocols and such?
1274 |
1275 | Now let's go back to the reducers:
1276 |
1277 | ```swift
1278 | public extension AppState {
1279 | static let reducer = Reducer
1280 | // 1.
1281 | .combine(
1282 | // 2.
1283 | AppState.reducerCore,
1284 | // 3.
1285 | DogsState
1286 | .reducer
1287 | // 4.
1288 | .pullback(
1289 | // 5.
1290 | state: \.dogsState,
1291 | // 6.
1292 | action: /AppAction.dogs,
1293 | // 7.
1294 | environment: { _ in DogsEnvironment.live }
1295 | ),
1296 | // 8.
1297 | BreedState
1298 | .reducer
1299 | // 9.
1300 | .optional()
1301 | .pullback(
1302 | state: \.breedState,
1303 | action: /AppAction.breed,
1304 | environment: { _ in BreedEnvironment.live }
1305 | )
1306 | )
1307 | }
1308 | ```
1309 |
1310 | Here's what happened:
1311 |
1312 | 1. Combining method helps to "combine" different reducers into one big reducer. So this combined reducer is going to work with every performed action from App, Dogs, and Breed modules as we'd like it to work.
1313 | 2. As the first reducer here we use an **AppReducer** itself.
1314 | 3. **DogsReducer** that we combined with a big **AppReducer** .That’s possible thanks to the **AppState** and **AppAction** structure.
1315 | 4. **DogsReducer** is pulled back into **AppReducer** with the `pullback` function help. And as a result of this function we have **AppReducer**.
1316 | 5. Via the usage of Swift **KeyPath**, we can say that **DogsReducer** is only going to work with the `dogsState` part of the **AppState**.
1317 | 6. Via the usage of pointfree **CasePath** (if you'd like to know more, check out this [repository](https://github.com/pointfreeco/swift-case-paths). In short, it's a way to use enums in the same fashion as KeyPaths). We can say that **DogsReducer** is only going to work with the `AppAction.dogs` part of **AppAction**.
1318 | 7. Here, we've just created a **DogsEnvironment**. If our app is more complicated and has a lot of dependencies, we'd use a closure parameter **AppEnviroment**. But in this case it's just a **Void**, so we don't need it.
1319 | 8. **BreedReducer** that’s also combined into the big **AppReducer**
1320 | 9. **BreedState** is optional inside **AppState**, so we use another **Reducer** helper `optional`.
1321 |
1322 | ### RootView
1323 |
1324 | Basically, we've built the whole necessary logic, but haven't made a real navigation yet. The final **RootView** of the app:
1325 |
1326 | ```swift
1327 | public struct RootView: View {
1328 |
1329 | let store: Store
1330 | let dogsView: DogsView
1331 |
1332 | public init(store: Store) {
1333 | self.store = store
1334 | // 1.
1335 | dogsView = DogsView(
1336 | store: store.scope(
1337 | state: \.dogsState.view,
1338 | action: { local -> AppAction in
1339 | AppAction.dogs(
1340 | DogsAction.view(local)
1341 | )
1342 | }
1343 | )
1344 | )
1345 | }
1346 |
1347 | public var body: some View {
1348 | // 2.
1349 | WithViewStore(store.scope(state: \.breedState)) { viewStore in
1350 | HStack {
1351 | // 3.
1352 | dogsView
1353 | NavigationLink(
1354 | destination: breedView,
1355 | isActive: viewStore.binding(
1356 | get: { $0 != nil },
1357 | send: .breedsDisappeared
1358 | ),
1359 | label: EmptyView.init
1360 | )
1361 | }
1362 | }
1363 | }
1364 |
1365 | // 4.
1366 | var breedView: some View {
1367 | IfLetStore(
1368 | store.scope(
1369 | state: \.breedState?.view,
1370 | action: { local -> AppAction in
1371 | AppAction.breed(
1372 | BreedAction.view(local)
1373 | )
1374 | }
1375 | ),
1376 | then: BreedView.init(store:)
1377 | )
1378 | }
1379 | }
1380 | ```
1381 |
1382 | Here's what happened:
1383 |
1384 | 1. **DogsView** was created as an immutable variable because it exists in the whole app life cycle and basically the main view of the app.
1385 | 2. In the **RootView** we're just interested in the `breedState` part of the **AppState** only for navigation purposes, which means that closure will be called only on `breedState` changes.
1386 | 3. The `body` of the view contains **DogsView** and invisible **NavigationLink** which is backed by **ViewStore** **Binding** with a destination of **BreedView**.
1387 | 4. A property for **BreedView** creation. If you take a look inside, you’ll see the **IfLetStore** entity, which helps to create views, backed by store, only when it's possible.
1388 |
1389 | That's basically it. For the last step, let's put everything together inside the **RootView** preview.
1390 |
1391 | ```swift
1392 | struct RootView_Previews: PreviewProvider {
1393 | static var previews: some View {
1394 | NavigationView {
1395 | RootView(
1396 | store: Store(
1397 | initialState: .initial,
1398 | reducer: AppState.combinedReducer,
1399 | environment: ()
1400 | )
1401 | )
1402 | }
1403 | }
1404 | }
1405 | ```
1406 |
1407 | You should see a working app with a real live API.
1408 |
1409 | 
1410 |
1411 | ### App tests
1412 |
1413 | Last but not least. Here’s the one test for App module:
1414 |
1415 |
1416 | AppStoreTests.swift
1417 |
1418 |
1419 | ```swift
1420 | @testable import DogBreedsComponent
1421 | import ComposableArchitecture
1422 | import XCTest
1423 |
1424 | class AppStoreTests: XCTestCase {
1425 | func testStore(initialState: AppState)
1426 | -> TestStore {
1427 | TestStore(
1428 | initialState: initialState,
1429 | reducer: AppState.reducer,
1430 | environment: ()
1431 | )
1432 | }
1433 | }
1434 |
1435 | // MARK: - Navigation
1436 | extension AppStoreTests {
1437 | func testNavigation() {
1438 |
1439 | let breedName = "Hound"
1440 | let dog = Dog(breed: breedName, subBreeds: ["subreed1", "subbreed2"])
1441 |
1442 | let initialState = AppState(
1443 | dogs: [Dog(breed: "anotherDog0", subBreeds: []), dog, Dog(breed: "anotherDog1", subBreeds: [])]
1444 | )
1445 | let store = testStore(initialState: initialState)
1446 |
1447 | store.send(.dogs(.breedWasSelected(name: breedName))) {
1448 | $0.breedState = BreedState(dog: dog, imageURL: nil)
1449 | }
1450 |
1451 | store.send(.breedsDisappeared) {
1452 | $0.breedState = nil
1453 | }
1454 | }
1455 | }
1456 | ```
1457 |
1458 |
1459 | That's all folks! You've just built you're first TCA based application. Congratulations! 🥳
1460 |
1461 | ## Where to go next
1462 |
1463 | - [The framework itself](https://github.com/pointfreeco/swift-composable-architecture)
1464 | - [PointFree TCA video collection](https://www.pointfree.co/collections/composable-architecture)
1465 | - [Series of articles about reactive programming and unidirectional architectures](https://medium.com/atimca/what-is-reactive-programming-43e60cc4c0f?source=friends_link&sk=4ab8aa82f6e669bad59be42cba67e0ef) Learn more about the paradigm behind it and how to build your own unidirectional architecture framework.
1466 | - [A video of another unidirectional architecture RxFeedback](https://academy.realm.io/posts/try-swift-nyc-2017-krunoslav-zaher-modern-rxswift-architectures/) A solid introduction to the concepts.
1467 | - [redux.js.org](https://redux.js.org) Thorough documentation about `Redux` framework for js. Don't need to go into the details, but this website contains a good explanation of the concept.
1468 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/App/Models/Dog.swift:
--------------------------------------------------------------------------------
1 | public struct Dog: Equatable {
2 | let breed: String
3 | let subBreeds: [String]
4 | }
5 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/App/Store/AppAction.swift:
--------------------------------------------------------------------------------
1 | public enum AppAction: Equatable {
2 | case breed(BreedAction)
3 | case breedsDisappeared
4 | case dogs(DogsAction)
5 | }
6 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/App/Store/AppState+Reducer.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 |
3 | extension AppState {
4 | static let reducerCore = Reducer { state, action, _ in
5 | switch action {
6 | case .breed:
7 | return .none
8 | case .breedsDisappeared:
9 | state.breedState = nil
10 | return .none
11 | case .dogs(.breedWasSelected(let breed)):
12 | guard let dog = state.dogs.first(where: { $0.breed.lowercased() == breed.lowercased() }) else { fatalError() }
13 | state.breedState = BreedState(dog: dog, imageURL: nil)
14 | return .none
15 | case .dogs:
16 | return .none
17 | }
18 | }
19 | }
20 |
21 | public extension AppState {
22 | static let reducer = Reducer
23 | .combine(
24 | AppState.reducerCore,
25 | DogsState
26 | .reducer
27 | .pullback(
28 | state: \.dogsState,
29 | action: /AppAction.dogs,
30 | environment: { _ in DogsEnvironment.live }
31 | ),
32 | BreedState
33 | .reducer
34 | .optional()
35 | .pullback(
36 | state: \.breedState,
37 | action: /AppAction.breed,
38 | environment: { _ in BreedEnvironment.live }
39 | )
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/App/Store/AppState.swift:
--------------------------------------------------------------------------------
1 | public struct AppState: Equatable {
2 | var dogs: [Dog]
3 | var dogsInternal = DogsInternalState()
4 | var breedState: BreedState?
5 |
6 | public static let initial = AppState(dogs: [], breedState: nil)
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/App/Store/SharedStates/DogsSharedState.swift:
--------------------------------------------------------------------------------
1 | extension AppState {
2 | var dogsState: DogsState {
3 | get {
4 | DogsState(internalState: dogsInternal, dogs: dogs)
5 | }
6 | set {
7 | dogsInternal = .init(state: newValue)
8 | dogs = newValue.dogs
9 | }
10 | }
11 | }
12 |
13 | extension DogsState {
14 | init(
15 | internalState: DogsInternalState,
16 | dogs: [Dog]
17 | ) {
18 |
19 | self.init(
20 | filterQuery: internalState.filterQuery,
21 | dogs: dogs
22 | )
23 | }
24 | }
25 |
26 | struct DogsInternalState: Equatable {
27 | var filterQuery: String
28 |
29 | init() {
30 | self.filterQuery = ""
31 | }
32 |
33 | init(state: DogsState) {
34 | self.filterQuery = state.filterQuery
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/App/Views/RootView.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 |
4 | public struct RootView: View {
5 |
6 | let store: Store
7 | let dogsView: DogsView
8 |
9 | public init(store: Store) {
10 | self.store = store
11 | dogsView = DogsView(
12 | store: store.scope(
13 | state: \.dogsState.view,
14 | action: { local -> AppAction in
15 | AppAction.dogs(
16 | DogsAction.view(local)
17 | )
18 | }
19 | )
20 | )
21 | }
22 |
23 | public var body: some View {
24 | WithViewStore(store.scope(state: \.breedState)) { viewStore in
25 | HStack {
26 | dogsView
27 | NavigationLink(
28 | destination: breedView,
29 | isActive: viewStore.binding(
30 | get: { $0 != nil },
31 | send: .breedsDisappeared
32 | ),
33 | label: EmptyView.init
34 | )
35 | }
36 | }
37 | }
38 |
39 | var breedView: some View {
40 | IfLetStore(
41 | store.scope(
42 | state: \.breedState?.view,
43 | action: { local -> AppAction in
44 | AppAction.breed(
45 | BreedAction.view(local)
46 | )
47 | }
48 | ),
49 | then: BreedView.init(store:)
50 | )
51 | }
52 | }
53 |
54 | #if DEBUG
55 | struct RootView_Previews: PreviewProvider {
56 | static var previews: some View {
57 | NavigationView {
58 | RootView(
59 | store: Store(
60 | initialState: .initial,
61 | reducer: AppState.reducer,
62 | environment: ()
63 | )
64 | )
65 | }
66 | }
67 | }
68 | #endif
69 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Breed/Store/BreedAction.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public enum BreedAction: Equatable {
4 | case breedImageURLReceived(URL?)
5 | case getBreedImageURL
6 | }
7 |
8 | // MARK: - Scope
9 | extension BreedAction {
10 | static func view(_ localAction: BreedView.ViewAction) -> Self {
11 | switch localAction {
12 | case .onAppear:
13 | return .getBreedImageURL
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Breed/Store/BreedEnvironment.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 |
3 | struct BreedEnvironment {
4 | var loadDogImage: (_ breed: String) -> Effect
5 | }
6 |
7 | #if DEBUG
8 | extension BreedEnvironment {
9 | static let failing = BreedEnvironment(
10 | loadDogImage: { _ in .failing("DogsEnvironment.loadDogImage") }
11 | )
12 | }
13 |
14 | extension BreedEnvironment {
15 | static let fake = BreedEnvironment(
16 | loadDogImage: { _ in
17 | Effect(value: URL(string: "https://images.dog.ceo/breeds/hound-blood/n02088466_9069.jpg"))
18 | .delay(for: .seconds(2), scheduler: DispatchQueue.main)
19 | .eraseToEffect()
20 | }
21 | )
22 | }
23 | #endif
24 |
25 | extension BreedEnvironment {
26 |
27 | private struct BreedImageResponse: Codable {
28 | let message: String?
29 | }
30 |
31 | static let live = BreedEnvironment(
32 | loadDogImage: { breed in
33 | URLSession
34 | .shared
35 | .dataTaskPublisher(for: URL(string: "https://dog.ceo/api/breed/\(breed)/images/random")!)
36 | .map(\.data)
37 | .decode(type: BreedImageResponse.self, decoder: JSONDecoder())
38 | .compactMap(\.message)
39 | .map(URL.init(string:))
40 | .replaceError(with: nil)
41 | .receive(on: DispatchQueue.main)
42 | .eraseToEffect()
43 | }
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Breed/Store/BreedState+Reducer.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 |
3 | extension BreedState {
4 | static let reducer = Reducer { state, action, environment in
5 | switch action {
6 | case .breedImageURLReceived(let url):
7 | state.imageURL = url
8 | return .none
9 | case .getBreedImageURL:
10 | return environment
11 | .loadDogImage(state.dog.breed)
12 | .map(BreedAction.breedImageURLReceived)
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Breed/Store/BreedState.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct BreedState: Equatable {
4 | let dog: Dog
5 | var imageURL: URL?
6 | }
7 |
8 | // MARK: - Scope
9 | extension BreedState {
10 | var view: BreedView.ViewState {
11 | BreedView.ViewState.convert(from: self)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Breed/Views/BreedView+ViewAction.swift:
--------------------------------------------------------------------------------
1 | extension BreedView {
2 | enum ViewAction: Equatable {
3 | case onAppear
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Breed/Views/BreedView+ViewState.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension BreedView {
4 | struct ViewState: Equatable {
5 | let title: String
6 | let subtitle: String?
7 | let subBreeds: [String]
8 | let imageURL: URL?
9 | }
10 | }
11 |
12 | // MARK: - Converter
13 | extension BreedView.ViewState {
14 | static func convert(from state: BreedState) -> Self {
15 | .init(
16 | title: state.dog.breed.capitalizedFirstLetter,
17 | subtitle: state.dog.subBreeds.isEmpty ? nil : "Sub-breeds",
18 | subBreeds: state.dog.subBreeds.map(\.capitalizedFirstLetter),
19 | imageURL: state.imageURL
20 | )
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Breed/Views/BreedView.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import Kingfisher
3 | import SwiftUI
4 |
5 | struct BreedView: View {
6 |
7 | let store: Store
8 |
9 | var body: some View {
10 | WithViewStore(store) { viewStore in
11 | ScrollView {
12 |
13 | KFImage(viewStore.imageURL)
14 | .header()
15 |
16 | viewStore
17 | .subtitle
18 | .flatMap(Text.init)
19 | .font(.title)
20 |
21 | ForEach(viewStore.subBreeds, id: \.self) { breed in
22 | VStack {
23 | HStack {
24 | Text(breed)
25 | Spacer()
26 | }
27 | Divider()
28 | }
29 | .foregroundColor(.primary)
30 | }
31 | .padding()
32 |
33 | }
34 | .navigationBarTitle(viewStore.title)
35 | .onAppear { viewStore.send(.onAppear) }
36 | }
37 | }
38 | }
39 |
40 | #if DEBUG
41 | struct BreedView_Previews: PreviewProvider {
42 | static var previews: some View {
43 | NavigationView {
44 | BreedView(
45 | store: Store(
46 | initialState: BreedView.ViewState(
47 | title: "Hound",
48 | subtitle: "sub-breeds",
49 | subBreeds: [
50 | "Afghan",
51 | "Basset",
52 | "Blood",
53 | "English",
54 | "Jbizan",
55 | "Plott",
56 | "Walker"
57 | ],
58 | imageURL: URL(string: "https://images.dog.ceo/breeds/hound-basset/n02088238_9351.jpg")
59 | ),
60 | reducer: .empty,
61 | environment: ()
62 | )
63 | )
64 | }
65 | }
66 | }
67 | #endif
68 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Dogs/Store/DogsAction.swift:
--------------------------------------------------------------------------------
1 | public enum DogsAction: Equatable {
2 | case breedWasSelected(name: String)
3 | case dogsLoaded([Dog])
4 | case filterQueryChanged(String)
5 | case loadDogs
6 | }
7 |
8 | // MARK: - Scope
9 | extension DogsAction {
10 | static func view(_ localAction: DogsView.ViewAction) -> Self {
11 | switch localAction {
12 | case .cellWasSelected(let breed):
13 | return .breedWasSelected(name: breed)
14 | case .onAppear:
15 | return .loadDogs
16 | case .filterTextChanged(let newValue):
17 | return .filterQueryChanged(newValue)
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Dogs/Store/DogsEnvironment.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 |
3 | struct DogsEnvironment {
4 | var loadDogs: () -> Effect<[Dog], Never>
5 | }
6 |
7 | #if DEBUG
8 | extension DogsEnvironment {
9 | static let failing = DogsEnvironment(
10 | loadDogs: { .failing("DogsEnvironment.loadDogs") }
11 | )
12 | }
13 |
14 | extension DogsEnvironment {
15 | static let fake = DogsEnvironment(
16 | loadDogs: {
17 | Effect(
18 | value: [
19 | Dog(breed: "affenpinscher", subBreeds: []),
20 | Dog(breed: "bulldog", subBreeds: ["boston", "english", "french"])
21 | ]
22 | )
23 | .delay(for: .seconds(2), scheduler: DispatchQueue.main)
24 | .eraseToEffect()
25 | }
26 | )
27 | }
28 | #endif
29 |
30 | extension DogsEnvironment {
31 |
32 | private struct DogsResponse: Codable {
33 | let message: [String: [String]]
34 | }
35 |
36 | static let live = DogsEnvironment(
37 | loadDogs: {
38 | URLSession
39 | .shared
40 | .dataTaskPublisher(for: URL(string: "https://dog.ceo/api/breeds/list/all")!)
41 | .map(\.data)
42 | .decode(type: DogsResponse.self, decoder: JSONDecoder())
43 | .map { response in
44 | response
45 | .message
46 | .map(Dog.init)
47 | .sorted { $0.breed < $1.breed }
48 | }
49 | .replaceError(with: [])
50 | .receive(on: DispatchQueue.main)
51 | .eraseToEffect()
52 | }
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Dogs/Store/DogsState+Reducer.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 |
3 | extension DogsState {
4 | static let reducer = Reducer { state, action, environment in
5 | switch action {
6 | case .breedWasSelected:
7 | return .none
8 | case .dogsLoaded(let dogs):
9 | state.dogs = dogs
10 | return .none
11 | case .filterQueryChanged(let query):
12 | state.filterQuery = query
13 | return .none
14 | case .loadDogs:
15 | return environment
16 | .loadDogs()
17 | .map(DogsAction.dogsLoaded)
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Dogs/Store/DogsState.swift:
--------------------------------------------------------------------------------
1 | struct DogsState: Equatable {
2 | var filterQuery: String
3 | var dogs: [Dog]
4 |
5 | static let initial = DogsState(filterQuery: "", dogs: [])
6 | }
7 |
8 | // MARK: - Scope
9 | extension DogsState {
10 | var view: DogsView.ViewState {
11 | DogsView.ViewState.convert(from: self)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Dogs/Views/DogsView+ViewAction.swift:
--------------------------------------------------------------------------------
1 | extension DogsView {
2 | enum ViewAction: Equatable {
3 | case cellWasSelected(breed: String)
4 | case onAppear
5 | case filterTextChanged(String)
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Dogs/Views/DogsView+ViewState.swift:
--------------------------------------------------------------------------------
1 | extension DogsView {
2 | struct ViewState: Equatable {
3 | let filterText: String
4 | let loadingState: LoadingState
5 | }
6 | }
7 |
8 | // MARK: - Loading
9 | extension DogsView.ViewState {
10 | enum LoadingState: Equatable {
11 | case loaded(breeds: [String])
12 | case loading
13 |
14 | var breeds: [String] {
15 | guard case .loaded(let breeds) = self else { return [] }
16 | return breeds
17 | }
18 |
19 | var isLoading: Bool { self == .loading }
20 | }
21 | }
22 |
23 | // MARK: - Converter
24 | extension DogsView.ViewState {
25 | static func convert(from state: DogsState) -> Self {
26 | .init(
27 | filterText: state.filterQuery,
28 | loadingState: loadingState(from: state)
29 | )
30 | }
31 |
32 | private static func loadingState(from state: DogsState) -> LoadingState {
33 | if state.dogs.isEmpty { return .loading }
34 |
35 | var breeds = state.dogs.map(\.breed.capitalizedFirstLetter)
36 | if !state.filterQuery.isEmpty {
37 | breeds = breeds.filter {
38 | $0.lowercased().contains(state.filterQuery.lowercased())
39 | }
40 | }
41 |
42 | return .loaded(breeds: breeds)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Dogs/Views/DogsView.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 | import SwiftUI
3 |
4 | struct DogsView: View {
5 |
6 | let store: Store
7 |
8 | var body: some View {
9 | WithViewStore(store) { viewStore in
10 | VStack {
11 |
12 | if viewStore.loadingState.isLoading {
13 | ProgressView()
14 | } else {
15 | searchBar(for: viewStore)
16 | breedsList(for: viewStore)
17 | }
18 |
19 | }
20 | .navigationBarTitle("Dogs")
21 | .onAppear { viewStore.send(.onAppear) }
22 | }
23 | }
24 |
25 | @ViewBuilder
26 | private func searchBar(for viewStore: ViewStore) -> some View {
27 | HStack {
28 | Image(systemName: "magnifyingglass")
29 | TextField(
30 | "Filter breeds",
31 | text: viewStore.binding(
32 | get: \.filterText,
33 | send: ViewAction.filterTextChanged
34 | )
35 | )
36 | .textFieldStyle(RoundedBorderTextFieldStyle())
37 | .autocapitalization(.none)
38 | .disableAutocorrection(true)
39 | }
40 | .padding(.horizontal)
41 | }
42 |
43 | @ViewBuilder
44 | private func breedsList(for viewStore: ViewStore) -> some View {
45 | ScrollView {
46 | ForEach(viewStore.loadingState.breeds, id: \.self) { breed in
47 | VStack {
48 | Button(action: { viewStore.send(.cellWasSelected(breed: breed)) }) {
49 | HStack {
50 | Text(breed)
51 | Spacer()
52 | Image(systemName: "chevron.right")
53 | }
54 | }
55 | Divider()
56 | }
57 | .foregroundColor(.primary)
58 | }
59 | .padding()
60 | }
61 | }
62 | }
63 |
64 | #if DEBUG
65 | struct DogsView_Previews: PreviewProvider {
66 | static var previews: some View {
67 | NavigationView {
68 | DogsView(
69 | store: Store(
70 | initialState: DogsView.ViewState(
71 | filterText: "",
72 | loadingState: .loaded(
73 | breeds: [
74 | "affenpinscher",
75 | "african",
76 | "airedale",
77 | "akita",
78 | "appenzeller",
79 | "australian",
80 | "basenji",
81 | "beagle",
82 | "bluetick"
83 | ]
84 | )
85 | ),
86 | reducer: .empty,
87 | environment: ()
88 | )
89 | )
90 | }
91 |
92 | NavigationView {
93 | DogsView(
94 | store: Store(
95 | initialState: DogsView.ViewState(
96 | filterText: "",
97 | loadingState: .loading
98 | ),
99 | reducer: .empty,
100 | environment: ()
101 | )
102 | )
103 | }
104 |
105 | NavigationView {
106 | DogsView(
107 | store: Store(
108 | initialState: DogsState.initial,
109 | reducer: DogsState.reducer,
110 | environment: DogsEnvironment.fake
111 | )
112 | .scope(
113 | state: \.view,
114 | action: DogsAction.view
115 | )
116 | )
117 | }
118 | }
119 | }
120 | #endif
121 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Utils/KFImage+Header.swift:
--------------------------------------------------------------------------------
1 | import Kingfisher
2 | import SwiftUI
3 |
4 | extension KFImage {
5 | func header() -> some View {
6 | GeometryReader { geometry in
7 | resizable()
8 | .placeholder {
9 | ProgressView()
10 | .frame(height: 240)
11 | }
12 | .aspectRatio(contentMode: .fill)
13 | .frame(width: geometry.size.width, height: 240)
14 | .clipped()
15 | }
16 | .frame(height: 240)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/DogBreedsComponent/Utils/String+Capitalized.swift:
--------------------------------------------------------------------------------
1 | extension String {
2 | var capitalizedFirstLetter: String {
3 | prefix(1).capitalized + dropFirst()
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/Tests/DogBreedsComponentTests/App/Store/AppStoreTests.swift:
--------------------------------------------------------------------------------
1 | @testable import DogBreedsComponent
2 | import ComposableArchitecture
3 | import XCTest
4 |
5 | class AppStoreTests: XCTestCase {
6 | func testStore(initialState: AppState)
7 | -> TestStore {
8 | TestStore(
9 | initialState: initialState,
10 | reducer: AppState.reducerCore,
11 | environment: ()
12 | )
13 | }
14 | }
15 |
16 | // MARK: - Navigation
17 | extension AppStoreTests {
18 | func testNavigation() {
19 |
20 | let breedName = "Hound"
21 | let dog = Dog(breed: breedName, subBreeds: ["subreed1", "subbreed2"])
22 |
23 | let initialState = AppState(
24 | dogs: [Dog(breed: "anotherDog0", subBreeds: []), dog, Dog(breed: "anotherDog1", subBreeds: [])]
25 | )
26 | let store = testStore(initialState: initialState)
27 |
28 | store.send(.dogs(.breedWasSelected(name: breedName))) {
29 | $0.breedState = BreedState(dog: dog, imageURL: nil)
30 | }
31 |
32 | store.send(.breedsDisappeared) {
33 | $0.breedState = nil
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Tests/DogBreedsComponentTests/Breed/Store/BreedStoreTests.swift:
--------------------------------------------------------------------------------
1 | @testable import DogBreedsComponent
2 | import ComposableArchitecture
3 | import XCTest
4 |
5 | class BreedStoreTests: XCTestCase {
6 | func testStore(initialState: BreedState)
7 | -> TestStore {
8 | TestStore(
9 | initialState: initialState,
10 | reducer: BreedState.reducer,
11 | environment: .failing
12 | )
13 | }
14 | }
15 |
16 | // MARK: - Image Loading
17 | extension BreedStoreTests {
18 | func testBreedImageLoad() {
19 |
20 | let breedName = "Breed"
21 | let initialState = BreedState(dog: Dog(breed: breedName, subBreeds: []), imageURL: nil)
22 | let store = testStore(initialState: initialState)
23 |
24 | let expectedURL = URL(string: "breed.com")
25 |
26 | store.environment.loadDogImage = {
27 | XCTAssertEqual($0, breedName)
28 | return Effect(value: expectedURL)
29 | }
30 |
31 | store.send(.getBreedImageURL)
32 | store.receive(.breedImageURLReceived(expectedURL)) {
33 | $0.imageURL = expectedURL
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Tests/DogBreedsComponentTests/Breed/Views/BreedViewStateConverterTests.swift:
--------------------------------------------------------------------------------
1 | @testable import DogBreedsComponent
2 | import XCTest
3 |
4 | class BreedViewStateConverterTest: XCTestCase {
5 |
6 | func testViewStateTitleGoesFromFirstLetterCapitalizedStatesDogsBreed() {
7 | // Given
8 | let dog = Dog(breed: "dog", subBreeds: [])
9 | // When
10 | let viewState = BreedView.ViewState.convert(from: BreedState(dog: dog, imageURL: nil))
11 | // Then
12 | XCTAssertEqual(viewState.title, "Dog")
13 | }
14 |
15 | func testViewStateSubTitleIsSubBreedsIfStatesSubBreedsArrayIsNotEmpty() {
16 | // Given
17 | let dog = Dog(breed: "", subBreeds: ["0"])
18 | // When
19 | let viewState = BreedView.ViewState.convert(from: BreedState(dog: dog, imageURL: nil))
20 | // Then
21 | XCTAssertEqual(viewState.subtitle, "Sub-breeds")
22 | }
23 |
24 | func testViewStateSubBreedsGoFromFirstLetterCapitalizedStatesSubBreeds() {
25 | // Given
26 | let dog = Dog(breed: "", subBreeds: ["abc", "def"])
27 | // When
28 | let viewState = BreedView.ViewState.convert(from: BreedState(dog: dog, imageURL: nil))
29 | // Then
30 | XCTAssertEqual(viewState.subBreeds, ["Abc", "Def"])
31 | }
32 |
33 | func testViewStateImageURLGoesFromStatesImageURL() {
34 | // Given
35 | let url = URL(string: "dog.com")
36 | // When
37 | let viewState = BreedView.ViewState.convert(from: BreedState(dog: Dog(breed: "", subBreeds: []), imageURL: url))
38 | // Then
39 | XCTAssertEqual(viewState.imageURL, url)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Tests/DogBreedsComponentTests/Dogs/Store/DogsStoreTests.swift:
--------------------------------------------------------------------------------
1 | @testable import DogBreedsComponent
2 | import ComposableArchitecture
3 | import XCTest
4 |
5 | class DogsStoreTests: XCTestCase {
6 | func testStore(initialState: DogsState = .initial)
7 | -> TestStore {
8 | TestStore(
9 | initialState: initialState,
10 | reducer: DogsState.reducer,
11 | environment: .failing
12 | )
13 | }
14 | }
15 |
16 | // MARK: - Filtering
17 | extension DogsStoreTests {
18 | func testFilterQueryChanged() {
19 |
20 | let store = testStore()
21 | let query = "buhund"
22 |
23 | store.send(.filterQueryChanged(query)) {
24 | $0.filterQuery = query
25 | }
26 | }
27 | }
28 |
29 | // MARK: - Loading
30 | extension DogsStoreTests {
31 | func testDogsLoad() {
32 |
33 | let store = testStore()
34 | let expectedDogs = [Dog(breed: "dog", subBreeds: [])]
35 | store.environment.loadDogs = {
36 | Effect(value: expectedDogs)
37 | }
38 |
39 | store.send(.loadDogs)
40 | store.receive(.dogsLoaded(expectedDogs)) {
41 | $0.dogs = expectedDogs
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Tests/DogBreedsComponentTests/Dogs/Views/DogsViewStateConverterTests.swift:
--------------------------------------------------------------------------------
1 | @testable import DogBreedsComponent
2 | import XCTest
3 |
4 | class DogsViewStateConverterTest: XCTestCase {
5 | func testViewStateFilterTextGoesFromStateFilterQuery() {
6 | // Given
7 | let filterQuery = "filter"
8 | // When
9 | let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: filterQuery, dogs: []))
10 | // Then
11 | XCTAssertEqual(viewState.filterText, filterQuery)
12 | }
13 |
14 | func testViewStateLoadingStateIsLoadingIfStateDogsAndFilterQueryIsEmpty() {
15 | // When
16 | let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: "", dogs: []))
17 | // Then
18 | XCTAssertEqual(viewState.loadingState, .loading)
19 | }
20 |
21 | func testViewStateLoadingStateIsLoadedGoesFirstLetterCapitalizedStateDogsBreedWithEmptyFilterQuery() {
22 | // Given
23 | let dogs = [Dog(breed: "breed0", subBreeds: []), Dog(breed: "breed1", subBreeds: [])]
24 | // When
25 | let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: "", dogs: dogs))
26 | // Then
27 | XCTAssertEqual(viewState.loadingState, .loaded(breeds: ["Breed0", "Breed1"]))
28 | }
29 |
30 | func testViewStateLoadingStateIsLoadedGoesFirstLetterCapitalizedStateDogsBreedFilteredContainsByFilterQuery() {
31 | // Given
32 | let breed0 = "Abc0"
33 | let breed1 = "Abc1"
34 | let breed2 = "Def0"
35 | let breed3 = "Def1"
36 | let dogs = [breed0, breed1, breed2, breed3].map { Dog(breed: $0, subBreeds: []) }
37 | // When
38 | let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: "abc", dogs: dogs))
39 | // Then
40 | XCTAssertEqual(viewState.loadingState, .loaded(breeds: [breed0, breed1]))
41 |
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/resources/AppDemo0.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atimca/TCA-tutorial/5361a9eb7fd3e65029dcc1bfaa8b4c264d9706f8/resources/AppDemo0.gif
--------------------------------------------------------------------------------
/resources/Breeds/Final.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atimca/TCA-tutorial/5361a9eb7fd3e65029dcc1bfaa8b4c264d9706f8/resources/Breeds/Final.png
--------------------------------------------------------------------------------
/resources/Dogs/Final.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atimca/TCA-tutorial/5361a9eb7fd3e65029dcc1bfaa8b4c264d9706f8/resources/Dogs/Final.gif
--------------------------------------------------------------------------------
/resources/Dogs/Loaded.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atimca/TCA-tutorial/5361a9eb7fd3e65029dcc1bfaa8b4c264d9706f8/resources/Dogs/Loaded.png
--------------------------------------------------------------------------------
/resources/Dogs/Loading.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atimca/TCA-tutorial/5361a9eb7fd3e65029dcc1bfaa8b4c264d9706f8/resources/Dogs/Loading.png
--------------------------------------------------------------------------------
/resources/setup/Step0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atimca/TCA-tutorial/5361a9eb7fd3e65029dcc1bfaa8b4c264d9706f8/resources/setup/Step0.png
--------------------------------------------------------------------------------
/resources/setup/Step1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/atimca/TCA-tutorial/5361a9eb7fd3e65029dcc1bfaa8b4c264d9706f8/resources/setup/Step1.png
--------------------------------------------------------------------------------