TCA는 일관되고 이해하기 쉬운 방법으로 애플리케이션을 작성하기 위한 라이브러리이다.
TCA는 UIKit
과 SwiftUI
에서 모두 사용할 수 있고, iOS, watchOS, macOS, 그리고 tvOS에서 사용할 수 있다.
(아마 visionOS도 되겠지...?)
TCA는 비교적 최근에 1.0.0 버전이 나왔고, 현재 최신 버전은 1.2.0인데,
이번 사이드 프로젝트에 TCA를 사용해보면 재미있는 경험이 되지 않을까 싶어 '머리가 아닌 마음으로 이해하기' 위한 여정을 정리해보려고 한다.
TCA는 라이브러리이므로 우선 프로젝트에 추가하여야 한다.
Swift Package Manager에 다음의 URL를 넣고 설치한다.
https://github.com/pointfreeco/swift-composable-architecture
TCA를 마음으로 이해하기 위해서는 이를 활용한 간단한 앱을 만들어보는 편이 쉽고 빠를 것 같았다.
+, - 버튼을 누를 때마다 화면의 숫자가 바뀌는 간단한 앱이다.
Feature는 Reducer 프로토콜을 구현하는 구조체로, 기능에서 사용할 값, 상태, 액션, 그리고 reduce 함수로 구성되어 있다.
우선 빈 Feature를 작성해본다.
import ComposableArchitecture
struct Feature: Reducer {
struct State: Equatable {
}
enum Action: Equatable {
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
}
// Parent로서 동작할 경우
/*
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
}
}
}
*/
}
Feature는 Parent로서 Child를 가질 수 있는데, 이 경우엔 reduce
함수가 아닌 body
를 구현해주어야 한다.
작성한 Feature를 실제로 동작시키기 위해, Store로 사용해야 한다.
import SwiftUI
import ComposableArchitecture
struct ContentView: View {
let store: StoreOf<Feature>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
// ... UI
}
}
}
ContentView에 let store: StoreOf<Feature>
를 선언해주고, View에서 WithViewStore(_ store:observe:content:)
를 작성해 ViewStore를 사용할 수 있도록 한다.
이제 ContentView가 store
프로퍼티를 갖게 되었으므로, ContentView를 초기화할 때에는 다음과 같이 작성한다.
ContentView(store: Store(initialState: Feature.State(), reducer: {
Feature()
}))
Store를 초기화할 때 initialState에 Feature.State()
를 넣어주고, reducer에 reduce가 구현된 Feature()를 넣어주면 된다.
만약 Feature의 State가 초기에 가져야 하는 값이 있다면 이 곳에서 하면 된다.
따라서 WindowGroup은 이렇게 된다.
import SwiftUI
import ComposableArchitecture
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView(store: Store(initialState: Feature.State(), reducer: {
Feature()
}))
}
}
}
이제 버튼을 클릭하면 화면의 숫자가 상승하는 앱을 예제로 만들어본다.
우선 화면을 그린다.
import SwiftUI
import ComposableArchitecture
struct ContentView: View {
let store: StoreOf<Feature>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
VStack {
Text("0")
.font(.title)
HStack {
Button("-") {
// - Action
}
Button("+") {
// + Action
}
}
.buttonStyle(.borderedProminent)
}
}
}
}
숫자를 표시할 Text
, 그리고 각각 더하기와 빼기 작업을 할 Button
두 개를 구현한다.
TCA에서 View는 오직 UI를 그리는 역할만을 하기 때문에 그 어떠한 상태 변수, Action 등을 선언하지 않는다.
이는 모두 Feature에서 구현된다.
Feature.State
에는 숫자를 저장할 변수를 선언한다.
import Foundation
import ComposableArchitecture
struct Feature: Reducer {
struct State: Equatable {
var count: Int = 0
}
//...
}
이 앱은 화면에 숫자를 표시할 count: Int
변수를 하나 선언해주었다.
Feature.Action
에는 버튼이 수행할 Action을 정의해놓는다.
import Foundation
import ComposableArchitecture
struct Feature: Reducer {
struct State: Equatable { ... }
enum Action: Equatable {
case plusButtonTap
case minusButtonTap
}
//...
}
Action
은 열거형으로, 보낼 수 있는 Action을 나열한 것이다.
실제 이 Action과 State가 연결되는 것은 reduce 함수이다.
reduce 함수를 잠깐 보면
func reduce(into state: inout State, action: Action) -> Effect<Action> { ... }
State의 변수를 변경할 수 있도록 inout State
와 어떤 액션이 호출되었는지 알 수 있도록 Action
이 전달된다.
여기서 Action
을 switch
의 판별 변수로 사용하면 각 Action의 case에 따라 작업을 수행할 수 있다.
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .plusButtonTap:
// 더하기 작업 구현
return .none
case .minusButtonTap:
// 빼기 작업 구현
return .none
}
}
State의 값을 변경하기 위해서는 인자로 전달된 state: inout State
를 사용하여 조작한다.
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .plusButtonTap:
// 더하기 작업 구현
state.count += 1
return .none
case .minusButtonTap:
// 빼기 작업 구현
state.count -= 1
return .none
}
}
import Foundation
import ComposableArchitecture
struct Feature: Reducer {
struct State: Equatable {
var count: Int = 0
}
enum Action: Equatable {
case plusButtonTap
case minusButtonTap
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .plusButtonTap:
state.count += 1
return .none
case .minusButtonTap:
state.count -= 1
return .none
}
}
}
Feature의 구현이 완료되었으면, 이제 View에서 값을 참조하거나 변경하는 작업을 어떻게 할 수 있는지 알아보자
아까 변경한 ContentView를 다시 보자
import SwiftUI
import ComposableArchitecture
struct ContentView: View {
let store: StoreOf<Feature>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
// ... UI
}
}
}
이 코드에서 WithViewStore
의 content 클로저를 보면 viewStore
라는 argument 하나가 반환되는 것을 볼 수 있는데,
이 viewStore
를 가지고 State의 값을 참조할 수 있다.
Text("\(viewStore.count)")
.font(.title)
Text
가 viewStore의 count를 보여주도록 했다.
그럼 State의 count 값이 변경되면 상태 변화를 감지하여 다시 랜더링 될 것이다.
다만 이 때 viewStore의 프로퍼티들은 모두 let
으로, 값의 직접적인 변경은 불가능하다.
값을 변경하기 위해서는 꼭 Action을 통해야 한다.
TCA에서의 흐름은 무조건 단방향이라는 것을 기억하자.
Action을 보내는 것도 State의 값을 참조하는 것 만큼 쉽다.
viewStore의 send(_ action: Feature.Action)
함수를 사용하면 Action을 보낼 수 있다.
Button("-") {
// - Action
viewStore.send(.minusButtonTap)
}
Button("+") {
// + Action
viewStore.send(.plusButtonTap)
}
이렇게 버튼이 눌렸을 때 viewStore에 아까 선언했던 .plusButtonTap
Action을 send()
함수를 통해 보내면
reduce 함수에서 구현했던 .plusButtonTap
로직을 수행할 수 있는 것이다.
import SwiftUI
import ComposableArchitecture
struct ContentView: View {
let store: StoreOf<Feature>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
VStack {
Text("\(viewStore.count)")
.font(.title)
HStack {
Button("-") {
// - Action
viewStore.send(.minusButtonTap)
}
Button("+") {
// + Action
viewStore.send(.plusButtonTap)
}
}
.buttonStyle(.borderedProminent)
}
}
}
}
#Preview {
ContentView(store: Store(initialState: Feature.State(), reducer: {
Feature()
}))
}