[SwiftUI] TCA Tutorial - Navigation stacks

별똥별·2024년 12월 26일

TCA

목록 보기
7/24

안녕하세요. 이번 시간은 드디어 Meet the Composable Architecture 튜토리얼의 마지막 장인 Navigation stacks입니다. 제목에 나와있는 Navigation Stack은 iOS 16부터 신규 제공되능 기능인데요.
Navigation Stack document iOS 15까지의 Navigation은 Navigation View 로 사용하던것과 다른 개념이죠. 이에 대한 설명은 다음에 하도록 하겠습니다!

이번 시간은 위에서 나온 Composable Architecture와 Navigation Stacks의 조합을 알아봅시다!!

Section 1. Contact detail feature.

우선 ContactsView 에서 접근할 수 있는 ContactsDetailView와 ContactsDetailFeature를 만들어야합니다.

  1. ContactsDetailFeature.swift 생성
@Reducer
struct ContactDetailFeature {
  @ObservableState
  struct State: Equatable {
    let contact: Contact
  }
  enum Action {
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      }
    }
  }
}

위와 같이 ContactsDetailFeature.swift 파일을 생성해줍니다.

  • State의 contact는 Mutable하지 않으므로 let으로 선언해줍니다.
  1. ContactsDetailView.swift 생성
import ComposableArchitecture
import SwiftUI


struct ContactDetailView: View {
  let store: StoreOf<ContactDetailFeature>
  
  var body: some View {
    Form {
    }
    .navigationTitle(Text(store.contact.name))
  }
}


#Preview {
  NavigationStack {
    ContactDetailView(
      store: Store(
        initialState: ContactDetailFeature.State(
          contact: Contact(id: UUID(), name: "Blob")
        )
      ) {
        ContactDetailFeature()
      }
    )
  }
}

마찬가지로 ContactsDetailView.swift 파일을 만들어줍니다.

Section 2. Drill-down to contact detail

Section 1에서 세부 연락처 Reducer와 뷰를 만들었으므로 ContactsFeature에서 Detail로 드릴다운 할 수 있게 됩니다.

  1. ContactsFeature.swift로 가서 State에 새로운 필드를 추가합니다.
@ObservableState
  struct State: Equatable {
    var contacts: IdentifiedArrayOf<Contact> = []
    @Presents var destination: Destination.State?
    var path = StackState<ContactDetailFeature.State>()
  }

StackState 는 스택에 푸시되고 있는 기능을 나타냅니다.

  1. 마찬가지로 Action에도 새로운 코드를 추가합니다.
enum Action {
    case addButtonTapped
    case deleteButtonTapped(id: Contact.ID)
    case destination(PresentationAction<Destination.Action>)
    case path(StackAction<ContactDetailFeature.State, ContactDetailFeature.Action>)
    enum Alert: Equatable {
      case confirmDeletion(id: Contact.ID)
    }
  }

StackAction은 스택 내부에서 element를 push하거나 pop하는 등의 작업이나 스택 내부 기능에서 발생할 수 있는 작업을 나타냅니다.

  1. 코드의 바깥쪽에도 새로운 코드를 추가합니다.
    .forEach(\.path, action: \.path) {
      ContactDetailFeature()
    }

ForEach 를 통해 ContactDetailFeature를 ContactFeature의 스택에 통합할 수 있습니다.

  1. ContactsView.swift로 이동해 NavigationStack을 추가해줍니다.
 NavigationStack(path: $store.scope(state: \.path, action: \.path)) {

NavigationStack을 통해 StackState와 StackAction으로 범위가 지정단 store에 바인딩 됩니다.

  1. Navigation Init은 두개의 후행 클로저를 사용합니다. 첫 번째는 스택의 루트에 대한 것이고 두 번째는 destination에 대한 것입니다.
destination: { store in
      ContactDetailView(store: store)
    }
  1. ForEach의 List를 NavigationLink로 감싸줍니다.
        ForEach(store.contacts) { contact in
          NavigationLink(state: ContactDetailFeature.State(contact: contact)) {
            HStack {
              Text(contact.name)
              Spacer()
              Button {
                store.send(.deleteButtonTapped(id: contact.id))
              } label: {
                Image(systemName: "trash")
                  .foregroundColor(.red)
              }
            }
          }
          .buttonStyle(.borderless)
        }
      }

init과 관련된 document

Section 3. Deleting a contact

지금까지 작업을 통해 우리는 ContactsView에 AddContactsView를 연결했지만 뷰 간의 상호 작용은 없습니다. 이번 섹션에서 연락처를 삭제할 수 있는 기능을 추가해 보도록 하겠습니다.

  1. ContactDetailFeature.swift로 이동하여 알림 State를 추가합니다.
  @ObservableState
  struct State: Equatable {
    @Presents var alert: AlertState<Action.Alert>?
    let contact: Contact
  }
  1. Action도 추가해줍니다.
  enum Action {
    case alert(PresentationAction<Alert>)
    case delegate(Delegate)
    case deleteButtonTapped
    enum Alert {
      case confirmDeletion
    }
    enum Delegate {
      case confirmDeletion
    }
  }

사용자가 UI에서 할 수 있는 모든 작업, 예를 들어 "삭제" 버튼 탭하기와 같은 작업뿐만 아니라 알림 내부의 모든 작업과 부모 기능에 연락하여 연락처를 삭제하도록 지시해야 할 때의 위임 작업도 포함됩니다.

알림 및 위임 작업에는 이전에 필요했던 것처럼 ID가 필요하지 않습니다. 그 이유는 곧 알게 될 것입니다.

  1. body를 구현해줍니다.
  @Dependency(\.dismiss) var dismiss
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .alert(.presented(.confirmDeletion)):
        return .run { send in
          await send(.delegate(.confirmDeletion))
          await self.dismiss()
        }
      case .alert:
        return .none
      case .delegate:
        return .none
      case .deleteButtonTapped:
        state.alert = .confirmDeletion
        return .none
      }
    }
    .ifLet(\.$alert, action: \.alert)
  }
}


extension AlertState where Action == ContactDetailFeature.Action.Alert {
  static let confirmDeletion = Self {
    TextState("Are you sure?")
  } actions: {
    ButtonState(role: .destructive, action: .confirmDeletion) {
      TextState("Delete")
    }
  }
}

본문 속성에 새로운 작업을 구현합니다. 이는 여러 프레젠테이션 대상에서 수행한 작업과 매우 유사합니다. 삭제 확인은 위임 작업을 보내고 DismissEffect 종속성을 사용하여 무시하는 방식으로 처리하며, 나중에 테스트하기 쉽도록 자체 도우미에게 경고 상태를 추출했습니다.

  1. ContactsDetailView로 이동해 삭제하는 버튼을 추가합니다.
  var body: some View {
    Form {
      Button("Delete") {
        store.send(.deleteButtonTapped)
      }
    }
    .navigationTitle(Text(store.contact.name))
    .alert($store.scope(state: \.alert, action: \.alert))
  }
  1. ContactsFeature로 가서 path action을 추가합니다.
      case let .path(.element(id: id, action: .delegate(.confirmDeletion))):
        guard let detailState = state.path[id: id]
        else { return .none }
        state.contacts.remove(id: detailState.contact.id)
        return .none

특히 .delegate(.confirmDeletion) 작업이 언제 전송되는지 듣고, 이 경우 배열에서 연락처를 제거하고자 합니다.

이를 마지막으로 우린 간단한 튜토리얼을 모두 완료했습니다.
고생하셨습니다 :)

profile
밍밍

0개의 댓글