안녕하세요. 이번 시간은 Navigation Tutorial의 첫번째 장인 Your first presentation을 알아볼 예정입니다.
Composable Architecture는 부모 피처에서 자식 피처를 제공하는데 도움을 주는 여러 기능들을 제공합니다. optional 상태에서 벗어산 기능을 제공하는법을 먼저 알아봅시다.
바로 시작하시죠!
연락처 목록을 보여주고 상단의 +버튼을 통해 신규 연락처를 추가할 수 있는 Contacts 앱을 만든다고 가정해봅시다.
import Foundation
import ComposableArchitecture
struct Contact: Equatable, Identifiable {
let id: UUID
var name: String
}
@Reducer
struct ContactsFeature {
@ObservableState
struct State: Equatable {
var contacts: IdentifiedArrayOf<Contact> = []
}
enum Action {
case addButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .addButtonTapped:
// TODO: Handle action
return .none
}
}
}
}
struct ContactsView: View {
let store: StoreOf<ContactsFeature>
var body: some View {
NavigationStack {
List {
ForEach(store.contacts) { contact in
Text(contact.name)
}
}
.navigationTitle("Contacts")
.toolbar {
ToolbarItem {
Button {
store.send(.addButtonTapped)
} label: {
Image(systemName: "plus")
}
}
}
}
}
}
현재 상태는 Reducer를 만들고 뷰에 연결해준 상태입니다. 별도의 Action 작업이 구성되지는 않았습니다.
import ComposableArchitecture
@Reducer
struct AddContactFeature {
@ObservableState
struct State: Equatable {
var contact: Contact
}
enum Action {
case cancelButtonTapped
case saveButtonTapped
case setName(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .cancelButtonTapped:
return .none
case .saveButtonTapped:
return .none
case let .setName(name):
state.contact.name = name
return .none
}
}
}
}
import SwiftUI
struct AddContactView: View {
@Bindable var store: StoreOf<AddContactFeature>
var body: some View {
Form {
TextField("Name", text: $store.contact.name.sending(\.setName))
Button("Save") {
store.send(.saveButtonTapped)
}
}
.toolbar {
ToolbarItem {
Button("Cancel") {
store.send(.cancelButtonTapped)
}
}
}
}
}
위의 코드를 보면 store가 Bindable로 연결되어있습니다. TextField에서 State 변화가 가능하기에 Bindable을 선언합니다. 관련된 사항은 bindings 공식문서를 참고하시면 됩니다.
우리는 2개의 Reducer와 View를 만들었습니다. 이번 시간에는 연락처 목록 화면에서 연락처 추가 화면으로 이동하는 방법을 배워볼 예정입니다. Presents와 ifLet을 사용하여 Reducer를 통합해봅시다!
ContactsFeature로 돌아가서 State와 Action에 새로운 코드를 넣어줍니다.
@Reducer
struct ContactsFeature {
@ObservableState
struct State: Equatable {
// Presents 선언
@Presents var addContact: AddContactFeature.State?
var contacts: IdentifiedArrayOf<Contact> = []
}
enum Action {
case addButtonTapped
// PresentationAction을 통해 선언(이를 통해 부모는 자식 피처에 전송된 모든 작업 관리 가능)
case addContact(PresentationAction<AddContactFeature.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .addButtonTapped:
// TODO: Handle action
return .none
case .addContact:
return .none
}
}
// ifLet을 통해 선언
.ifLet(\.$addContact, action: \.addContact) {
AddContactFeature()
}
}
}
ifLet을 통해 자식 action이 시스템에 들어올 때 자식 reducer를 실행하고 모든 action에 대해 부모 reducer가 실행하는 새 reducer가 생성됩니다. 또한 가능 feasture가 해제될때 자동으로 해제됩니다.
우리는 Presents, PresentationAction, ifLet을 통해 두 뷰를 통합하였습니다.
case .addButtonTapped:
state.addContact = AddContactFeature.State(
contact: Contact(id: UUID(), name: "")
)
return .none
// 연락처 추가 기능 내에서 취소 버튼을 눌렀을 때 액신(presented 사용)
case .addContact(.presented(.cancelButtonTapped)):
state.addContact = nil
return .none
case .addContact(.presented(.saveButtonTapped)):
guard let contact = state.addContact?.contact
else { return .none }
state.contacts.append(contact)
state.addContact = nil
return .none
위처럼 case별 action을 정의해줍니다.
이제 두 기능의 도메인과 리듀서를 통합했으니 뷰를 통합해야합니다.
ContactsView.swift 파일로 돌아갑시다.
struct ContactsView: View {
@Bindable var store: StoreOf<ContactsFeature>
var body: some View {
NavigationStack {
List {
ForEach(store.contacts) { contact in
Text(contact.name)
}
}
.navigationTitle("Contacts")
.toolbar {
ToolbarItem {
Button {
store.send(.addButtonTapped)
} label: {
Image(systemName: "plus")
}
}
}
}
.sheet(
item: $store.scope(state: \.addContact, action: \.addContact)
) { addContactStore in
NavigationStack {
AddContactView(store: addContactStore)
}
}
}
}
@Bindable 속성 래퍼를 사용하여 스토어에 대한 바인딩을 도출하고, 스토어는 연락처 추가 기능의 프레젠테이션 도메인까지만 범위를 정하고 sheet view modifier에게 전달할 수 있습니다. 연락처 추가 상태가 non-nil이 되면 연락처 추가 기능 도메인에만 초점을 맞춘 새 스토어가 도출되며, 이 도메인은 연락처 추가 보기에 전달할 수 있습니다.
이전 섹션에서는 부모 reducer가 자녀의 action을 검사하여 "저장" 및 "취소" 버튼을 탭한 시기를 결정할 수 있도록 하여 자녀-부모 의사소통을 용이하게 했습니다. 이는 부모가 자녀 기능에서 어떤 일이 발생했을 때 어떤 논리를 수행해야 하는지에 대한 가정을 하게 될 수 있으므로 이상적이지 않습니다.
더 나은 패턴은 자녀 기능에 대해 소위 "delegate action"을 사용하여 부모에게 원하는 작업을 직접 지시하는 것입니다.
AddContactsFeature.swift로 돌아가서 Delegate 열거형을 추가해줍니다.
import ComposableArchitecture
@Reducer
struct AddContactFeature {
@ObservableState
struct State: Equatable {
var contact: Contact
}
enum Action {
case cancelButtonTapped
case delegate(Delegate)
case saveButtonTapped
case setName(String)
enum Delegate: Equatable {
case cancel
case saveContact(Contact)
}
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .cancelButtonTapped:
return .none
case .saveButtonTapped:
return .none
case let .setName(name):
state.contact.name = name
return .none
}
}
}
}
위 Delegate에서는 부모가 수신하고 수행할 수 있는 모든 action을 포함합니다. 즉, 자식 feature가 부모 fature에게 원하는 동작을 전달할 수 있습니다.
Delegate 패턴을 통해 자식 도메인에서 부모 도메인으로 communication할 수도 있지만 Shared를 통해서도 가능합니다. SyncUps Tutorial, Deleting syncup부분에 자세하게 나와있습니다.
case .delegate:
return .none
delegate action을 추가해줍니다. 하지만, reducer의 액션은 실제 작업을 수행해서는 안됩니다. 부모의 delegate에서 실제 작업을 처리할 예정이기 때문입니다.
case .cancelButtonTapped:
return .send(.delegate(.cancel))
case .delegate:
return .none
case .saveButtonTapped:
return .send(.delegate(.saveContact(state.contact)))
위처럼 delegate action을 보내주도록 수정합니다.
ContactsFeature.swift로 돌아가서 reducer를 업데이트하여 연락처를 닫거나 저장할 시점을 파악하고 delegate 동작을 수신하도록 수정합니다.
case .addContact(.presented(.delegate(.cancel))):
state.addContact = nil
return .none
case let .addContact(.presented(.delegate(.saveContact(contact)))):
// guard let contact = state.addContact?.contact
// else { return .none }
state.contacts.append(contact)
state.addContact = nil
return .none
위와같이 Delegate 패턴을 이용하면 자식 피처는 상위 피처에서 하기를 원하는 작업을 설명할 수 있습니다. 하지만 여기에도 개선점이 하나 더 있는데 자식 피처 스스로 cancel을 탭할때와 같은 간단한 동작도 일일히 부모에게 전달하려는건 매우 번거롭습니다.
TCA에서는 이러한 동작을 라이브러리로 정의해주었는데 Dismiss가 그러한 경우중의 하나입니다.
@Dependency(\.dismiss) var dismiss
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .cancelButtonTapped:
return .run { _ in await self.dismiss() }
case .delegate:
return .none
case .saveButtonTapped:
return .run { [contact = state.contact] send in
await send(.delegate(.saveContact(contact)))
await self.dismiss()
}
case let .setName(name):
state.contact.name = name
return .none
}
}
}
이를 통해 Delegate의 cancel은 사용하지 않습니다.
enum Delegate {
// case cancel
case saveContact(Contact)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .addButtonTapped:
state.addContact = AddContactFeature.State(
contact: Contact(id: UUID(), name: "")
)
return .none
// case .addContact(.presented(.delegate(.cancel))):
// state.addContact = nil
// return .none
case let .addContact(.presented(.delegate(.saveContact(contact)))):
state.contacts.append(contact)
// state.addContact = nil
return .none
case .addContact:
return .none
}
}
.ifLet(\.$addContact, action: \.addContact) {
AddContactFeature()
}
위처럼 수정합니다.
우리는 이번 챕터에서 Reducer, View 간의 communication을 가능하게 하는 방법을 배웠습니다. Presents, PresentationAction, ifLet이 그러한 예시입니다. 또한 Delegate Pattern을 이용하여 하위뷰에서 상위뷰에 특정 action을 전달하고 처리하는 방법을 배웠습니다.
고생하셨습니다!