[SwiftUI] TCA Tutorial - Multiple presentation destinations

별똥별·2024년 12월 11일

TCA

목록 보기
6/24

안녕하세요. 이번 시간은 Navigation Tutorial의 두번째 장인 Multiple presentation destinations에 대해 알아보겠습니다. 이번 튜토리얼은 통해 여러분들은 부모 피처에서 자식 피처에게 많은 기능을 표현하려면 어떻게 하는지 배울 수 있게 됩니다.

Section 1. Delete contacts

연락처 목록에 연락처를 삭제할 수 있는 기능을 추가하되, 진짜 삭제를 할 것인지 확인해야합니다. 저번 시간에 배웠던 Presents(). PresentationAction() 및 ifLet을 사용하여 alert도 구현할 수 있습니다.

@Reducer
struct ContactsFeature {
  @ObservableState
  struct State: Equatable {
    @Presents var addContact: AddContactFeature.State?
    var contacts: IdentifiedArrayOf<Contact> = []
  }
  enum Action {
    case addButtonTapped
    case addContact(PresentationAction<AddContactFeature.Action>)
    case deleteButtonTapped(id: Contact.ID)
  }
  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 let .addContact(.presented(.delegate(.saveContact(contact)))):
        state.contacts.append(contact)
        return .none
        
      case .addContact:
        return .none
        
      case let .deleteButtonTapped(id: id):
        return .none
      }
    }
    .ifLet(\.$addContact, action: \.addContact) {
      AddContactFeature()
    }
  }
}

지난 챕터에서 작업했던 ContactsFeature.swift로 돌아가서 삭제 action에 대한 새로운 코드를 추가합니다. 지금 상태는 삭제 action은 추가한 상태이지만 삭제를 확인할 alert은 없는 상태입니다. 아래 작업을 통해 Alert도 추가해줍시다.

  @ObservableState
  struct State: Equatable {
    @Presents var addContact: AddContactFeature.State?
    @Presents var alert: AlertState<Action.Alert>?
    var contacts: IdentifiedArrayOf<Contact> = []
  }
  enum Action {
    case addButtonTapped
    case addContact(PresentationAction<AddContactFeature.Action>)
    case alert(PresentationAction<Alert>)
    case deleteButtonTapped(id: Contact.ID)
    enum Alert: Equatable {
      case confirmDeletion(id: Contact.ID)
    }
  }

@Presents를 통해 AlertState를 추가해줍니다. 참고로 AlertState는 Equtable을 준수합니다. 이후 alert 알림창을 구현해봅시다.

      case let .deleteButtonTapped(id: id):
        state.alert = AlertState {
          TextState("Are you sure?")
        } actions: {
          ButtonState(role: .destructive, action: .confirmDeletion(id: id)) {
            TextState("Delete")
          }
        }

deleteButtonTapped 액션이 들어오면 위와같은 알림창을 띄우고 Delete를 누르면 다시 confirmDeletion 액션으로 넘어갑니다.

코드 하단에 ifLet을 추가해줍니다.

    .ifLet(\.$alert, action: \.alert)

이후 alert action을 구현해줍니다.

      case let .alert(.presented(.confirmDeletion(id: id))):
        state.contacts.remove(id: id)
        return .none
        
      case .alert:
        return .none

우리는 위에서 redeucer에 대한 수정을 가했습니다. 실제로 사용자에게 보여지는 view 또한 수정해줍니다.

    .alert($store.scope(state: \.alert, action: \.alert))

마지막으로, View에도 삭제 버튼을 추가해줘야겠죠? 다음과 같이 코드를 수정해봅시다.

        ForEach(store.contacts) { contact in
          HStack {
            Text(contact.name)
            Spacer()
            Button {
              store.send(.deleteButtonTapped(id: contact.id))
            } label: {
              Image(systemName: "trash")
                .foregroundColor(.red)
            }
          }
        }

Section 2. Improve domain modeling

현재 ContactsFeature는 두 목적지로 이동할 수 있습니다. Add Contacts sheet 혹은 delete alert인데요. 중요한 점은 두 목적지를 동시에 이동할 수 없다는 것입니다. 하지만, Presents()를 이용하면 가능합니다. navigate를 위해 optional을 사용하면 navigate 수에 따라 optional의 수가 늘어나게 됩니다. 2개의 optional은 1개의 invalid가 있고, 4개의 optional은 3개의 invalid가 있고 4개의 optional에는 11개의 invalid가 있습니다.이러한 옵셔널은 application을 더 복잡하게 만듭니다. 이번 섹션에서는 보다 간결하게 개선시키는법을 알아봅시다.

  1. ContactsFeature.swift에서 extension을 만들어 Destinaion 열거형을 정의합니다.
extension ContactsFeature {
  @Reducer
  enum Destination {
  }
}
  1. AddContactsFeature에 대한 case를 추가합니다. 이 케이스에서는 status가 아닌 실제 reducer를 사용한다는 점에 유의하세요.
extension ContactsFeature {
  @Reducer
  enum Destination {
    case addContact(AddContactFeature)
  }
}
  1. 마찬가지로 alert관련 케이스도 추가해줍니다.
extension ContactsFeature {
  @Reducer
  enum Destination {
    case addContact(AddContactFeature)
    case alert(AlertState<ContactsFeature.Action.Alert>)
  }
}
  1. 여러 개의 상호 배타적 Reducer를 결합한 단일 reducer를 정의했습니다. Xcode의 매크로 코드를 이용하여 모든 코드를 확인할 수 있고 추후에 feature를 추가하고싶다면 case에 추가하기만하면 됩니다. 이후 다시 ContactsFeature Reducer로 돌아가서 State를 수정해줍니다.
  @ObservableState
  struct State: Equatable {
    var contacts: IdentifiedArrayOf<Contact> = []
    // @Presents var addContact: AddContactFeature.State?
    // @Presents var alert: AlertState<Action.Alert>?
    @Presents var destination: Destination.State?
  }
  1. State가 Equtable가 아니라는 오류가 표시됩니다. 프로토콜을 추가해줍시다.
extension ContactsFeature {
  @Reducer
  enum Destination {
    case addContact(AddContactFeature)
    case alert(AlertState<ContactsFeature.Action.Alert>)
  }
}
extension ContactsFeature.Destination.State: Equatable {}
  1. Reducer의 Action도 수정해줍니다.
  enum Action {
    case addButtonTapped
    case deleteButtonTapped(id: Contact.ID)
    // case addContact(PresentationAction<AddContactFeature.Action>)
    // case alert(PresentationAction<Alert>)
    case destination(PresentationAction<Destination.Action>)
    enum Alert: Equatable {
      case confirmDeletion(id: Contact.ID)
    }
  }
  1. addContact 또한 destination으로 수정합니다.
      case .addButtonTapped:
        state.destination = .addContact(
          AddContactFeature.State(
            contact: Contact(id: UUID(), name: "")
          )
        )
        return .none
  1. addContact를 저장할때도 수정합니다.
      case let .destination(.presented(.addContact(.delegate(.saveContact(contact))))):
        state.contacts.append(contact)
        return .none
  1. 그리고 alert에서 삭제를 확인합니다.
      case let .destination(.presented(.alert(.confirmDeletion(id: id)))):
        state.contacts.remove(id: id)
        return .none
  1. 다른 작업이 없음을 반환하기 위해 .none을 추가합니다.
      case .destination:
        return .none
  1. destinaion alert을 표시하는 대신 케이스를 가르키도록 수정합니다.
      case let .deleteButtonTapped(id: id):
        state.destination = .alert(
          AlertState {
            TextState("Are you sure?")
          } actions: {
            ButtonState(role: .destructive, action: .confirmDeletion(id: id)) {
              TextState("Delete")
            }
          }
        )
        return .none
      }
  1. Reducer 하단에 ifLet을 추가합니다.
@Reducer
struct ContactsFeature {
  @ObservableState
  struct State: Equatable {
    var contacts: IdentifiedArrayOf<Contact> = []
    @Presents var destination: Destination.State?
  }
  enum Action {
    case addButtonTapped
    case deleteButtonTapped(id: Contact.ID)
    case destination(PresentationAction<Destination.Action>)
    enum Alert: Equatable {
      case confirmDeletion(id: Contact.ID)
    }
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .addButtonTapped:
        state.destination = .addContact(
          AddContactFeature.State(
            contact: Contact(id: UUID(), name: "")
          )
        )
        return .none
        
      case let .destination(.presented(.addContact(.delegate(.saveContact(contact))))):
        state.contacts.append(contact)
        return .none
        
      case let .destination(.presented(.alert(.confirmDeletion(id: id)))):
        state.contacts.remove(id: id)
        return .none
        
      case .destination:
        return .none
        
      case let .deleteButtonTapped(id: id):
        state.destination = .alert(
          AlertState {
            TextState("Are you sure?")
          } actions: {
            ButtonState(role: .destructive, action: .confirmDeletion(id: id)) {
              TextState("Delete")
            }
          }
        )
        return .none
      }
    }
    .ifLet(\.$destination, action: \.destination)
  }
}
  1. 모든 목적지를 단일 선택 값으로 모델링할 때, 먼저 목적지 도메인으로 범위를 설정한 다음 익숙한 키 경로 도트 체인 구문을 사용하여 특정 목적지와 관련된 상태 및 동작 사례로 범위를 확장합니다. 이는 Reducer() 매크로가 각 열거형에 @CasePathable 매크로를 적용하기 때문에 익숙한 도트 구문을 사용하여 수행할 수 있습니다.
    .sheet(
      item: $store.scope(state: \.destination?.addContact, action: \.destination.addContact)
    ) { addContactStore in
      NavigationStack {
        AddContactView(store: addContactStore)
      }
    }
    .alert($store.scope(state: \.destination?.alert, action: \.destination.alert))

두 개의 독립적이고 부정확하게 모델링된 옵션 값을 하나의 옵션 열거로 변환하기만 하면 됩니다. 이제 한 번에 하나의 목적지만 활성화할 수 있음을 증명할 수 있습니다. 이제 남은 것은 뷰를 업데이트하여 목적지 열거가 시트를 구동하는 경우와 알림을 구동하는 경우를 지정할 수 있도록 하는 것뿐입니다.

profile
밍밍

0개의 댓글