서론
- UIDiffableDataSource는 iOS13 및 WWDC19부터 공개된 기존 고전 방식의 DataSource를 대체하는 새로운 API입니다. 이 API를 통해서 기존 UI를 구현하는 방식보다 훨씬 간단하고 효율적으로 구현할 수 있습니다.
- 추가적으로 DiffableDataSource와 잘 어울리는 레이아웃을 그리는 API인 Compositional Layout과 CollectionViewCell인 이 세 가지를 이용하여 화면을 구성하는 방법을 소개해드릴 예정입니다.
- 제가 진행했던 페이버 프로젝트는 ReactorKit과 RxSwift의 조합을 사용하고 있습니다.
- ReactorKit은 MVVM의 Rx화를 시켜 통일화를 시켜준 라이브러리이기 떄문에 Reactor를 ViewModel이라고 생각하셔도 무방합니다.
기존 DataSource의 문제점
- 단순한 단일 섹션을 가진 화면을 구성하는 것은 어렵지 않습니다.
- 하지만 다중 섹션을 가지고 있고, 경우의 수에 따라서 특정 섹션이 사라지기도 하고 생겨나기도 하는 화면이 있습니다.
- 이런 화면은 기존 reloadData로만 화면을 업데이트하기에는 조금 버거워 보일 수 있습니다. 사용자 경험도 저하시킬 수 있고요.
데이터 정의하기
- 많은 섹션들과 아이템을 관리하기 위한 가장 좋은 툴은 enum이라고 할 수 있습니다.
- Associated Value를 통해 각 섹션 및 아이템에 들어갈 구체적인 데이터 타입을 정의합니다.
// MARK: - Item
enum ProfileSectionItem: Hashable {
case profileSetupHelper(ProfileHelperType)
case anniversarySetupHelper
case favors(Favor)
case anniversaries(Anniversary, isMine: Bool)
case memo(String?)
case friends(Friend)
}
// MARK: - Section
enum ProfileSection: Hashable {
case anniversarySetupHelper
case profileSetupHelper
case favors
case anniversaries
case memo
case friends
}
- 한 가지 주의할 점은 우리는 DiffableDataSource를 사용할 때에는 데이터가 Hashable 프로토콜을 채택해야합니다.
- 이전 데이터과 현재 데이터를 Hash Value로 판단하여 무엇이 이전과 다른지 파악하고, 만약 데이터가 바뀌었다면 렌더링이 자동으로 되게끔 하는 것이 우리의 목표입니다.
레이아웃 정의하기
- 다음은 각 섹션의 헤더와 셀들의 레이아웃을 정의해야합니다.
- UICompositionalLayout 을 사용하여 각 섹션과 아이템들의 '레이아웃'을 정의합니다.
- CompositionalLayout은 섹션과 아이템이 많을 수록 코드가 굉장히 길어질 수도 있습니다. 그래서 최대한 ViewController가 너무 방대해지지 않도록 분리해서 구현해주시는 것이 적합합니다.
extension ProfileSection: Composable {
public var item: UICollectionViewComposableLayout.Item {
switch self {
case .anniversarySetupHelper:
return .listRow(height: .absolute(250))
case .profileSetupHelper:
return .grid(
width: .absolute(250),
height: .absolute(250)
)
case .favors:
return .grid(
width: .estimated(100),
height: .absolute(32)
)
case .anniversaries:
return .listRow(
height: .absolute(95)
)
case .memo:
return .listRow(
height: .estimated(130)
)
case .friends:
return .grid(
width: .absolute(60),
height: .absolute(87)
)
}
}
}
비즈니스 로직 처리하기
- 이제 ViewModel에서 관련 섹션과 아이템의 객체들을 만들어서 단방향 흐름을 구현합니다.
final class ProfileViewReactor: Reactor {
enum Action { ... }
enum Mutation { ... }
struct State {
var sections: [ProfileSection] = []
var items: [[ProfileSectionItem]] = []
var user = User()
}
- State에는 위에서 정의했던 enum의 배열 값이 각각 들어있습니다.
- 만약 사용자의 액션(인풋)이 들어오면 데이터를 변경하고 자동으로 UI를 변경하고 싶습니다.
- ReactorKit에는 이와 알맞은 메서드가 있습니다. 바로 transform(state:) 입니다.
func transform(state: Observable<State>) -> Observable<State> {
return state.map { state in
var newState = state
var newSections: [ProfileSection] = []
var newItems: [[ProfileSectionItem]] = [] // 아이템은 2차원 배열
// 취향
if !state.favorItems.isEmpty {
newSections.append(.favors)
newItems.append(state.favorItems)
}
// 기념일
if !state.anniversaryItems.isEmpty {
newSections.append(.anniversaries)
newItems.append(state.anniversaryItems.prefix(3).wrap())
}
// 친구
if !state.friendItems.isEmpty {
newSections.append(.friends)
newItems.append(newState.friendItems)
}
newState.sections = newSections
newState.items = newItems
return newState
}
}
- transform(state:)는 ViewController에 전달할 Observable가 전달되기 직전에 호출되어, 데이터 변경 값에 따라서 원하는 UI를 재구성할 수 있습니다.
VC와 UI 바인딩
func bind(reactor: ProfileViewReactor) {
reactor.state.map { (sections: $0.sections, items: $0.items) }
.asDriver(onErrorRecover: { _ in return .empty()})
.drive(with: self, onNext: { owner, sectionData in
var snapshot = NSDiffableDataSourceSnapshot<ProfileSection, ProfileSectionItem>()
snapshot.appendSections(sectionData.sections)
sectionData.items.enumerated().forEach { idx, items in
snapshot.appendItems(items, toSection: sectionData.sections[idx])
}
DispatchQueue.main.async {
owner.dataSource.apply(snapshot)
}
})
.disposed(by: self.disposeBag)
}
- ReactorKit은 bind(reactor:)를 통해 State와 구독 관계를 맺을 수 있습니다.
- 이제 가공된 items와 sections을 VC에서 받을 준비가 되어있어야 합니다. sections와 items는 항상 같이 사용 되어야 하기 때문에, 두 값을 동시에 구독합니다.
- sectionData는 sections와 items가 들어있는 튜플
- 먼저 스냅샷에 섹션을 추가
- 다음에는 sectionData의 enumerated()를 하여 순회
- sections와 items에 있는 [Item]은 1:1 대응이기 때문에 sections[index]의 아이템들은 차례대로 삽입
- 마지막으로 apply을 통해 새로운 snapshot을 적용
- 이제 의도적인 reloadData없이 자동으로 데이터 값이 바뀐다면 화면 렌더링을 새롭게하는 작업이 완료되었습니다.
마치며
- 이번 글에서는 ReactorKit와 DiffableDataSource를 통해 복잡한 UI 관리를 어떻게 효율적으로 할 수 있을지 고민하는 시간이 되었습니다.
- 고전적인 DataSource를 사용해도 충분히 구현할 수 있지만, 시간이 지나면서 사용자 경험의 욕구가 늘어나고 앱이 고도화 되기때문에 최신 기술을 도입하고 리펙토링 하는 것을 주저하지 않고 프로젝트에 녹여내는 것이 중요한 덕목이 됩니다.