29 Mar 2021
An example case of our to structure your SwiftUI projects using The Composable Architecture.
Our journey began when SwiftUI was launched. After years of UIKit, Apple's official Declarative UI framework for iOS was finally out. Fortunately, one of our clients wanted to do a proof of concept for a fantastic 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 was a charm to work with, although we struggled to 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 for how we tackle small/medium SwiftUI projects using TCA at Significa. 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.
Our example project is an app that shows Pokemon cards. We are using the V1 for Pokemon TGC API to retrieve the Pokemon Cards info. In the Cards tab, we retrieve the information from the server and show 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.
Also, we have a detailed screen where the card image is shown in larger sizes. Here, the user can toggle it as favourite.
Finally, the Favorites tab shows up the favourite cards list in the same way as the Cards tab.
We always try to do a native approach as much as possible, avoiding unnecessary dependencies to do small stuff. Of course, we are not trying to reinvent the wheel, but we like not to rely too much on something that can be deprecated too quickly, which is usually caused by a lack of support.
Another key point is simplification, and we always try to simplify our solutions. Begin with a simple basis and evolve from there. If we are not building a spaceship, why do we need the rocket boosters?
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 there was no reason not to 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.
In this section, let's resume a bit what's inside the main folders:
Inside the data folder, you will find everything related to the data itself: models, web service clients, CoreData
and other services you may need to handle the data models.
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 testability a lot, allowing our code to be fully "mockable". This will remove the test 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...
This is where all the design components are. We usually add typography, buttons, table cells, fields, modals, etc. Usually, this is the first place to have some UI code. Breaking the UI into reusable components helps with code clarity. Main views will have the necessary code to glue all these components, creating complex UI's with simple code.
The Application
folder contains the app screen main views. In our Pokemon example it includes the cards list (CardsView
), the favourite cards list (FavoritesView
) and the card detail screen (CardDetailView
). All the main views have their own "Core" file, which is where the TCA elements are. In almost every example project on the TCA repository 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
entity 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
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 favourites are the same across all the screens when we perform an add/remove favourite action.
Cancelling the Effects is very important, especially 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 favourite 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 no longer needs to be running.
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, which fits in the TCA. You can check some simple examples in PokemonCardsSnapshotTests.swift
file.
We think SwiftUI is one of the biggest changes in recent years, and it will definitely improve the code quality and overall development speed in the next few years. This is just the beginning; new architectures will emerge, and we should always keep an eye on updates and support these kinds of community projects.
In the end, it's a relief to have the technology to automatically do our manual testing, which helps developers build better products.
Daniel Almeida
R&O Engineering Manager
Daniel looks like Jesus. Same hair, same beard. A bit of a doppelgänger that can’t resurrect. However, he can code. Jesus couldn’t and that’s enough for us to believe in Daniel instead. Daniel is a Front-end Developer at Significa.
Significa
Team
30 September 2024
Optimise your e-commerce website for better performance.Significa
Team
Significa
Team