Context
Our journey began when SwiftUI was launched. After years of UIKit, finally, Apple's official Declarative UI framework for iOS was out. Fortunately, at Coletiv one of our clients wanted to do a proof of concept for an amazing business concept in an app and they wanted the project to be built using SwitfUI 🎉
We've tried to find the best way to accommodate all these new concepts in our code. @State
, @Bindings
, @ObservedObject
, all these new @'s became our first concern when dealing with the View state changes and where, in the code, we should perform it. Some of the problems are very well described in these videos.
Finally after some time investigating, André showed me these videos provided by pointfree.co. They describe how their proposal for a SwiftUI architecture is. Their explanations are very practical and clear. Their solutions are always iterated until the final one is achieved.
The Composable Architecture (TCA) was adopted in the mentioned projects and it was a charm to work with, although we had some struggles to really integrate this new approach. Their example project is where you can check lots of use cases that can be used as an inspiration to build your products. Check them out and, if you feel like it, contribute 🙌.
Since then we decided to do a small example project to serve as a base on how we tackle small/medium SwiftUI projects using TCA at Coletiv. Next, we will try to sum up the structure and assumptions from our example project. Please note that the goal of this article is not to explain the TCA, we think the author's explanation is better than ours 😅
Pokemon TGC 👾
Pokemon TCG - Cards Screen
Our example project is an app to show Pokemon Cards. We are using the V1 for Pokemon TGC API to retrieve the Pokemon Cards info. In the Cards tab we are retrieving the information from the server and showing it in an infinite scroll list.
The API documentation was fully open source, but now requires a login to access it. The new V2 version also requires an API Key to be generated. Either way it's free for now.
Pokemon TCG - Card Detail Screen
Also, we have a detail screen where the card image is being shown with a larger size. Here the user can toggle it as favorite.
Finally, the Favorites tab is showing up the favorite cards list with the same aspect as the Cards tab.
Xcode Project 🛠
We always try to do a native approach as much as possible, avoiding unnecessary dependencies to just do small stuff. Of course we are not trying to reinvent the wheel but we like to not rely too much on something that can be deprecated too quickly, usually caused by lack of support.
Another key point is simplification, we always try to simplify our solutions. Begin with a simple basis and evolve from there, if we are not building a spaceship then why do we need the rocket boosters? 🚀
Dependencies 🕸
We use the Swift Package Manager (SPM) to manage the dependencies, most of the widely used deps are now also available using the SPM, so we thought that there's no reason to not use the vanilla dependency manager for Swift. We should pay our respects 🤲🏻 to CocoaPods and Carthage as they played a crucial role in the deps problem for years until the SPM came out. 🚀 Check the example README for more details.
Folder Structure 📁
In this section let's resume a bit what's inside the main folders:
Folder Structure Overview
Data 💿
Inside the data folder you will find everything related with the data itself, models, web service clients, CoreData
and other services you may need to handle the data models.
Client Folder
The CardsClient
and FavoriteCardsClient
clients should be the only way the TCA Reducers access and change data. Those clients follow a pattern well described in this video where there's always a live and a mock version of the client. This improves a lot on testability allowing our code to be fully "mockable". This will remove the tests dependency from external data providers (ex. API calls).
The Provider
holds all the code to interact with the services that provide the data. This includes request/response serialisation, URLRequest
, CoreData
queries, etc...
Design System 👨🎨
This is where all the design components are. We usually add here typography, buttons, table cells, fields, modals, etc. Usually, this is the first place to have some UI code. Breaking the UI in reusable components helps on code clarity. Main views will have just the necessary code to glue all these components, creating complex UI's with simple code.
Application 📱
The Application
folder contains the app screen main views, in our Pokemon example it includes the cards list (CardsView
), the favorite cards list (FavoritesView
) and the card detail screen (CardDetailView
). All the main views have its own "Core" file, this file is where the TCA elements are. In almost every example project on the TCA repository the View
and the Core
are in the same file. We think this separation improves code readability but there's a discussion here about the Core
entities separation and the creation of a generator for those.
Digging up into some code, there are some highlights I would like to share:
Main Reducer
// Main Reducer
let mainReducer: Reducer<MainState, MainAction, MainEnvironment> = .combine(
cardsReducer.pullback(
state: \MainState.cardsState,
action: /MainAction.cards,
environment: { environment in
CardsEnvironment(
cardsClient: environment.cardsClient,
favoriteCardsClient: environment.favoriteCardsClient,
mainQueue: environment.mainQueue,
uuid: environment.uuid
)
}
),
favoritesReducer.pullback(
state: \MainState.favoritesState,
action: /MainAction.favorites,
environment: { environment in
FavoritesEnvironment(
favoriteCardsClient: environment.favoriteCardsClient,
mainQueue: environment.mainQueue,
uuid: environment.uuid
)
}
),
.init { state, action, environment in
switch action {
// Update favorites on Cards State
case .cards(.card(id: _, action: .toggleFavoriteResponse(.success(let favorites)))):
state.favoritesState.cards = .init(
favorites.map {
CardDetailState(
id: environment.uuid(),
card: $0
)
}
)
return .none
case .cards:
return .none
// Update favorites on Favorites State
case .favorites(.card(id: _, action: .toggleFavoriteResponse(.success(let favorites)))):
state.cardsState.favorites = favorites
return .none
case .favorites:
return .none
case .selectedTabChange(let selectedTab):
state.selectedTab = selectedTab
return .none
}
}
)
Note that the mainReducer
combines the cardsReducer
and the favoritesReducer
. This allows the mainReducer
to handle all the actions that are also handled by the cardsReducer
and favoritesReducer
. This is useful, for example, to update the main state so that the favorites are the same across all the screens when we perform an add/remove favorite action.
Cancel Effects
Cancelling the Effects is very important, specially for network related tasks.
Here's a quick example from the favoritesReducer
:
// Favorites Reducer
switch action {
case .onAppear:
guard state.cards.isEmpty else { return .none }
return .init(value: .retrieveFavorites)
case .retrieveFavorites:
return environment.favoriteCardsClient
.all()
.receive(on: environment.mainQueue)
.catchToEffect()
.map(FavoritesAction.favoritesResponse)
.cancellable(id: FavoritesCancelId())
case .favoritesResponse(.success(let favorites)):
state.cards = .init(
favorites.map {
CardDetailState(
id: environment.uuid(),
card: $0
)
}
)
return .none
case .card(id: _, action: .onDisappear):
return .init(value: .retrieveFavorites)
case .card(id: _, action: _):
return .none
case .onDisappear:
return .cancel(id: FavoritesCancelId())
}
// Favorites view
struct FavoritesView: View {
var store: Store<FavoritesState, FavoritesAction>
var body: some View {
WithViewStore(store) { viewStore in
NavigationView {
ScrollView {
itemsList(viewStore)
.padding()
}
.edgesIgnoringSafeArea(.bottom)
.navigationBarTitle(Localization.Cards.title)
}
.onAppear { viewStore.send(.onAppear) }
.onDisappear { viewStore.send(.onDisappear) }
}
}
}
The first time the view appears it will trigger the .retrieveFavorites
action. This will trigger the fetch request for the favorite cards that will be later returned to the .favoritesResponse
action. Notice that a cancel id is being assigned to this Effect. If the user dismisses this screen/view this id will allow any other action to cancel an in progress effect that does not need to be running anymore.
Testing ✅
One of the reasons we adopted TCA is testability. TCA definitely changed the way we approach testing.
In our PokemonCards
example we have some tests to ensure the reducers business logic is well implemented.
We also want to highlight the SnapshotTesting package that also fits in the TCA. You can check some simple examples in PokemonCardsSnapshotTests.swift
file.
Conclusion
We think SwiftUI is one of the biggest changes in the latest years and it will definitely improve the code quality and overall development speed in the next years. This is just the beginning, new architectures will emerge and we should always keep an eye on updates and support these kind of community projects.
In the end it's a relief to have the technology to automatically do our manual testing, helping the developers to build better products.
Next Steps 🔮
- Update the example to the V2 of the Pokémon TCG API
- We are also hoping to update this example with more complex use cases
- We definitely want to test this in huge projects, applying for example this awesome modular architecture
Source Code
https://github.com/coletiv/coletiv-ios-swiftui-tca-example