상위 View와 하위 View의 양방향 정보 전달 (feat. Combine)

SteadySlower·2022년 8월 14일
0
post-custom-banner

구현해야 하는 기능

현재 단어장을 개발하면서 가장 트러블을 일으키고 있는 부분은 단어 list를 담당하는 상위 View인 StudyView와 각각의 단어의 정보를 표시하는 하위 View, WordCell 간의 정보를 어떻게 전달할 것인가입니다.

먼저 상위 View에서 하위 View에는 각각의 Cell이 언제 앞면으로 변경되어야 하는지 알려주어야 합니다. 단어의 순서를 랜덤으로 섞을 때나 틀린 단어만 모아볼 때 기존의 Cell들을 모두 다시 앞면으로 세팅해주어야 하기 때문입니다.

또한 하위 View에서 상위 View에는 어떤 단어가 성공 / 실패 처리 되었을 때 해당 정보를 전달해야합니다. View에 보여지는 words 배열은 @Binding으로 연결되어 있다고 해도 DB에서 불러온 원래 데이터를 가지고 있는 rawWords 배열은 바인딩 되어 있지 않기 때문에 수동으로 수정해주어야 합니다.

구현 계획 by Combine

UIKit에서는 상위 View → 하위 View의 경우는 상위 View가 하위 View 클래스의 참조를 가지고 있다가 그 클래스의 메소드의 실행시키면 됩니다. (UIView가 클래스이기 때문에 가능합니다.) 반면에 하위 View → 상위 View의 경우 delegate 패턴을 사용했습니다.

하지만 SwiftUI의 경우 데이터 바인딩을 사용해서 정보전달을 하는 방법을 선호합니다. 따라서 우리도 Combine을 통해 Publisher로 정보를 주고 받고 Publisher을 각각의 View에 바인딩해서 문제를 해결해봅시다.

구현

이벤트 객체 정의하기

protocol Event를 준수하는 두 개의 enum을 만듭니다. CellEvent는 하위 View인 Cell에서 발생하는 이벤트를 상위 View에 전달할 때, StudyViewEvent는 상위 View의 이벤트를 하위 View에 전달할 때 사용합니다. 각각의 enum에 View에서 발생할 수 있는 이벤트를 정의합니다.

상위에서 발생하는 이벤트의 경우 그냥 이벤트의 발생 여부만 알려주면 되지만 Cell에서 발생하는 이벤트의 경우 어떤 단어가 어떤 상태로 바뀌었는지 전달해주어야 합니다. 연관값을 사용해서 정의하도록 합니다.

protocol Event {}

enum CellEvent: Event {
    case studyStateUpdate(id: String?, state: StudyState)
}

enum StudyViewEvent: Event {
    case toFront
}

Publisher 만들기

상위에 Event를 발행하는 publisher를 하나 만들고 하위 뷰를 init할 때 참조를 전달하겠습니다.

하위에서는 해당 publisher의 참조를 View와 ViewModel에 둘 다 가지고 있습니다. Cell을 앞면으로 돌리는 것은 View의 역할이고 단어의 성공 / 실패를 저장하는 것은 데이터의 영역이므로 VM의 영역이기 때문입니다.

// 상위 View의 ViewModel
private(set) var eventPublisher = PassthroughSubject<Event, Never>()
// 하위 View에 전달
ForEach(0..<viewModel.words.count, id: \.self) { index in
    WordCell(word: viewModel.words[index], eventPublisher: viewModel.eventPublisher)
        .frame(width: deviceWidth * 0.9, height: viewModel.words[index].hasImage ? 200 : 100)
}
// 하위 View
private let eventPublisher: PassthroughSubject<Event, Never>

// 하위 View의 VM
private let eventPublisher: PassthroughSubject<Event, Never>

onReceive에 Publisher 등록하기

상위 View

onReceive를 통해 하위 View가 이벤트를 발행하면 받아서 처리하도록 구현합니다. 이벤트를 처리하는 함수는 event 객체를 CellEvent로 캐스팅한 후에 id와 state 값을 받아 필요한 함수를 실행합니다.

// publisher가 발행되면 이벤트 처리함수 실행
.onReceive(viewModel.eventPublisher) { event in
    viewModel.handleEvent(event)
}

// 이벤트를 처리하는 함수
func handleEvent(_ event: Event) {
    guard let event = event as? CellEvent else { return }
    switch event {
    case .studyStateUpdate(let id, let state):
        updateStudyState(id: id, state: state)
    }
}

하위 View

하위 View도 동일하게 onReiceive를 구현합니다. 그리고 이벤트를 받아 처리하는 함수도 구현합니다. StudyView에서 toFront 이벤트를 발행하면 View의 속성을 변경하여 화면이 갱신될 수 있도록 합니다.

// publisher가 발행되면 이벤트 처리 함수 실행
.onReceive(eventPublisher) { event in
    handleEvent(event)
}

// 이벤트 처리하는 함수
private func handleEvent(_ event: Event) {
    guard let event = event as? StudyViewEvent else { return }
    switch event {
    case .toFront:
        isFront = true
    }
}

Publisher에서 발행하기

이제 마지막으로 이벤트가 발행하는 시점에 publisher에서 이벤트를 발행해야 합니다.

상위 View에서 이벤트 발생

상위 View에서 단어를 랜덤으로 섞거나 studyMode를 토글하면 하위 View인 Cell은 모두 단어를 앞면으로 돌려야 합니다. 이 두 함수를 실행할 때 eventPublisher를 이용해 발행해주도록 합시다.

func shuffleWords() {
    rawWords.shuffle()
    filterWords()
    eventPublisher.send(StudyViewEvent.toFront)
}

func toggleStudyMode() {
    studyMode = studyMode == .all ? .excludeSuccess : .all
    filterWords()
    eventPublisher.send(StudyViewEvent.toFront)
}

하위 View에서 이벤트 발생

하위 View에서 DragGesture를 통해서 단어의 성공 / 실패 여부를 저장하면 상위 View에서 받아서 DB에 등록하고 rawWords를 수정해야 합니다. DragGesture에 연결된 함수에서 이벤트를 발행합니다.

// 제스쳐
.gesture(DragGesture(minimumDistance: 30, coordinateSpace: .global)
    .onChanged({ value in
        self.dragWidth =  value.translation.width
    })
    .onEnded({ value in
        self.dragWidth = 0
        if value.translation.width > 0 {
            viewModel.updateStudyState(to: .success)
        } else {
            viewModel.updateStudyState(to: .fail)
        }
    })
)
func updateStudyState(to state: StudyState) {
    eventPublisher.send(CellEvent.studyStateUpdate(id: word.id, state: state))
}

마치며…

하나의 publisher를 활용해서 두 개의 View가 정보를 주고 받는 것을 구현해봤습니다. 하지만 확신이 없는 것은 이런 방법이 추천할 만한 방법인지는 모르겠네요. 하지만 delegate pattern 보다는 데이터 바인딩을 더 선호하는 SwiftUI니까 어느 정도 차선책은 되지 않을까 합니다. 🤔

profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.
post-custom-banner

0개의 댓글