코드 전문
import ComposableArchitecture
import SwiftUI
private let readMe = """
This screen demonstrates how multiple independent screens can share state in the Composable \
Architecture. Each tab manages its own state, and could be in separate modules, but changes in \
one tab are immediately reflected in the other.
This tab has its own state, consisting of a count value that can be incremented and decremented, \
as well as an alert value that is set when asking if the current count is prime.
Internally, it is also keeping track of various stats, such as min and max counts and total \
number of count events that occurred. Those states are viewable in the other tab, and the stats \
can be reset from the other tab.
"""
@Reducer
struct CounterTab {
@ObservableState
struct State: Equatable {
@Presents var alert: AlertState<Action.Alert>?
var stats = Stats()
}
enum Action: Equatable {
case alert(PresentationAction<Alert>)
case decrementButtonTapped
case incrementButtonTapped
case isPrimeButtonTapped
enum Alert: Equatable {}
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .alert:
return .none
case .decrementButtonTapped:
state.stats.decrement()
return .none
case .incrementButtonTapped:
state.stats.increment()
return .none
case .isPrimeButtonTapped:
state.alert = AlertState {
TextState(
isPrime(state.stats.count)
? "👍 The number \(state.stats.count) is prime!"
: "👎 The number \(state.stats.count) is not prime :("
)
}
return .none
}
}
.ifLet(\.$alert, action: \.alert)
}
}
struct CounterTabView: View {
@Bindable var store: StoreOf<CounterTab>
var body: some View {
Form {
Text(template: readMe, .caption)
VStack(spacing: 16) {
HStack {
Button {
store.send(.decrementButtonTapped)
} label: {
Image(systemName: "minus")
}
Text("\(store.stats.count)")
.monospacedDigit()
Button {
store.send(.incrementButtonTapped)
} label: {
Image(systemName: "plus")
}
}
Button("Is this prime?") { store.send(.isPrimeButtonTapped) }
}
}
.buttonStyle(.borderless)
.navigationTitle("Shared State Demo")
.alert($store.scope(state: \.alert, action: \.alert))
}
}
@Reducer
struct ProfileTab {
@ObservableState
struct State: Equatable {
var stats = Stats()
}
enum Action {
case resetStatsButtonTapped
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .resetStatsButtonTapped:
state.stats.reset()
return .none
}
}
}
}
struct ProfileTabView: View {
let store: StoreOf<ProfileTab>
var body: some View {
Form {
Text(
template: """
This tab shows state from the previous tab, and it is capable of reseting all of the \
state back to 0.
This shows that it is possible for each screen to model its state in the way that makes \
the most sense for it, while still allowing the state and mutations to be shared \
across independent screens.
""",
.caption
)
VStack(spacing: 16) {
Text("Current count: \(store.stats.count)")
Text("Max count: \(store.stats.maxCount)")
Text("Min count: \(store.stats.minCount)")
Text("Total number of count events: \(store.stats.numberOfCounts)")
Button("Reset") { store.send(.resetStatsButtonTapped) }
}
}
.buttonStyle(.borderless)
.navigationTitle("Profile")
}
}
@Reducer
struct SharedState {
enum Tab { case counter, profile }
@ObservableState
struct State: Equatable {
var currentTab = Tab.counter
var counter = CounterTab.State()
var profile = ProfileTab.State()
}
enum Action: Equatable {
case counter(CounterTab.Action)
case profile(ProfileTab.Action)
case selectTab(Tab)
}
var body: some Reducer<State, Action> {
Scope(state: \.counter, action: \.counter) {
CounterTab()
}
.onChange(of: \.counter.stats) { _, stats in
Reduce { state, _ in
state.profile.stats = stats
return .none
}
}
Scope(state: \.profile, action: \.profile) {
ProfileTab()
}
.onChange(of: \.profile.stats) { _, stats in
Reduce { state, _ in
state.counter.stats = stats
return .none
}
}
Reduce { state, action in
switch action {
case .counter, .profile:
return .none
case let .selectTab(tab):
state.currentTab = tab
return .none
}
}
}
}
struct SharedStateView: View {
@Bindable var store: StoreOf<SharedState>
var body: some View {
TabView(selection: $store.currentTab.sending(\.selectTab)) {
NavigationStack {
CounterTabView(
store: self.store.scope(state: \.counter, action: \.counter)
)
}
.tag(SharedState.Tab.counter)
.tabItem { Text("Counter") }
NavigationStack {
ProfileTabView(
store: self.store.scope(state: \.profile, action: \.profile)
)
}
.tag(SharedState.Tab.profile)
.tabItem { Text("Profile") }
}
}
}
struct Stats: Equatable {
private(set) var count = 0
private(set) var maxCount = 0
private(set) var minCount = 0
private(set) var numberOfCounts = 0
mutating func increment() {
count += 1
numberOfCounts += 1
maxCount = max(maxCount, count)
}
mutating func decrement() {
count -= 1
numberOfCounts += 1
minCount = min(minCount, count)
}
mutating func reset() {
self = Self()
}
}
/// Checks if a number is prime or not.
private func isPrime(_ p: Int) -> Bool {
if p <= 1 { return false }
if p <= 3 { return true }
for i in 2...Int(sqrtf(Float(p))) {
if p % i == 0 { return false }
}
return true
}
#Preview {
SharedStateView(
store: Store(initialState: SharedState.State()) {
SharedState()
}
)
}
리드미 해석
This screen demonstrates how multiple independent screens can share state in the Composable Architecture. Each tab manages its own state, and could be in separate modules, but changes in one tab are immediately reflected in the other.
This tab has its own state, consisting of a count value that can be incremented and decremented, as well as an alert value that is set when asking if the current count is prime.
Internally, it is also keeping track of various stats, such as min and max counts and total number of count events that occurred. Those states are viewable in the other tab, and the stats can be reset from the other tab.
이 화면은 컴포저블 아키텍처에서 여러 개의 독립된 화면이 어떻게 상태를 공유할 수 있는지 보여줍니다. 각 탭은 자체 상태를 관리하며 별도의 모듈에 있을 수 있지만 한 탭의 변경 사항은 다른 탭에 즉시 반영됩니다.
이 탭에는 증가 및 감소할 수 있는 카운트 값과 현재 카운트가 소수인지 확인할 때 설정되는 경고 값으로 구성된 자체 상태가 있습니다.
내부적으로는 최소 및 최대 카운트, 발생한 총 카운트 이벤트 수 등 다양한 통계도 추적합니다. 이러한 상태는 다른 탭에서 볼 수 있으며, 다른 탭에서 통계를 초기화할 수 있습니다.
SharedState Reducer
SharedState의 Reudcer는 두가지 뷰를 관장합니다. 하나의 스토어에서 두가지의 뷰를 보여주기 위해서는 몇가지를 더 생각해야 합니다. TCA에서 로직이 있는 뷰를 보여주는것은 Store를 통해 보여줘야 한다고 생각합니다. 즉 한개의 Reducer에서 최소 한가지 이상의 Store을 선언하고 이를 뷰와 연결해 줘야 합니다.
자식 Reducer를 생성하기
TCA에서 두가지 뷰를 보여준다는 것은 자식 Reducer를 부모와 연결하는 것으로 볼 수 있습니다. 자식 뷰를 부모 뷰에 연결하기 위해서
우리는 자식 Reducer를 생성해야 합니다. 자식 Redcuer 를생성하기 위해서 자식 Reducer에 대한 State, Action을 부모 Reducer에서 정의합니다.
@Reducer
struct SharedState {
enum Tab { case counter, profile }
@ObservableState
struct State: Equatable {
var currentTab = Tab.counter
var counter = CounterTab.State() // 생성하기
var profile = ProfileTab.State() // 생성하기
}
enum Action: Equatable {
case counter(CounterTab.Action)
case profile(ProfileTab.Action)
case selectTab(Tab)
}
}
그리고 자식 Action, State을 Body에서 사용합니다. Body에서 Scope을 통해 자식 Reducer을 생성 해줍니다.
var body: some Reducer<State, Action> {
Scope(state: \.counter, action: \.counter) {
CounterTab()
}
Scope(state: \.profile, action: \.profile) {
ProfileTab()
}
// Some Reduce Code ...
}
그 다음 뷰에 Reduce를 필요로하는 View를 생성하고 stroeOf의 생성자를 원활하게 채울 수 있습니다.
struct SharedStateView: View {
@Bindable var store: StoreOf<SharedState>
var body: some View {
TabView(selection: $store.currentTab.sending(\.selectTab)) {
NavigationStack {
CounterTabView(
// View의 init의 매개변수인 store을 scope을 통해 불러옵니다.
store: self.store.scope(state: \.counter, action: \.counter)
)
}.tag(SharedState.Tab.counter)
NavigationStack {
ProfileTabView(
// View의 init의 매개변수인 store을 scope을 통해 불러옵니다.
store: self.store.scope(state: \.profile, action: \.profile)
)
}.tag(SharedState.Tab.profile)
}
}
}
onChange
자식 View에서 일어나는 상태 변화에 대해서 알아야할 경우가 있습니다. 그럴 때는 State가 어떻게 변하는지에 따라서 부모 뷰가 자식 뷰에게 이벤트나 proprerty 혹은 method를 변경시켜줄 수 있습니다.
var body: some Reducer<State, Action> {
Scope(state: \.counter, action: \.counter) {
CounterTab()
}
.onChange(of: \.counter.stats) { oldValue, newValue in
Reduce { state, action in
state.profile.stats = newValue
return .none
}
}
}
위와 관련한 설명: 변하면, 새로운 버전의 클로저가 호출되고 그래서 캡쳐된 값들은 옵저브된 값들이 새 값을 가진 시점에 값을 갖게 됩니다.
onChange말고 ReferenceType을 쓰면 안될까?
안됩니다. 이부분에 대해서 정확하게 쓰지말라는 TCA커뮤니티에 비슷한 글이 있는데, 쓰지 말라는 말을 들었습니다.
https://github.com/pointfreeco/swift-composable-architecture/discussions/2811
How to store reference type entities in a Reducer · pointfreeco swift-composable-architecture · Discussion #2811
Hey everyone! I started a new project ~month ago, and it's my first large one with TCA. It builds with SwiftUI and TCA 1.6.0. I have a question about reference types: how to store them in reducer's...
github.com
서두의 두문장은 다음과 같습니다.
짧은 대답은 참조 타입을 상태*에 저장하지 않는다는 것입니다. 멀리서도 변경할 수 있는 참조 유형은 수신 동작에 대한 응답으로만 상태를 변경할 수 있도록 규정하는 TCA와 같은 단방향 아키텍처에 적합하지 않습니다.
이부분에 대해서 ReferenceType을 위해서 TCA SharedState의 대한 Disscution이 올라와 있는걸 보면, 차후 TCA가 업데이트이후 추가되지 않을까 싶습니다.
동작 화면
게시물은 TCA 라이브러리의 CaseStudies를 직접 작성해보고 어떻게 작동하는지에 대해서 공부하기 위해서 작성했습니다.
'Swift > swiftUI' 카테고리의 다른 글
[SwiftUI] ScrollView + AsyncImage + LazyLayout 트러블 슈팅 (0) | 2025.03.18 |
---|---|
[SwiftUI] TaskModifier (0) | 2024.05.06 |