28
loading...
This website collects cookies to deliver better user experience
Disclaimer: This tutorial uses Xcode 12.4, Swift 5.3, and iOS 14.
You can find the source code of this tutorial here. If you're so eager to try it that you want to skip the tutorial, just launch RootView.swift
preview.
the composable architecture
(TCA for short). TCA
is a variant of an unidirectional architecture built upon reactive programming principles, created by PointFree. They provide extensive documentation about it and its creation process. You can check it out here. Interesting stuff and highly recommended if you like to understand every concept as intended by its creators.TCA
as well. However, let's start with a bit of theoretical knowledge so we know what we’re doing here.TCA
is one of the variations of unidirectional architectures family, such as Redux
, RxFeedback
, Flux
etc. Let's just copy some explanation from the official GitHub.State management
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.
Composition
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.
Side effects
How to let certain parts of the application talk to the outside world in the most testable and understandable way possible.
Testing
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.
Ergonomics
How to accomplish all of the above in a simple API with as few concepts and moving parts as possible.
State
A type that describes the data your feature needs to perform its logic and render its UI.
Action
A type that represents all actions that can happen in your features, such as user actions, notifications, event sources et cetera.
Environment
A type that holds any dependencies the feature needs, like API clients, analytics clients and so on.
Reducer
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.
Store
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.
Package.swift
file by adding necessary dependencies that we need for this tutorial. The final result will look like this:// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "DogBreedsComponent",
platforms: [.iOS(.v14)],
products: [
.library(
name: "DogBreedsComponent",
targets: ["DogBreedsComponent"]
),
],
dependencies: [
// 1.
.package(
name: "swift-composable-architecture",
url: "https://github.com/pointfreeco/swift-composable-architecture.git",
.exact("0.17.0")
),
// 2.
.package(
name: "Kingfisher",
url: "https://github.com/onevcat/Kingfisher",
.exact("6.2.1")
)
],
targets: [
.target(
name: "DogBreedsComponent",
dependencies: [
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "Kingfisher", package: "Kingfisher")
]
),
.testTarget(
name: "DogBreedsComponentTests",
dependencies: [
"DogBreedsComponent",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture")
]
),
]
)
TCA
framework itselfKingfisher
library, that we’re going to use for async image loadingKingfisher
library, with a possibility to load async images:import Kingfisher
import SwiftUI
extension KFImage {
func header() - > some View {
GeometryReader { geometry in
resizable()
.placeholder {
ProgressView()
.frame(height: 240)
}
.aspectRatio(contentMode: .fill)
.frame(width: geometry.size.width, height: 240)
.clipped()
}
.frame(height: 240)
}
}
extension String {
var capitalizedFirstLetter: String {
prefix(1).capitalized + dropFirst()
}
}
State
from view/layout-based ViewState
.DogsView
itself. The result is supposed to look like this:import SwiftUI
struct DogsView: View {
var body: some View {
Text("Hello, World!")
}
}
struct SwiftUIView_Previews: PreviewProvider {
static var previews: some View {
DogsView()
}
}
SwiftUI
view from a template. However, we're going to use DogsView
as a namespace for a further journey.ViewState
for Dogs screen.extension DogsView {
// 1.
struct ViewState: Equatable {
// 2.
let filterText: String
// 3.
let loadingState: LoadingState
}
}
// MARK: - Loading
// 3.
extension DogsView.ViewState {
enum LoadingState: Equatable {
case loaded(breeds: [String])
case loading
var breeds: [String] {
guard case .loaded(let breeds) = self else { return [] }
return breeds
}
var isLoading: Bool { self == .loading }
}
}
struct
ViewState which is going to represent DogsView layout.filterText
property speaks for itself.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).extension DogsView {
enum ViewAction: Equatable {
case cellWasSelected(breed: String)
case onAppear
case filterTextChanged(String)
}
}
SwiftUI
body for the DogsView as presented in the GIF earlier:import ComposableArchitecture
import SwiftUI
struct DogsView: View {
// 1.
let store: Store<ViewState, ViewAction>
var body: some View {
// 2.
WithViewStore(store) { viewStore in
VStack {
// 3.
if viewStore.loadingState.isLoading {
ProgressView()
} else {
searchBar(for: viewStore)
breedsList(for: viewStore)
}
}
.navigationBarTitle("Dogs")
.padding()
// 4.
.onAppear { viewStore.send(.onAppear) }
}
}
}
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 Actions.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.ViewAction.onAppear
to the Store. We’ll cover the handling of these events in a bit.searchBar
function.@ViewBuilder
private func searchBar(for viewStore: ViewStore<ViewState, ViewAction>) - > some View {
HStack {
Image(systemName: "magnifyingglass")
TextField(
"Filter breeds",
// 1.
text: viewStore.binding(
get: \.filterText,
send: ViewAction.filterTextChanged
)
)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
}
}
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
.viewStore.binding(
get: \.filterText,
send: ViewAction.filterTextChanged
)
Binding(
get: { viewStore.filterText },
set: { viewStore.send(.filterTextChanged($0)) }
)
breedsList
function real quick:@ViewBuilder
private func breedsList(for viewStore: ViewStore<ViewState, ViewAction>) - > some View {
ScrollView {
// 1.
ForEach(viewStore.loadingState.breeds, id: \.self) { breed in
VStack {
// 2.
Button(action: { viewStore.send(.cellWasSelected(breed: breed)) }) {
HStack {
Text(breed)
Spacer()
Image(systemName: "chevron.right")
}
}
Divider()
}
.foregroundColor(.primary)
}
}
.padding()
}
ForEach
we're going through the ViewState.breeds
array and drawing cells for every breed.ViewAction.cellWasSelected
action to the Store.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:struct DogsView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
DogsView(
store: Store(
initialState: DogsView.ViewState(
filterText: "",
loadingState: .loaded(
breeds: [
"affenpinscher",
"african",
"airedale",
"akita",
"appenzeller",
"australian",
"basenji",
"beagle",
"bluetick"
]
)
),
reducer: .empty,
environment: ()
)
)
}
NavigationView {
DogsView(
store: Store(
initialState: DogsView.ViewState(
filterText: "",
loadingState: .loading
),
reducer: .empty,
environment: ()
)
)
}
}
}
struct DogsState: Equatable {
var filterQuery: String
var dogs: [Dog]
static let initial = DogsState(filterQuery: "", dogs: [])
}
public struct Dog: Equatable {
let breed: String
let subBreeds: [String]
}
public enum DogsAction: Equatable {
case breedWasSelected(name: String)
case dogsLoaded([Dog])
case filterQueryChanged(String)
case loadDogs
}
struct DogsEnvironment {
var loadDogs: () - > Effect<[Dog], Never>
}
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: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
constructing some common types of effects.
extension DogsState {
// 1.
static let reducer = Reducer<DogsState, DogsAction, DogsEnvironment> { state, action, environment in
switch action {
// 2.
case .breedWasSelected:
return .none
// 3.
case .dogsLoaded(let dogs):
state.dogs = dogs
return .none
// 4.
case .filterQueryChanged(let query):
state.filterQuery = query
return .none
// 5.
case .loadDogs:
return environment
.loadDogs()
.map(DogsAction.dogsLoaded)
}
}
}
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.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. DogsAction.breedWasSelected
is not handled at this moment, because we're going to extract navigation from the Dogs module itself.DogsAction.dogsLoaded
is an action used for DogsState to update with new dogs when they‘re loaded.DogsAction.filterQueryChanged
is an action that’s called when the user changes the filter query.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.extension DogsView.ViewState {
// 1.
static func convert(from state: DogsState) - > Self {
.init(
filterText: state.filterQuery,
loadingState: loadingState(from: state)
)
}
private static func loadingState(from state: DogsState) - > LoadingState {
if state.dogs.isEmpty { return .loading }
// 2.
var breeds = state.dogs.map(\.breed.capitalizedFirstLetter)
if !state.filterQuery.isEmpty {
// 3.
breeds = breeds.filter {
$0.lowercased().contains(state.filterQuery.lowercased())
}
}
return .loaded(breeds: breeds)
}
}
class DogsViewStateConverterTest: XCTestCase {
func testViewStateFilterTextGoesFromStateFilterQuery() {
// Given
let filterQuery = "filter"
// When
let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: filterQuery, dogs: []))
// Then
XCTAssertEqual(viewState.filterText, filterQuery)
}
func testViewStateLoadingStateIsLoadingIfStateDogsAndFilterQueryIsEmpty() {
// When
let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: "", dogs: []))
// Then
XCTAssertEqual(viewState.loadingState, .loading)
}
func testViewStateLoadingStateIsLoadedGoesFirstLetterCapitalizedStateDogsBreedWithEmptyFilterQuery() {
// Given
let dogs = [Dog(breed: "breed0", subBreeds: []), Dog(breed: "breed1", subBreeds: [])]
// When
let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: "", dogs: dogs))
// Then
XCTAssertEqual(viewState.loadingState, .loaded(breeds: ["Breed0", "Breed1"]))
}
func testViewStateLoadingStateIsLoadedGoesFirstLetterCapitalizedStateDogsBreedFilteredContainsByFilterQuery() {
// Given
let breed0 = "Abc0"
let breed1 = "Abc1"
let breed2 = "Def0"
let breed3 = "Def1"
let dogs = [breed0, breed1, breed2, breed3].map { Dog(breed: $0, subBreeds: []) }
// When
let viewState = DogsView.ViewState.convert(from: DogsState(filterQuery: "abc", dogs: dogs))
// Then
XCTAssertEqual(viewState.loadingState, .loaded(breeds: [breed0, breed1]))
}
}
extension DogsState {
var view: DogsView.ViewState {
DogsView.ViewState.convert(from: self)
}
}
extension DogsAction {
static func view(_ localAction: DogsView.ViewAction) - > Self {
switch localAction {
case .cellWasSelected(let breed):
return .breedWasSelected(name: breed)
case .onAppear:
return .loadDogs
case .filterTextChanged(let newValue):
return .filterQueryChanged(newValue)
}
}
}
extension DogsEnvironment {
static let fake = DogsEnvironment(
loadDogs: {
Effect(
value: [
Dog(breed: "affenpinscher", subBreeds: []),
Dog(breed: "bulldog", subBreeds: ["boston", "english", "french"])
]
)
.delay(for: .seconds(2), scheduler: DispatchQueue.main)
.eraseToEffect()
}
)
}
loading
to loaded
states.NavigationView {
DogsView(
// 1.
store: Store(
initialState: DogsState.initial,
reducer: DogsState.reducer,
environment: DogsEnvironment.fake
)
// 2.
.scope(
state: \.view,
action: DogsAction.view
)
)
}
State
/Action
based approach really helps with testing.TCA
, you probably know what TestStore is. Let's implement it for Dogs module:class DogsStoreTests: XCTestCase {
func testStore(initialState: DogsState = .initial)
- > TestStore<DogsState, DogsState, DogsAction, DogsAction, DogsEnvironment> {
TestStore(
initialState: initialState,
reducer: DogsState.reducer,
environment: .failing
)
}
}
send
and receive
functions that mimic real app/user behavior. But wait a second... What's the environment: .failing
here?extension DogsEnvironment {
static let failing = DogsEnvironment(
loadDogs: { .failing("DogsEnvironment.loadDogs") }
)
}
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.extension DogsStoreTests {
func testDogsLoad() {
// 1.
let store = testStore()
let expectedDogs = [Dog(breed: "dog", subBreeds: [])]
// 2.
store.environment.loadDogs = {
Effect(value: expectedDogs)
}
// 3.
store.send(.loadDogs)
// 4.
store.receive(.dogsLoaded(expectedDogs)) {
// 5.
$0.dogs = expectedDogs
}
}
}
store: TestStore
property, which we’ll use for testing.loadDogs
dependency.DogsAction.loadDogs
for a request of dogs loading.DogsAction.dogsLoaded
.extension DogsStoreTests {
func testFilterQueryChanged() {
let store = testStore()
let query = "buhund"
store.send(.filterQueryChanged(query)) {
$0.filterQuery = query
}
}
}
Dogs.breed
as a title for screen.Dogs.subBreeds
as a title for each cell.TCA
. If you’d like to know more details, watch this pointfree collection about modularity.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.public struct AppState: Equatable {
// 1.
var dogs: [Dog]
// 2.
var dogsInternal = DogsInternalState()
// 3.
var breedState: BreedState?
public static let initial = AppState(dogs: [], breedState: nil)
}
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.// 1.
struct DogsInternalState: Equatable {
var filterQuery: String
init() {
self.filterQuery = ""
}
init(state: DogsState) {
self.filterQuery = state.filterQuery
}
}
// 2.
extension DogsState {
init(
internalState: DogsInternalState,
dogs: [Dog]
) {
self.init(
filterQuery: internalState.filterQuery,
dogs: dogs
)
}
}
// 3.
extension AppState {
var dogsState: DogsState {
get {
DogsState(internalState: dogsInternal, dogs: dogs)
}
set {
dogsInternal = .init(state: newValue)
dogs = newValue.dogs
}
}
}
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.dogsState
which actually is a DogsState part of AppState.public enum AppAction: Equatable {
case breed(BreedAction)
case breedsDisappeared
case dogs(DogsAction)
}
extension AppState {
static let reducerCore = Reducer<AppState, AppAction, Void> { state, action, _ in
switch action {
// 1.
case .breed:
return .none
// 2.
case .breedsDisappeared:
state.breedState = nil
return .none
// 3.
case .dogs(.breedWasSelected(let breed)):
guard let dog = state.dogs.first(where: { $0.breed.lowercased() == breed.lowercased() }) else { fatalError() }
state.breedState = BreedState(dog: dog, imageURL: nil)
return .none
// 4.
case .dogs:
return .none
}
}
}
AppAction.breedsDisappeared
an action where we need to clear BreedState after a screen disappeared.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.extension DogsEnvironment {
private struct DogsResponse: Codable {
let message: [String: [String]]
}
static let live = DogsEnvironment(
loadDogs: {
URLSession
.shared
.dataTaskPublisher(for: URL(string: "https://dog.ceo/api/breeds/list/all")!)
.map(\.data)
.decode(type: DogsResponse.self, decoder: JSONDecoder())
.map { response in
response
.message
.map(Dog.init)
.sorted { $0.breed < $1.breed }
}
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.eraseToEffect()
}
)
}
extension BreedEnvironment {
private struct BreedImageResponse: Codable {
let message: String?
}
static let live = BreedEnvironment(
loadDogImage: { breed in
URLSession
.shared
.dataTaskPublisher(for: URL(string: "https://dog.ceo/api/breed/\(breed)/images/random")!)
.map(\.data)
.decode(type: BreedImageResponse.self, decoder: JSONDecoder())
.compactMap(\.message)
.map(URL.init(string:))
.replaceError(with: nil)
.receive(on: DispatchQueue.main)
.eraseToEffect()
}
)
}
public extension AppState {
static let reducer = Reducer<AppState, AppAction, Void>
// 1.
.combine(
// 2.
AppState.reducerCore,
// 3.
DogsState
.reducer
// 4.
.pullback(
// 5.
state: \.dogsState,
// 6.
action: /AppAction.dogs,
// 7.
environment: { _ in DogsEnvironment.live }
),
// 8.
BreedState
.reducer
// 9.
.optional()
.pullback(
state: \.breedState,
action: /AppAction.breed,
environment: { _ in BreedEnvironment.live }
)
)
}
pullback
function help. And as a result of this function we have AppReducer.dogsState
part of the AppState.AppAction.dogs
part of AppAction.optional
.public struct RootView: View {
let store: Store<AppState, AppAction>
let dogsView: DogsView
public init(store: Store<AppState, AppAction>) {
self.store = store
// 1.
dogsView = DogsView(
store: store.scope(
state: \.dogsState.view,
action: { local - > AppAction in
AppAction.dogs(
DogsAction.view(local)
)
}
)
)
}
public var body: some View {
// 2.
WithViewStore(store.scope(state: \.breedState)) { viewStore in
HStack {
// 3.
dogsView
NavigationLink(
destination: breedView,
isActive: viewStore.binding(
get: { $0 != nil },
send: .breedsDisappeared
),
label: EmptyView.init
)
}
}
}
// 4.
var breedView: some View {
IfLetStore(
store.scope(
state: \.breedState?.view,
action: { local - > AppAction in
AppAction.breed(
BreedAction.view(local)
)
}
),
then: BreedView.init(store:)
)
}
}
breedState
part of the AppState only for navigation purposes, which means that closure will be called only on breedState
changes.body
of the view contains DogsView and invisible NavigationLink which is backed by ViewStore Binding with a destination of BreedView.struct RootView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
RootView(
store: Store(
initialState: .initial,
reducer: AppState.combinedReducer,
environment: ()
)
)
}
}
}
@testable import DogBreedsComponent
import ComposableArchitecture
import XCTest
class AppStoreTests: XCTestCase {
func testStore(initialState: AppState) - > TestStore {
TestStore(
initialState: initialState,
reducer: AppState.reducer,
environment: ()
)
}
}
// MARK: - Navigation
extension AppStoreTests {
func testNavigation() {
let breedName = "Hound"
let dog = Dog(breed: breedName, subBreeds: ["subreed1", "subbreed2"])
let initialState = AppState(
dogs: [Dog(breed: "anotherDog0", subBreeds: []), dog, Dog(breed: "anotherDog1", subBreeds: [])]
)
let store = testStore(initialState: initialState)
store.send(.dogs(.breedWasSelected(name: breedName))) {
$0.breedState = BreedState(dog: dog, imageURL: nil)
}
store.send(.breedsDisappeared) {
$0.breedState = nil
}
}
}
Redux
framework for js. Don't need to go into the details, but this website contains a good explanation of the concept.