Swift

TCA - Binding

elisha0103 2023. 8. 17. 17:01

개요

SwiftUI의 바인딩 타입은 애플리케이션의 서로 다른 부분 간의 통신을 용이하게 합니다. 따라서 SwiftUI에서 가장 중요한 유형 중 하나입니다. 이를 통해서 애플리케이션 중 일부의 변경사항을 즉시 다른 부분에 반역할 수 있고, @ObservedObject, @State 프로퍼티 래퍼 또는 environment 값을 사용하여 궁극적으로 바인딩 하도록 돕습니다.

SwiftUI에서의 바인딩은 프로퍼티 래퍼로 양방향 바인딩을 사용하여 상태를 처리합니다. TCA의 가장 큰 특징은 단방향 데이터 흐름을 채택하는 것인데, TCA의 Store는 SwiftUI의 UI 컨트롤과 양방향 바인딩을 이루어야고, Store에 있는 State와 Action은 단방향 데이터 흐름을 갖도록하는 해야합니다. 이러한 것을 가능토록 하는 TCA의 바인딩 개념을 확인하겠습니다.

TCA의 가장 기본적인 바인딩은 Binding(get:send:) 형태입니다.

두 개의 클로저를 매개변수로 전달받습니다.

  • get: State를 바인딩의 값으로 변환하도록 하는 클로저
  • send: 바인딩의 값을 다시 Store에 피드백 하는 Action으로 변환하는 클로저

 

기본 Binding

예를 들어, reducer에는 사용자가 햅틱 피드백을 활성화 했는지 추적하는 도메인 기능이 있다고 가정해봅시다.

State에는 햅틱 피드백에 대한 Bool 프로퍼티를 정의할 수 있습니다.

struct Settings: Reducer {
  struct State: Equatable {
    var isHapticFeedbackEnabled = true
    // ...
  }

  // ...
}

 

만약 View에서 토글을 사용하여 Store 외부에서 이 State를 조정할 수 있다면, Action을 정의할 수 있습니다.

struct Settings: Reducer {
  struct State: Equatable { /* ... */ }

  enum Action { 
    case isHapticFeedbackEnabledChanged(Bool)
    // ...
  }

  // ...
}

 

이제 reducer에서 해당 Action을 조작할 수 있도록 해봅시다.

그럼 reducer에서 Action에 대한 결과로 State의 값이 변경될 것입니다.

struct Settings: Reducer {
  struct State: Equatable { /* ... */ }
  enum Action { /* ... */ }
  
  func reduce(
    into state: inout State, action: Action
  ) -> Effect<Action> {
    switch action {
    case let .isHapticFeedbackEnabledChanged(isEnabled):
      state.isHapticFeedbackEnabled = isEnabled
      return .none

    // ...
    }
  }
}

 

이로서 View에서 토글이 TCA 기능과 통신하도록하는 바인딩을 호출할 수 있습니다.

struct SettingsView: View {
  let store: StoreOf<Settings>
  
  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { viewStore in
      Form {
        Toggle(
          "Haptic feedback",
          isOn: viewStore.binding(
            get: \\.isHapticFeedbackEnabled,
            send: { .isHapticFeedbackEnabledChanged($0) }
          )
        )

        // ...
      }
    }
  }
}

Toggle 에서 isOn에는 바인딩 객체를 전달해야합니다. viewStore.binding(get:send:)에서 get에 전달된 객체를 바인딩해서 isOn에 전달해주고, send에 전달된 Action을 Store에 다시 피드백 해줌으로써 해당 Action에 맞는 reducer 로직이 실행되어 State의 값이 변경됩니다.

정리하면, binding의 get을 통해 SwiftUI의 UI 컨트롤과 바인딩 통신을 하고, send를 통해 Store 내부에 바인딩된 값을 전달함으로써 비즈니스 로직이 수행되도록 합니다.

가장 기본적인 Binding(get:send)를 사용하려면 위와 같이 get과 send에 직접 State와 Action을 정의하여 전달해야하는 작업이 동반됩니다. 만약 View에 Action이 더 늘어난다면, View에는 더 많은 바인딩 코드가 작성되어야합니다. 이 것은 코드가 길어지며 가독성이 나빠질 수 있습니다. 따라서 TCA는 reducer와 View에 이러한 불필요한 작업이 반복되지 않도록 하고 간단하게 바인딩 작업을 할 수 있는 기능을 제공합니다.

 

개선된 Binding

Settings의 State를 더 작성해보겠습니다.

struct Settings: Reducer {
  struct State: Equatable {
    var digest = Digest.daily
    var displayName = ""
    var enableNotifications = false
    var isLoading = false
    var protectMyPosts = false
    var sendEmailNotifications = false
    var sendMobileNotifications = false
  }

  // ...
}

 

State의 각 프로퍼티는 대부분 View에서 편집할 수 있어야 합니다. 편집한다는 것은 State의 각 프로퍼티가 Store에 보낼 수 있는 Action이 필요하다는 뜻입니다. 따라서 각 프로퍼티를 조작하는 Action을 열거형의 각 case로 나타내겠습니다.

struct Settings: Reducer {
  struct State: Equatable { /* ... */ }

  enum Action {
    case digestChanged(Digest)
    case displayNameChanged(String)
    case enableNotificationsChanged(Bool)
    case protectMyPostsChanged(Bool)
    case sendEmailNotificationsChanged(Bool)
    case sendMobileNotificationsChanged(Bool)
  }

  // ...
}

 

이제 case로 선언된 각 Action에 대한 비즈니스 로직을 reducer에 작성합니다.

struct Settings: Reducer {
  struct State: Equatable { /* ... */ }
  enum Action { /* ... */ }

  func reduce(
    into state: inout State, action: Action
  ) -> Effect<Action> {
    switch action {
    case let digestChanged(digest):
      state.digest = digest
      return .none

    case let displayNameChanged(displayName):
      state.displayName = displayName
      return .none

    case let enableNotificationsChanged(isOn):
      state.enableNotifications = isOn
      return .none

    case let protectMyPostsChanged(isOn):
      state.protectMyPosts = isOn
      return .none

    case let sendEmailNotificationsChanged(isOn):
      state.sendEmailNotifications = isOn
      return .none

    case let sendMobileNotificationsChanged(isOn):
      state.sendMobileNotifications = isOn
      return .none
    }
  }
}

딱 봐도 알듯이 비슷한 코드가 너무 많이 작성됩니다. 앞서 설명한 것처럼 State이 늘어나면 Action과 reducer의 코드가 복잡해지고 불필요한 바인딩 코드가 많아져서 별로 좋지 않은 코드의 양상이 나타납니다.

 

우리는 이러한 문제를 BindingState, BindingAction, BindingReducer 프로퍼티 래퍼를 사용하여 개선하겠습니다.

struct Settings: Reducer {
  struct State: Equatable {
    @BindingState var digest = Digest.daily
    @BindingState var displayName = ""
    @BindingState var enableNotifications = false
    var isLoading = false
    @BindingState var protectMyPosts = false
    @BindingState var sendEmailNotifications = false
    @BindingState var sendMobileNotifications = false
  }

  // ...
}

State 구조체 필드에 프로퍼티 래퍼가 추가되면서 이제 해당 필드들은 SwiftUI의 UI 컨트롤에 바인딩이 가능하여 View에서 해당 필드 값을 조정할 수 있습니다.

isLoading 은 프로퍼티 래퍼를 추가하지 않으므로 View에서 해당 필드 값을 변경할 수 없도록 합니다.

 

다음으로 State의 모든 필드 Action을 하나의 case로 축소합니다. 이 때 축소된 Action case는 제네릭 타입을 갖는 BindingAction을 associatedValue로 보유합니다. 제네릭 타입은 reducer의 State로 결정됩니다.

struct Settings: Reducer {
  struct State: Equatable { /* ... */ }

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

  // ...
}

 

이제 reducer는 BindingReducer를 사용하여 State을 변경하는 비즈니스 로직을 간단하게 할 수 있습니다.

struct Settings: Reducer {
  struct State: Equatable { /* ... */ }
  enum Action: BindableAction { /* ... */ }

  var body: some Reducer<State, Action> {
    BindingReducer()
  }
}

 

Binding Action은 View에서 다음처럼 호출하여 구성되고 Store에 전송됩니다.

Binding Action을 사용하는 것도 기존 SwiftUI의 Binding을 사용할 때처럼 간단해졌습니다.

TextField("Display name", text: viewStore.$displayName)

 

만약, 기초적인 바인딩 작업 위에 추가 기능을 작성해야하는 경우, reducer는 지정된 Key-Path에 추가 작업에 대한 코드를 작성할 수 있습니다.

var body: some Reducer<State, Action> {
  BindingReducer()

  Reduce { state, action in
    switch action
    case .binding(\\.$displayName):
      // Validate display name
  
    case .binding(\\.$enableNotifications):
      // Return an authorization request effect
  
    // ...
    }
  }
}

 

Test

Binding Action은 일반 Action일 때 테스트되는 것과 비슷한 방식으로 테스트할 수 있습니다. 기존과 다른점은 테스트할 때 .displayNameChanged(”Blob”)와 같이 바인딩된 값을 Store에 전달하는 방법 대신, .set(\\.$displayName, “Blob”)와 같이 특정 Key-Path가 어떤 값으로 설정되어있는지 나타내는 BindingAction을 전달합니다.

let store = TestStore(initialState: Settings.State()) {
  Settings()
}

store.send(.set(\\.$displayName, "Blob")) {
  $0.displayName = "Blob"
}
store.send(.set(\\.$protectMyPosts, true)) {
  $0.protectMyPosts = true
)

 

View State Binding

개별 View마다 View State가 있을 수 있습니다. 해당 View에도 TCA의 Store를 사용한다면 Store의 State와 View State의 일부 필드를 바인딩하여 비즈니스 로직에 사용할 수 있습니다. Store 외부에 있는 View State를 바인딩 하기 위하여 먼저 View State의 필드에는 @BindingState가 아닌, @BindingViewState 프로퍼티 래퍼를 사용합니다.

struct NotificationSettingsView: View {
  let store: StoreOf<Settings>

  struct ViewState: Equatable {
    @BindingViewState var enableNotifications: Bool
    @BindingViewState var sendEmailNotifications: Bool
    @BindingViewState var sendMobileNotifications: Bool
  }

  // ...
}

 

그리고 View Store가 구성되면 init(_:observe:content:file:line:) 이니셜라이저를 호출하고 observe에서 bindingViewStore를 전달받아 ViewState의 필드 값과 바인딩 합니다.

struct NotificationSettingsView: View {
  // ...

  var body: some View {
    WithViewStore(
      self.store,
      observe: { bindingViewStore in
        ViewState(
          enableNotifications: bindingViewStore.$enableNotifications,
          sendEmailNotifications: bindingViewStore.$sendEmailNotifications,
          sendMobileNotifications: bindingViewStore.$sendMobileNotifications
        )
      }
    ) {
      // ...
    }
  }
}

 

ViewState 구조체에 이니셜라이저를 다음처럼 정의한다면 더 간단하게 Store와 ViewState와 바인딩이 가능합니다.

struct NotificationSettingsView: View {
  // ...
  struct ViewState: Equatable {
    // ...

    init(bindingViewStore: BindingViewStore<Settings.State>) {
      self._enableNotifications = bindingViewStore.$enableNotifications
      self._sendEmailNotifications = bindingViewStore.$sendEmailNotifications
      self._sendMobileNotifications = bindingViewStore.$sendMobileNotifications
    }
  }

  var body: some View {
    WithViewStore(self.store, observe: ViewState.init) { viewStore in
      // ...
    }
  }
}

 

UI 컨트롤에서 바인딩 객체를 사용할 때에는 다음처럼 사용할 수 있습니다.

Form {
  Toggle("Enable notifications", isOn: viewStore.$enableNotifications)

  // ...
}

 

 


출처

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/bindings/

'Swift' 카테고리의 다른 글

애플 Text엔진 TextKit  (2) 2024.09.08
TCA - Environment는 어디갔나?  (0) 2023.07.10
TCA(The Composable Architecture)란?  (1) 2023.07.06
Swift 네트워크 추상화 - URL 처리방법  (0) 2023.05.09
객체지향 생활체조  (0) 2023.05.05