{"id":27649296,"url":"https://github.com/thoonk/compositional-diffable-demo","last_synced_at":"2025-04-24T03:05:01.057Z","repository":{"id":239117420,"uuid":"798590324","full_name":"thoonk/compositional-diffable-demo","owner":"thoonk","description":"AppStore 앱의 레이아웃을 클론한 Demo 프로젝트","archived":false,"fork":false,"pushed_at":"2024-06-20T06:32:40.000Z","size":395,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-04-24T03:04:57.197Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/thoonk.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2024-05-10T04:43:58.000Z","updated_at":"2024-06-20T06:32:43.000Z","dependencies_parsed_at":"2024-05-14T07:37:30.058Z","dependency_job_id":"b3071c8a-a409-41aa-b859-9fa45011a07e","html_url":"https://github.com/thoonk/compositional-diffable-demo","commit_stats":null,"previous_names":["thoonk/compositional-diffable-demo"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thoonk%2Fcompositional-diffable-demo","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thoonk%2Fcompositional-diffable-demo/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thoonk%2Fcompositional-diffable-demo/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thoonk%2Fcompositional-diffable-demo/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/thoonk","download_url":"https://codeload.github.com/thoonk/compositional-diffable-demo/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":250552072,"owners_count":21449164,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2025-04-24T03:05:00.497Z","updated_at":"2025-04-24T03:05:01.041Z","avatar_url":"https://github.com/thoonk.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Compositional Layout + Diffable DataSource를 적용한 데모 프로젝트\n\n- AppStore 앱 레이아웃을 클론하여 구현 \n- Compositional 과 Diffable DataSource 적용\n- 코드 작성 편의성을 위한 SnapKit 과 Then 라이브러리 사용\n- iOS 14+\n  \n## Compositional Layout\n### Compositional Layout 정의\n하나의 콜렉션 뷰에 섹션 별로 다른 레이아웃 정의\n\n```\nprivate lazy var collectionView = UICollectionView(\n    frame: .zero,\n    collectionViewLayout: UICollectionViewCompositionalLayout { section, env -\u003e NSCollectionLayoutSection? in\n        guard let sectionKind = AppSection(rawValue: section) else { return nil }\n        switch sectionKind {\n        case .feature:\n            return self.getLayoutFeatureSection()\n        case .rankingFeature:\n            return self.getLayoutRankingFeatureSection()\n        case .themeFeature:\n            return self.getLayoutThemeFeatureSection()\n        }\n    }).then {\n        $0.showsHorizontalScrollIndicator = false\n        $0.contentInset = .zero\n    }\n```\n\n### Section 정의\n```\nenum AppSection: Int, Hashable, CaseIterable {\n    case feature\n    case rankingFeature\n    case themeFeature\n    \n    var headerTitle: String? {\n        switch self {\n        case .feature:\n            return nil\n        case .rankingFeature:\n            return \"지금 주목해야 할 앱\"\n        case .themeFeature:\n            return \"테마별 필수 앱\"\n        }\n    }\n    \n    var description: String? {\n        switch self {\n        case .feature:\n            return nil\n        case .rankingFeature:\n            return \"새로 나온 앱과 업데이트\"\n        case .themeFeature:\n            return nil\n        }\n    }\n}\n```\n\n### Feature 섹션 레이아웃 정의\n\u003cimg src = \"Images/image_feature.png\" width = \"600\" hegiht = \"400\"\u003e\n\n이전 및 다음 아이템 노출을 위해 group .fractionalWidth(0.9) 설정 및 horizontal로 설정\n```\nfunc getLayoutFeatureSection() -\u003e NSCollectionLayoutSection {\n    let itemSize = NSCollectionLayoutSize(\n        widthDimension: .fractionalWidth(1.0),\n        heightDimension: .fractionalHeight(1.0)\n    )\n    \n    let item = NSCollectionLayoutItem(layoutSize: itemSize)\n    item.contentInsets  = NSDirectionalEdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6)\n    \n    let groupSize = NSCollectionLayoutSize(\n        widthDimension: .fractionalWidth(0.9),\n        heightDimension: .fractionalHeight(0.3)\n    )\n    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])\n    \n    let section = NSCollectionLayoutSection(group: group)\n    section.orthogonalScrollingBehavior = .groupPagingCentered\n    \n    let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(\n        layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(1)),\n        elementKind: SupplementaryKind.footer,\n        alignment: .bottom\n    )\n    section.boundarySupplementaryItems = [sectionFooter]\n    \n    return section\n}\n```\n\n### Ranking Feature 섹션 정의\n\u003cimg src = \"Images/image_ranking_feature.png\" width = \"600\" hegiht = \"400\"\u003e      \n\n이전 또는 다음 아이템 노출을 위해 group `.fractionalWidth(0.9)` 설정 \n\n한 Group 당 3개의 item을 노출하기 위해 group `.fractionalHeight(1.0/3.0)` 설정\n\n```\nfunc getLayoutRankingFeatureSection() -\u003e NSCollectionLayoutSection {\n    // Item\n    let itemSize = NSCollectionLayoutSize(\n        widthDimension: .fractionalWidth(1.0),\n        heightDimension: .fractionalHeight(1.0)\n    )\n    let item = NSCollectionLayoutItem(layoutSize: itemSize)\n    item.contentInsets  = NSDirectionalEdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6)\n    \n    // Group\n    let groupSize = NSCollectionLayoutSize(\n        widthDimension: .fractionalWidth(0.9),\n        heightDimension: .fractionalHeight(1.0/3.0)\n    )\n    let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 3)\n    \n    // Section\n    let section = NSCollectionLayoutSection(group: group)\n    section.orthogonalScrollingBehavior = .groupPagingCentered\n    let sectionHeader = configureSectionHeader()\n    let sectionFooter = configureSeparatorFooter()\n    section.boundarySupplementaryItems = [sectionHeader, sectionFooter]\n    \n    return section\n}\n```\n\n### Theme Feature 섹션 정의\n\u003cimg src = \"Images/image_theme_feature.png\" width = \"600\" hegiht = \"400\"\u003e\n\n다음 아이템 노출을 위해 group `.fractionalWidth(0.65)` 설정\n\n```\nfunc getLayoutThemeFeatureSection() -\u003e NSCollectionLayoutSection {\n    // Item\n    let itemSize = NSCollectionLayoutSize(\n        widthDimension: .fractionalWidth(1.0),\n        heightDimension: .fractionalHeight(1.0)\n    )\n    let item = NSCollectionLayoutItem(layoutSize: itemSize)\n    item.contentInsets  = NSDirectionalEdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6)\n    \n    // Group\n    let groupSize = NSCollectionLayoutSize(\n        widthDimension: .fractionalWidth(0.65),\n        heightDimension: .fractionalHeight(0.25)\n    )\n    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])\n    group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 0)\n    \n    // Section\n    let section = NSCollectionLayoutSection(group: group)\n    section.orthogonalScrollingBehavior = .groupPaging\n    let sectionHeader = configureSectionHeader()\n    let sectionFooter = configureSeparatorFooter()\n    section.boundarySupplementaryItems = [sectionHeader, sectionFooter]\n    \n    return section\n}\n```\n\n### 헤더 및 푸터 정의\n```\nprivate enum SupplementaryKind {\n    static let header = \"section-header-element-kind\"\n    static let footer = \"section-footer-element-kind\"\n}\n\nfunc configureSectionHeader() -\u003e NSCollectionLayoutBoundarySupplementaryItem {\n    return NSCollectionLayoutBoundarySupplementaryItem(\n        layoutSize: NSCollectionLayoutSize(\n            widthDimension: .fractionalWidth(1.0),\n            heightDimension: .estimated(50)\n        ),\n        elementKind: SupplementaryKind.header,\n        alignment: .top\n    )\n}\n\nfunc configureSeparatorFooter() -\u003e NSCollectionLayoutBoundarySupplementaryItem {\n    return NSCollectionLayoutBoundarySupplementaryItem(\n        layoutSize: NSCollectionLayoutSize(\n            widthDimension: .fractionalWidth(1.0),\n            heightDimension: .absolute(1)\n        ),\n        elementKind: SupplementaryKind.footer,\n        alignment: .bottom\n    )\n}\n```\n\n## Diffable DataSource\n### Section 및 Item 정의\nHashable 프로토콜 채택\n```\nenum AppSection: Int, Hashable, CaseIterable {\n    case feature\n    case rankingFeature\n    case themeFeature\n    \n    var headerTitle: String? {\n        switch self {\n        case .feature:\n            return nil\n        case .rankingFeature:\n            return \"지금 주목해야 할 앱\"\n        case .themeFeature:\n            return \"테마별 필수 앱\"\n        }\n    }\n    \n    var description: String? {\n        switch self {\n        case .feature:\n            return nil\n        case .rankingFeature:\n            return \"새로 나온 앱과 업데이트\"\n        case .themeFeature:\n            return nil\n        }\n    }\n}\n\nenum AppSectionItem: Hashable {\n    case feature(Feature)\n    case rankingFeature(RankingFeature)\n    case themeFeature(ThemeFeature)\n}\n\nstruct Feature: Hashable {\n    let type: String\n    let title: String\n    let description: String\n    private let identifier = UUID()\n}\n\nstruct RankingFeature: Hashable {\n    let title: String\n    let description: String\n    let isInAppPurchase: Bool\n    private let identifier = UUID()\n}\n\nstruct ThemeFeature: Hashable {\n    let title: String\n    private let identifier = UUID()\n}\n```\n\n### Cell 등록 및 DataSource 정의\n\n```\nfileprivate typealias AppDataSource = UICollectionViewDiffableDataSource\u003cAppSection, AppSectionItem\u003e\nprivate typealias FeatureRegistration = UICollectionView.CellRegistration\u003cFeatureCell, Feature\u003e\nprivate typealias RankingFeatureRegistration = UICollectionView.CellRegistration\u003cRankingFeatureCell, RankingFeature\u003e\nprivate typealias ThemeFeatureRegistration = UICollectionView.CellRegistration\u003cThemeFeatureCell, ThemeFeature\u003e\n\nprivate lazy var appDataSource = configureAppDataSource()\n\nfunc configureAppDataSource() -\u003e AppDataSource {\n    let featureCellRegistration = FeatureRegistration { cell, _ , feature in\n        cell.prepare(with: feature)\n    }\n    let rankingFeatureCellRegistration = RankingFeatureRegistration { cell, _ , feature in\n        cell.prepare(with: feature)\n    }\n    let themeFeatureCellRegistration = ThemeFeatureRegistration { cell, _, feature in\n        cell.prepare(with: feature)\n    }\n    \n    return AppDataSource(collectionView: collectionView) { collectionView, indexPath, listItem in\n        switch listItem {\n        case .feature(let feature):\n            return collectionView.dequeueConfiguredReusableCell(\n                using: featureCellRegistration,\n                for: indexPath,\n                item: feature\n            )\n        case .rankingFeature(let feature):\n            return collectionView.dequeueConfiguredReusableCell(\n                using: rankingFeatureCellRegistration,\n                for: indexPath,\n                item: feature\n            )\n        case .themeFeature(let feature):\n            return collectionView.dequeueConfiguredReusableCell(\n                using: themeFeatureCellRegistration,\n                for: indexPath,\n                item: feature\n            )\n        }\n    }\n}\n```\n\n### Header 및 Footer 등록\n```\nprivate typealias HeaderRegistration = UICollectionView.SupplementaryRegistration\u003cHeaderView\u003e\nprivate typealias FooterRegistration = UICollectionView.SupplementaryRegistration\u003cFooterView\u003e\n\nfunc configureSupplementaryViewRegistration() {\n    let headerRegistration = HeaderRegistration(elementKind: SupplementaryKind.header) { view, _, indexPath in\n        if let section =  AppSection(rawValue: indexPath.section) {\n            view.prepare(title: section.headerTitle, description: section.description)\n        }\n    }\n    \n    let footerRegistration = FooterRegistration(elementKind: SupplementaryKind.footer, handler: { _, _, _ in })\n    \n    appDataSource.supplementaryViewProvider = { [weak self] _ , kind, index in\n        switch kind {\n        case SupplementaryKind.header:\n            return self?.collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: index)\n        case SupplementaryKind.footer:\n            return self?.collectionView.dequeueConfiguredReusableSupplementary(using: footerRegistration, for: index)\n        default:\n            return UICollectionReusableView()\n        }\n    }\n}\n```\n\n### Mock 데이터 스냅샷 적용\n```\nfunc applyInitialSnapshots() {\n    var snapshot = NSDiffableDataSourceSnapshot\u003cAppSection, AppSectionItem\u003e()\n    \n    let sections = AppSection.allCases\n    snapshot.appendSections(sections)\n    \n    snapshot.appendItems(Mocks.features, toSection: .feature)\n    snapshot.appendItems(Mocks.rankingFeatures, toSection: .rankingFeature)\n    snapshot.appendItems(Mocks.themeFeatures, toSection: .themeFeature)\n    \n    appDataSource.apply(snapshot, animatingDifferences: true)\n}\n```\n\n## 💡 인사이트\n\n### Compositional Layout\n기존 레이아웃으로는 TableView에 CollectionView를 함께 사용하여 구현했음.   \n- 뎁스가 많아지고 코드량이 많아져 공수가 더 소요되었음.  \n\nCompositional Layout을 사용해서 구현했을 때, 하나의 CollectionView로 섹션에 따라 다양하고 복잡한 레이아웃을 간편하게 만들 수 있었음.  \n- 기존 레이아웃에 비해 뎁스가 줄어들고 성능이 높아짐.\n- 복잡한 레이아웃을 선언형 API로 간단하게 구축할 수 있음.\n\n### Diffable DataSource\n기존 DataSource방식에서는 시간이 지남에 따라 변하는 버전이 맞지 않는 이슈(UI와 DataSource 맞지 않음)가 있어 `reloadData()` 호출을 통해 해결했음. 하지만 애니메이션이 적용되지 않아 사용자 경험이 저하됨.\n- 위와 같은 이슈를 해결하기 위해 UI와 DataSource를 중앙화하여 관리하므로 이슈가 해결되었음.\n- IndexPath가 아닌 Snapshot을 사용하고 Snapsoht의 Section 및 Item identifier (Unique identifier, Hashable 준수)를 이용하여 UI 업데이트함.\n- 데이터가 변경될 때마다 새로운 Snapshot을 생성하고 이를 다시 DataSource에 적용하는 과정에서 애니메이션을 통해 자연스럽게 업데이트할 수 있음.\n\n-\u003e 변경사항이 있을 때 애니메이션이 적용되어 자연스럽게 업데이트되는 것과 UI와 DataSource 간에 버전이 맞지 않아 크래시나 에러가 발생할 일이 없음.    \n-\u003e Hashable 기반으로 O(n)의 빠른 성능을 가지고 있음. (기존 DataSource는 일반적으로 O(n^2))","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthoonk%2Fcompositional-diffable-demo","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthoonk%2Fcompositional-diffable-demo","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthoonk%2Fcompositional-diffable-demo/lists"}