indextrown/turbolistkit
TurboListKit은 UICollectionView를 Component -> Cell -> Section -> List 구조로 선언형에 가깝게 구성할 수 있게 도와주는 UIKit 리스트 어댑터입니다.
목차
목적
UICollectionViewDataSource,UICollectionViewDelegate, 셀 등록, diff 계산, 이벤트 라우팅을 adapter 내부로 캡슐화합니다.List,Section,Cell,Component조합으로 화면 구조를 데이터처럼 표현합니다.- 섹션 단위 compositional layout을 붙여 세로 리스트, 가로 리스트, 그리드 같은 레이아웃을 섞어 구성할 수 있게 합니다.
didSelect,onRefresh,onReachEnd같은 이벤트를 modifier 스타일로 선언할 수 있게 합니다.- 필요 시 prefetching plugin을 연결해 리소스 로딩 성능을 보완할 수 있게 합니다.
사용법
1. Adapter 준비
import TurboListKit
import UIKit
private let layoutAdapter = CollectionViewLayoutAdapter()
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewCompositionalLayout { [weak self] index, environment in
self?.layoutAdapter.sectionLayout(index: index, enviroment: environment)
}
return UICollectionView(frame: .zero, collectionViewLayout: layout)
}()
private lazy var adapter = CollectionViewAdapter(
configuration: .init(
refreshControl: .enabled(tintColor: .systemBlue)
),
collectionView: collectionView,
layoutAdapter: layoutAdapter
)2. List 구성
let list = List {
Section(id: "feed") {
for item in items {
Cell(
id: item.id,
component: FeedItemComponent(viewModel: item)
)
.didSelect { context in
print("selected:", context.id)
}
}
}
.withHeader(FeedHeaderComponent(title: "Feed"))
.withSectionLayout(
DefaultCompositionalLayoutSectionFactory.vertical(spacing: 12)
.withSectionContentInsets(.init(top: 16, leading: 20, bottom: 16, trailing: 20))
)
}
.onRefresh { [weak self] _ in
self?.reloadItems()
}
.onReachEnd(offsetFromEnd: .relativeToContainerSize(multiplier: 1.0)) { [weak self] _ in
self?.loadNextPage()
}3. 화면 반영
adapter.apply(
list,
updateStrategy: .animatedBatchUpdates
)4. 상태 변경 후 다시 apply
func render() {
adapter.apply(makeList())
}TurboListKit은 상태를 직접 보관하지 않으므로, 데이터가 바뀌면 새 List를 다시 만들어 apply(...)하는 방식으로 갱신합니다.
오토레이아웃 컴포넌트
오토레이아웃으로 구성한 UIView도 TurboListKit 컴포넌트로 그대로 사용할 수 있습니다. 이 경우 핵심은 sizeThatFits(_:)에서 오토레이아웃이 계산한 크기를 반환하는 것입니다.
DemoApp 예제에는 공통 헬퍼 UIView+AutoLayoutFittingSize.swift가 포함되어 있습니다.
override func sizeThatFits(_ size: CGSize) -> CGSize {
autoLayoutFittingSize(for: size)
}- 너비가 이미 정해져 있고, 오토레이아웃이 계산한 실제 높이를 알아야 할 때
systemLayoutSizeFitting(...)호출을 공통 헬퍼로 감싸서 여러 컴포넌트에서 같은sizeThatFits(_:)패턴을 재사용할 수 있습니다.
<br>
override func sizeThatFits(_ size: CGSize) -> CGSize {
autoLayoutFittingSize(
for: size,
targetWidth: min(size.width, 240),
minimumHeight: 140
)
}- 폭 상한이나 최소 높이가 필요한 카드형 컴포넌트일 때
- 카드가 너무 넓어지지 않게 최대 폭을 제한하고 싶을 때 사용합니다.
- 오토레이아웃 결과보다 작은 높이가 나오면 안 되는 UI에서 최소 높이를 함께 보장할 수 있습니다.
<br>
override func sizeThatFits(_ size: CGSize) -> CGSize {
let labelSize = label.sizeThatFits(
CGSize(width: CGFloat.greatestFiniteMagnitude, height: 24)
)
return CGSize(width: labelSize.width + 28, height: 48)
}- 높이는 고정이고, 텍스트 길이에 따라 폭이 달라지는 pill/tag 컴포넌트일 때
- 높이는 고정이고 폭만 내용에 따라 달라지는 경우라면 오토레이아웃 헬퍼보다 직접 계산이 더 단순합니다.
- pill, chip, tag처럼 한 줄 텍스트와 좌우 패딩만으로 크기가 결정되는 컴포넌트에 잘 맞습니다.
<br>
핵심 타입
| 타입 | 역할 | 주로 언제 쓰는가 | | --- | --- | --- | | Component | 셀/보조뷰를 그리는 최소 단위 | 실제 UI와 view model을 묶을 때 | | AnyComponent | Component 타입 소거 래퍼 | 내부 저장 및 diff 비교 시 | | IdentifiableComponent | id를 가진 Component | Cell(component:) 형태로 간단히 만들 때 | | Cell | 컬렉션 뷰 셀 모델 | 아이템 단위 UI를 선언할 때 | | Section | 셀 배열 + header/footer + layout 모델 | 섹션 단위 구성과 레이아웃을 묶을 때 | | SupplementaryView | header/footer 표현 모델 | 섹션 보조 뷰를 붙일 때 | | List | 화면 전체 리스트 모델 | 여러 섹션과 리스트 이벤트를 묶을 때 | | CollectionViewAdapter | UICollectionView와 List를 연결하는 핵심 어댑터 | 실제 화면에 리스트를 반영할 때 | | CollectionViewAdapterConfiguration | adapter 동작 옵션 | refresh, 커스텀 인디케이터, batch update, reconfigure 옵션을 설정할 때 | | CollectionViewAdapterUpdateStrategy | 업데이트 방식 enum | animated batch, non-animated batch, reloadData를 고를 때 | | CollectionViewLayoutAdapter | compositional layout과 section 데이터를 연결 | UICollectionViewCompositionalLayout의 section provider를 붙일 때 | | CompositionalLayoutSectionFactory | 섹션 레이아웃 생성 프로토콜 | 커스텀 NSCollectionLayoutSection을 만들 때 | | DefaultCompositionalLayoutSectionFactory | 기본 제공 레이아웃 팩토리 | 빠르게 vertical/horizontal/grid 레이아웃을 붙일 때 | | VerticalLayout | 세로 리스트 레이아웃 팩토리 | 일반 피드/리스트 섹션 구성 시 | | HorizontalLayout | 가로 스크롤 레이아웃 팩토리 | 카드 캐러셀 구성 시 | | VerticalGridLayout | 그리드 레이아웃 팩토리 | 2열 이상 그리드 구성 시 | | CollectionViewPrefetchingPlugin | prefetch 확장 포인트 | 이미지 등 리소스 prefetch를 붙일 때 | | ComponentRemoteImagePrefetchable | 원격 이미지 prefetch 대상 프로토콜 | 컴포넌트가 미리 받아둘 URL을 제공할 때 |
기능
Modifier / Event 기능
| 대상 | API | 설명 | | --- | --- | --- | | List | didScroll | 스크롤 중 이벤트를 받습니다. | | List | onRefresh | pull-to-refresh 이벤트를 받습니다. | | List | onReachEnd | 리스트 끝 근처 도달 시 추가 로딩 이벤트를 받습니다. | | List | willBeginDragging | 드래그 시작 직전 이벤트를 받습니다. | | List | willEndDragging | 드래그 종료 직전 이벤트를 받습니다. | | List | didEndDragging | 드래그 종료 이벤트를 받습니다. | | List | didScrollToTop | 맨 위로 이동했을 때 이벤트를 받습니다. | | List | willBeginDecelerating | 감속 시작 이벤트를 받습니다. | | List | didEndDecelerating | 감속 종료 이벤트를 받습니다. | | List | shouldScrollToTop | 탭 시 상단 이동 허용 여부를 제어합니다. | | Section | withSectionLayout | 섹션 레이아웃을 지정합니다. | | Section | withHeader | 섹션 헤더를 붙입니다. | | Section | withFooter | 섹션 푸터를 붙입니다. | | Section | willDisplayHeader | 헤더 표시 직전 이벤트를 받습니다. | | Section | willDisplayFooter | 푸터 표시 직전 이벤트를 받습니다. | | Section | didEndDisplayHeader | 헤더 제거 이벤트를 받습니다. | | Section | didEndDisplayFooter | 푸터 제거 이벤트를 받습니다. | | Cell | didSelect | 셀 선택 이벤트를 받습니다. | | Cell | willDisplay | 셀 표시 직전 이벤트를 받습니다. | | Cell | didEndDisplay | 셀 제거 이벤트를 받습니다. | | Cell | onHighlight | 셀 눌림 상태 이벤트를 받습니다. | | Cell | onUnhighlight | 셀 눌림 해제 이벤트를 받습니다. | | SupplementaryView | willDisplay | 보조 뷰 표시 직전 이벤트를 받습니다. | | SupplementaryView | didEndDisplaying | 보조 뷰 제거 이벤트를 받습니다. |
Adapter / Update 기능
| 기능 | API | 설명 | | --- | --- | --- | | 데이터 반영 | apply(_:updateStrategy:completion:) | 새 List를 UICollectionView에 반영합니다. | | 현재 상태 조회 | snapshot() | 현재 adapter가 보유한 List 스냅샷을 반환합니다. | | animated diff update | .animatedBatchUpdates | DifferenceKit 기반 애니메이션 갱신을 수행합니다. | | non-animated diff update | .nonanimatedBatchUpdates | 애니메이션 없이 변경분만 갱신합니다. | | full reload | .reloadData | 전체 reloadData()를 수행합니다. | | refresh control | CollectionViewAdapterConfiguration.RefreshControl | 시스템 UIRefreshControl 연결 여부와 tint를 설정합니다. | | custom refresh indicator | CollectionViewAdapterConfiguration.RefreshControl.Appearance | 기본 스피너 대신 커스텀 이미지 인디케이터 표시 방식을 설정합니다. | | queued update | adapter 내부 큐 | 업데이트 중 들어온 새 요청을 마지막 요청 기준으로 이어서 처리합니다. | | reconfigure items | enablesReconfigureItems | 가능하면 셀 재생성 대신 reconfigure 경로를 사용합니다. | | prefetching plugin | CollectionViewPrefetchingPlugin | prefetch lifecycle을 확장합니다. |
Custom Refresh Indicator
| 대상 | API | 설명 | | --- | --- | --- | | 인디케이터 활성화 | refreshControlAppearance: .init(...) | refresh control 위에 커스텀 인디케이터 외형을 연결합니다. | | 기본 이미지 지정 | Indicator.image(:) | 커스텀 인디케이터로 사용할 이미지를 지정합니다. | | 크기 조절 | .size(:) | 이미지 렌더링 크기를 포인트 단위로 설정합니다. | | 색상 적용 | .tintColor(_:) | 템플릿 렌더링용 tint color를 적용합니다. | | 회전 애니메이션 | .spin(duration:) | 지정한 duration으로 인디케이터 회전 애니메이션을 적용합니다. |
예제 앱
Examples/DemoApp에는 현재 다음 UIKit 예제가 포함되어 있습니다.
DemoViewController: 세로/가로/그리드 섹션, refresh, reach-end, selection을 한 화면에서 보여주는 종합 예제SampleViewController: 가장 단순한 세로 리스트 예제SampleAutoLayoutViewController:SampleViewController를 오토레이아웃 아이템 컴포넌트로 옮긴 버전AutoLayoutSampleViewController:UIStackView와 제약만으로 셀 높이를 계산하는 오토레이아웃 예제HorizontalOnlyViewController: 가로 카드 페이징과 연속 가로 태그 섹션만 모아둔 예제
예제 앱 상세 설명은 Examples/DemoApp/README.md에서 볼 수 있습니다.
성능 비교
TurboListKit은 기본 diff 엔진으로 UICollectionViewDiffableDataSource 대신 DifferenceKit을 사용합니다. Apple의 diffable data source도 섹션과 아이템 업데이트를 지원하지만, TurboListKit은 diff 계산 결과와 batch update fallback 정책을 adapter에서 직접 제어하기 위해 DifferenceKit을 선택했습니다.
왜 DifferenceKit을 선택했는가
| 비교 항목 | DifferenceKit | UICollectionViewDiffableDataSource | | --- | --- | --- | | 핵심 접근 | 직접 diff를 계산해 staged batch update에 반영 | snapshot을 적용하고 내부 diff를 시스템에 위임 | | 알고리즘 근거 | Paul Heckel 기반, 선형 시간 O(n) diff를 명시 | Apple 문서가 단순성, identifier 기반 업데이트를 강조 | | 섹션 지원 | 2차원 sectioned collection diff를 직접 모델링 | snapshot 중심 API로 추상화 | | 업데이트 제어 | change 수가 많을 때 reloadData fallback 같은 정책을 세밀하게 제어 가능 | apply 시점 제어는 쉽지만 내부 diff 전략은 추상화되어 있음 | | 채택 이유 | 라이브러리 레벨에서 성능 특성과 업데이트 전략을 노출하기 쉬움 | 앱 코드 단순화에는 강점이 있지만 adapter 내부 diff 제어 범위는 좁음 |
시간 복잡도 근거
DifferenceKit공식 README는 Paul Heckel 기반 diff 알고리즘이며, 선형 시간O(n)으로 동작한다고 명시합니다.- 같은 비교 표에서
Swift.CollectionDifference는 Myers 기반O(ND)로 소개됩니다. - Apple의 diffable data source 문서는 내부 복잡도를 전면에 내세우기보다, snapshot과 stable identifier를 사용해 컬렉션 변경을 단순화하는 점에 초점을 둡니다.
벤치마크 근거
DifferenceKit 공식 벤치마크에는 Swift.CollectionDifference와의 비교가 포함되어 있습니다.
| 시나리오 | DifferenceKit | Swift.CollectionDifference | | --- | --- | --- | | 5,000개 원소, 1,000 delete, 1,000 insert, 200 shuffle | 0.0019s | 0.0620s | | 100,000개 원소, 10,000 delete, 10,000 insert, 2,000 shuffle | 0.0348s | 5.0281s |
이 수치는 DifferenceKit 저장소에서 공개한 벤치마크 결과이며, TurboListKit 자체 벤치마크는 아닙니다. 다만 adapter가 large diff를 다루는 상황에서 어떤 diff 엔진을 기반으로 삼았는지 설명하는 근거로는 충분합니다.
참고 자료
- Apple, Updating collection views using diffable data sources: stable identifier와 snapshot 기반 업데이트 설명
- DifferenceKit README:
O(n)알고리즘 설명,Swift.CollectionDifference포함 벤치마크 공개
전체 파이프라인
flowchart LR
A[ViewController or UIView] --> B[State or ViewModel]
B --> C[Component]
C --> D[Cell]
D --> E[Section]
E --> F[List]
F --> G[CollectionViewAdapter.apply]
G --> H[DifferenceKit]
G --> I[CollectionViewLayoutAdapter]
I --> J[Compositional Layout]
H --> K[UICollectionView]
J --> K
K --> L[Cell / Header / Footer Rendering]동작 파이프라인
flowchart TD
A[User Action or State Change] --> B{어떤 경로인가}
B -->|Initial render| C[make List]
B -->|Data changed| C
B -->|Pull to refresh| D[onRefresh handler]
B -->|Reach end| E[onReachEnd handler]
D --> F[update state]
E --> F
F --> C
C --> G[adapter.apply]
G --> H[register reuse identifiers]
G --> I[diff or reload]
I --> J[UICollectionView update]
J --> K[event callbacks]
J --> L[visible UI updated]Package Metadata
Repository: indextrown/turbolistkit
Default branch: main
README: README.md