SwiftUI TCA
์žฅ์  - State ์˜ ๋ณ€๊ฒฝ -> UI์— ์ฆ‰์‹œ ๋ฐ˜์˜
- ์ฝ”๋“œ ์ž‘์„ฑ์ด ๊ฐ„๋‹จํ•จ
- State ๋ณ€๊ฒฝ ๋กœ์ง ๊ด€๋ฆฌ ์šฉ์ด
- ๋ณต์žกํ•œ State์™€ ์ด์— ๋”ฐ๋ฅธ Side Effect ์ฒ˜๋ฆฌ ์šฉ์ด
๋‹จ์  - State ๊ด€๋ฆฌ๊ฐ€ ๋ณต์žกํ•ด ์งˆ์ˆ˜๋ก State ๋ณ€ํ™”์— ๋”ฐ๋ฅธ side effect๋ฅผ ๊ด€๋ฆฌํ•˜๊ธฐ ์–ด๋ ค์›€ - ์–ด๋ ค์šด ๊ตฌํ˜„ ๋‚œ์ด๋„ ๐ŸฅŠ ๐Ÿ˜ซ

 

 

TCA Binding

  public func binding<Value>(
    get: @escaping (_ state: ViewState) -> Value,
    send valueToAction: @escaping (_ value: Value) -> ViewAction
  ) -> Binding<Value> {
    ObservedObject(wrappedValue: self)
      .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)]
  }
  • get : State๋ฅผ ๋ฐ”์ธ๋”ฉ์˜ ๊ฐ’์œผ๋กœ ๋ณ€ํ™˜ํ•˜๋„๋ก ํ•˜๋Š” ํด๋กœ์ €
  • send : ๋ฐ”์ธ๋”ฉ์˜ ๊ฐ’์„ ๋‹ค์‹œ Store์— ํ”ผ๋“œ๋ฐฑ ํ•˜๋Š” Action์œผ๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ํด๋กœ์ €

SwiftUI์—์„œ ChildView์—๊ฒŒ Binding<Value>๋ฅผ ์ „๋‹ฌํ•  ๋•Œ๊ฐ€ ์žˆ๋Š”๋ฐ ์œ„ binding<Value>๋Š” ๊ทธ ๋•Œ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ

Text("์ฟ ํฐ")
.padding(.leading, 10)
VStack {
    TextField("ํ”„๋กœ๋ชจ์…˜ ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.", text: .constant(""))
}
.padding()
.border(.black, width: 1)
.padding(.horizontal, 10)

(text์— Binding<String>์œผ๋กœ ์ƒ์œ„ State๋ฅผ ์ „๋‹ฌํ•˜๊ฒŒ ๋˜๋Š”๋ฐ ์ด๋•Œ์˜ State๋Š” ์–ด๋–ป๊ฒŒ ๊ด€๋ฆฌํ•  ๊ฒƒ์ธ๊ฐ€์— ๊ด€ํ•œ๊ฒƒ)

 

binding(get:send:) ์‚ฌ์šฉ ํ•˜๋Š” ๋ฒ•

    struct State: Equatable {
        var couponCode = ""
        // ์ƒ๋žต
    }

State์— ํ…์ŠคํŠธํ•„๋“œ์— ๋ฐ”์ธ๋”ฉ ํ•  ํ”„๋กœํผํ‹ฐ๋ฅผ ์ •์˜

 

    enum Action: Equatable {
        case couponCodeInserted(String)
    }

View์—์„œ ํ…์ŠคํŠธ ํ•„๋“œ๋ฅผ ํ†ตํ•ด ํ…์ŠคํŠธ๋ฅผ ์ž…๋ ฅํ•˜๋ฉด Store๊ฐ€ ์•„๋‹Œ Store ์™ธ๋ถ€์— ์žˆ๋Š” TextField ๋‚ด๋ถ€์—์„œ State๋ฅผ ์กฐ์ •ํ•˜๊ฒŒ ๋จ

์ด๋•Œ์˜ Action์„ ์ •์˜

 

        Reduce { state, action in
            switch action {
            case .couponCodeInserted(let code):
                state.couponCode = code
                return .none
            }
        }

reducer์—์„œ Action์— ๋Œ€ํ•œ ๊ฒฐ๊ณผ๋กœ State์˜ ๊ฐ’์„ ๋ณ€๊ฒฝ

 

VStack {
    TextField("ํ”„๋กœ๋ชจ์…˜ ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.", 
    text: viewStore.binding(
        get: \.couponCode,
        send: { .couponCodeInserted($0) }
    ))}
.padding()
.border(.black, width: 1)
.padding(.horizontal, 10)
  • TextField์˜ Binding<String>์œผ๋กœ ๋„˜๊ฒจ์ค€๋‹ค. ์ด๋•Œ Store์˜ binding(get:send:)๋ฅผ ์‚ฌ์šฉ
  • viewStore.binding(get:send:)์—์„œ get์— ์ „๋‹ฌ๋œ ๊ฐ์ฒด ๋ฐ”์ธ๋”ฉ ํ•˜์—ฌ text์— ์ „๋‹ฌ, send์— ์ „๋‹ฌ๋œ Action์„ Store์— ๋‹ค์‹œ ํ”ผ๋“œ๋ฐฑ, ์ดํ›„ ํ•ด๋‹น Action์— ํ•ด๋‹นํ•˜๋Š” reducer ๋กœ์ง์ด ์‹คํ–‰
  • get์œผ๋กœ State, send๋กœ Action
  • ์ด๋ฒคํŠธ๋ฅผ ๋‹จ๋ฐฉํ–ฅ ํ†ต์‹ ์œผ๋กœ ๋ฐ”๊พธ๊ณ  State์™€ ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋จ

binding(get:send)์˜ ๋‹จ์ 

  • ๊ฐ ์„œ๋ธŒ ๋ทฐ์—์„œ State๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ๋ฐฉ์‹์ด ์•„๋‹ˆ๊ธฐ ๋•Œ๋ฌธ์— ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ๋” ํฌ๊ณ  ๋ณต์žกํ•ด ์งˆ ์ˆ˜๋ก ๋งŽ์€ ๋ฐ”์ธ๋”ฉ ์ฝ”๋“œ๊ฐ€ ์ž‘์„ฑ๋˜๋ฉฐ reducer์˜ ๊ด€๋ฆฌ๊ฐ€ ๋ณต์žกํ•ด ์ง. 
  • ๊ฐ€๋…์„ฑ์ด ๋‚˜๋น ์ง€๊ณ  ๋ฐ˜๋ณต๋˜๋Š” ์ฝ”๋“œ ์ž‘์—…์ด ํ•„์š”ํ•˜๊ฒŒ ๋จ

 

@BindingState

  • @BindingState๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด SwiftUI์˜ UI ์ปจํŠธ๋กค์— ๋ฐ”์ธ๋”ฉ ๊ฐ€๋Šฅ
  • View์—์„œ ํ•ด๋‹น ํ•„๋“œ ๊ฐ’ ์กฐ์ • ๊ฐ€๋Šฅ
  •  View์—์„œ ํ•ด๋‹น ํ•„๋“œ ๊ฐ’์„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†๋„๋ก ํ•˜๋ ค๋ฉด @BindingState๋ฅผ ๋ถ™์ด์ง€ ์•Š์•„์•ผ ํ•จ
    • SwiftUI์—์„œ Binding์€ State๋ฅผ ๋‹ค๋ฅธ ๋ทฐ์™€ ๊ณต์œ ํ•˜๋Š” ๊ฐœ๋…์ด๊ธฐ ๋•Œ๋ฌธ์— Source of Truth ๊ด€๋ฆฌ๊ฐ€ ์–ด๋ ค์›Œ์ง„๋‹ค. ๋”ฐ๋ผ์„œ ๋ชจ๋“  ํ•„๋“œ์— @BindingState๋ฅผ ์‚ฌ์šฉํ•˜๋Š”๊ฑด ๊ถŒ์žฅ๋˜์ง€ ์•Š์Œ (์บก์Šํ™” ์†์ƒ)
    • ์ปดํฌ๋„ŒํŠธ์— ์ „๋‹ฌํ•˜๊ธฐ ์œ„ํ•œ ํ•„๋“œ์—๋งŒ ์‚ฌ์šฉ
    struct State: Equatable {
        @BindingState var isToggled = false
    }

 

BindableAction protocol

    enum Action: BindableAction, Equatable {
        case binding(BindingAction<State>)
    }

 

  • Action enum์— BindableAction ํ”„๋กœํ† ์ฝœ ์ฑ„ํƒ
  • BindingState๋ฅผ ์‚ฌ์šฉํ•œ ํ•„๋“œ๋Š” ์ด ํ•˜๋‚˜์˜ Action์— ์—ฐ๊ฒฐ๋จ 
struct BindingAction<Root>: CasePathable, Equatable, @unchecked Sendable
  • ํ•˜๋‚˜์˜ Action case๊ฐ€ ์—ฌ๋Ÿฌ BindingState์— ๋Œ€์‘ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๊ธฐ ์œ„ํ•ด ์ œ๋„ค๋ฆญ ํƒ€์ž…์„ ๊ฐ€์ง
  • ์ œ๋„ค๋ฆญ ํƒ€์ž…์€ reducer์˜ State
associatedtype State
  • bindable ๋‚ด์—๋Š” State ํƒ€์ž…์„ ์ œ๋„ค๋ฆญ์œผ๋กœ ๋ฐ›๊ธฐ ์œ„ํ•ด associatedtype์œผ๋กœ State๊ฐ€ ์ •์˜๋˜์–ด ์žˆ์Œ(๋ฐ”์ธ๋”ฉ์„ ์œ„ํ•œ ์ƒํƒœ ๊ฐ’์˜ ํƒ€์ž…์€ ์—ฌ๋Ÿฌ๊ฐ€์ง€๊ฐ€ ์˜ฌ ์ˆ˜ ์žˆ์Œ)

 

BindingReducer

    var body: some ReducerOf<Self> {
        BindingReducer()
  • reducer์—๋Š” BindingReducer๋ฅผ ์‚ฌ์šฉํ•ด์„œ State ๋ณ€๊ฒฝ์„ ๊ฐ„๋‹จํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ์Œ
  • Binding action์ด ์ˆ˜์‹ ๋˜๋ฉด State๋ฅผ ์—…๋ฐ์ดํŠธ ํ•ด์ฃผ๋Š” reducer
  • Store ๋‚ด๋ถ€ ๋ฐ”์ธ๋”ฉ ๊ฐ€๋Šฅํ•œ State(์˜ˆ์‹œ ์ฝ”๋“œ์—์„œ๋Š”isToggled) ํ•„๋“œ๊ฐ€ ์—…๋ฐ์ดํŠธ ๋˜๋ฉด BindingReducer()๊ฐ€ ์—…๋ฐ์ดํŠธ ๋œ ํ•„๋“œ ๊ฐ’๊ณผ ํ•จ๊ป˜ Action ์ˆ˜์‹  ํ›„ Reducer ํด๋กœ์ € ๋‚ด์— ๋„๋ฉ”์ธ ๋กœ์ง ์ฒ˜๋ฆฌํ•˜์—ฌ State์— ๊ฒฐ๊ณผ ๋ฐ˜์˜
  • State์™€ Action ์‚ฌ์ด๋ฅผ ๋ฐ”์ธ๋”ฉ ํ•˜๋Š” ์—ญํ™œ
Reduce { state, action in
  • action์ด BindableAction์„ ๋”ฐ๋ฅด๊ฒŒ ๋จ
static func binding(_ action: BindingAction<State>) -> Self
  • BindableAction์˜ ์œ„ ๋ฉ”์„œ๋“œ๋Š” BindingAction์„ ๋ฐ˜ํ™˜ 
  • BindingState๋กœ ์ •์˜ํ•œ State๋ฅผ ์ง€์ •ํ•ด์ฃผ๋ฉด ๋จ
            case .binding(\.$isToggled):
                return .none
            case .binding(_):
                return .none
  • Effect๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” Reduce ํด๋กœ์ €์—๋Š” case .bindg(_) ์—์„œ ์ •์˜ํ•ด ์ฃผ๋ฉฐ ํ‚คํŒจ์Šค๋ฅผ ์‚ฌ์šฉํ•ด์„œ ์ ‘๊ทผ
  • .binding(_) ๋„ ์ ์–ด์ฃผ์–ด์•ผ ํ•จ

BindingState ํ”„๋กœํผํ‹ฐ ๋ž˜ํผ์˜ ์‚ฌ์šฉ

Toggle("test ์ž…๋‹ˆ๋‹ค.", isOn: viewStore.$isToggled)

 

._printChanges()

received action:
  ProductDetailFeature.Action.amountControl(.plusButtonTapped)
  ProductDetailFeature.State(
-   productAmount: 1,
+   productAmount: 2,
-   amountControl: AmountControl.State(amount: 1)
+   amountControl: AmountControl.State(amount: 2)
    _isToggled: true
  )
  • ๋ฐ”์ธ๋”ฉ ํ”„๋กœํผํ‹ฐ๋Š” ์–ธ๋”๋ฐ”๋กœ ํ‘œ์‹œ๋จ

View State Binding

  • Store ์™ธ๋ถ€์— ์žˆ๋Š” View State๋ฅผ ๋ฐ”์ธ๋”ฉํ•˜๊ธฐ ์œ„ํ•ด์„œ BindingViewState ํ”„๋กœํผํ‹ฐ ๋ž˜ํผ๋ฅผ ์‚ฌ์šฉ

 

struct CartButton: View { }

 

  • Child View ํ˜น์€ Sub View๋ฅผ ๋งŒ๋“ค๋ฉด Binding์„ ํ†ตํ•ด์„œ ๊ฐ’์„ ๊ณต์œ ํ•ด์•ผ ํ•˜๋Š” ์ƒํ™ฉ์ด ๋ฐœ์ƒํ•˜๊ฒŒ ๋˜๋Š”๋ฐ ์ด๋•Œ BindingViewState๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Œ
let store: StoreOf<ProductDetailFeature>
  • ํ•˜์œ„๋ทฐ์— ์ƒ์œ„ ์Šคํ† ์–ด๋ฅผ ์ •์˜ํ•ด ์ฃผ๊ณ  init ์‹œ์ ์— ์ „๋‹ฌ ๋ฐ›์Œ
    struct ViewState: Equatable {
        @BindingViewState var productAmount: Int
    }
  • ViewState๋ฅผ ๊ตฌ์กฐ์ฒด๋ฅผ ๋งŒ๋“ค๊ณ  ๋ฐ”์ธ๋”ฉ์ด ํ•„์š”ํ•œ ๊ฐ’์„ ์ •์˜ ํ•ด์คŒ
    var body: some View {
        WithViewStore(store, observe: { bindingViewStore in
            ViewState(productAmount: bindingViewStore.$productAmount)
        }) { viewStore in
            ZStack(alignment: .bottomLeading) {
                Image(systemName: "cart")
                    .foregroundStyle(.green)
                    .font(.system(size: 30))
                
                if viewStore.productAmount > 0 {
                    Circle()
                        .frame(width: 20, height: 20)
                        .foregroundStyle(.red)
                        .overlay(
                            Text("\(viewStore.productAmount)")
                                .foregroundStyle(.white)
                                .fontWeight(.heavy)
                        )
                        .offset(x: -5, y: 8)
                }
            }
            .frame(width: 50, height: 50)
        }
    }
  • ์ƒ์œ„ State์—์„œ ์ „๋‹ฌ ๋ฐ›์€ ๊ฐ’ ์ค‘์— ํ•˜์œ„ ๋ทฐ์—์„œ๋งŒ ์‚ฌ์šฉํ•  ๊ฐ’์„ ์ง€์ •ํ•˜๊ธฐ ์œ„ํ•ด์„œ WithViewStore์˜ observe ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ง€์ •ํ•ด ์คŒ
  • BindingViewStore์„ ์‚ฌ์šฉํ•ด์„œ ์ƒ์œ„ ๋ทฐ์˜ ๋ชจ๋“  ๊ฐ’์— ์ ‘๊ทผํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹Œ ํ•„์š”ํ•œ ๊ฐ’๋งŒ ๊ณต์œ ํ•  ์ˆ˜ ์žˆ์Œ
  • ViewState์—์„œ BindingViewState๋กœ ์ง€์ •ํ•œ ๊ฐ’๊ณผ ์ƒ์œ„ ViewStore๋กœ ๋ถ€ํ„ฐ ์ „๋‹ฌ ๋ฐ›์€ State๋ฅผ ๋ฐ”์ธ๋”ฉ ํ•ด์คŒ
CartButton(store: self.store)
  • ์ƒ์œ„๋ทฐ์—์„œ ํ•˜์œ„๋ทฐ๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ๋Š” store๋ฅผ ์ „๋‹ฌํ•ด ์ฃผ๋ฉด ๋œ๋‹ค.

 

Pixabay ๋กœ๋ถ€ํ„ฐ ์ž…์ˆ˜๋œ&nbsp; Tim Mossholder ๋‹˜์˜ ์ด๋ฏธ์ง€ ์ž…๋‹ˆ๋‹ค.