CompositionalLayoutでiOS Music AppのUIを再現


## 完成品
<img width=380 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147358/eb078363-bd80-f267-73cb-05ba3bfa63cb.png">


CompositionalLayout組む際に毎回どう実装するんだっけ？となるケースが多く、[Appleのサンプルコード](https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views)も参考になるんですが、ちょっと足りない要素があるので自分のリファレンス用にMusicアプリのUIを再現してメモ

※UIのレイアウトの再現なのでアーキテクチャなどは適用せずにViewにベタ書きしています

### UICollectionViewDiffableDataSource
Assciated valuesなenumでセクションを管理しています


```swift
    private enum Section {
        case navigation(items: [Navigation])
        case recentlyAddAlbums(items: [Album])
        var ids: [AnyHashable] {
            switch self {
                case .navigation(let items): return items
                case .recentlyAddAlbums(let albums): return albums.map { $0.id }
            }
        }
    }

    private enum Navigation: Hashable, CaseIterable {
        case playlist
        case artist
        case album
        case song
        case recommend
        case downloaded
    }

    private var dataSource: UICollectionViewDiffableDataSource<Int, AnyHashable>?
```


## 全体コード

```swift


import UIKit


class LibraryViewController: UIViewController {
    private enum Section {
        case navigation(items: [Navigation])
        case recentlyAddAlbums(items: [Album])
        var ids: [AnyHashable] {
            switch self {
                case .navigation(let items): return items
                case .recentlyAddAlbums(let albums): return albums.map { $0.id }
            }
        }
    }

    private enum Navigation: Hashable, CaseIterable {
        case playlist
        case artist
        case album
        case song
        case recommend
        case downloaded

        var cellContent: (UIImage?, String) {
            switch self {
                case .playlist: return (UIImage(systemName: "music.note.list"), "プレイリスト")
                case .artist: return (UIImage(systemName: "music.mic"), "アーティスト")
                case .album: return (UIImage(systemName: "square.stack"), "アルバム")
                case .song: return (UIImage(systemName: "music.note"), "曲")
                case .recommend: return (UIImage(systemName: "person.crop.square"), "あなたにおすすめ")
                case .downloaded: return (UIImage(systemName: "arrow.down.circle"), "ダウンロード済み")
            }
        }
    }

    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<Int, AnyHashable>?

    private let recentlyAddAlbums: [Album] = (0..<40).map {
        .init(name: "album \($0)", artist: .init(name: "artist name"), songs: [])
    }

    private var sections: [Section] {
        [.navigation(items: Navigation.allCases),
         .recentlyAddAlbums(items: recentlyAddAlbums)]
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.title = "ライブラリ"
        configureCollectionView()
        // mock data append
        var snapshot = NSDiffableDataSourceSnapshot<Int, AnyHashable>()
        sections.enumerated().forEach {
            snapshot.appendSections([$0.offset])
            snapshot.appendItems($0.element.ids, toSection: $0.offset)
        }
        self.dataSource?.apply(snapshot)
    }

    private func configureCollectionView() {
        // layout
        self.collectionView = UICollectionView(frame: view.frame, collectionViewLayout: compositionalLayout())
        self.collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        self.view.addSubview(collectionView)
        // cell register
        let recentlyAddAlbumCellRegistration = UICollectionView.CellRegistration<MusicItemCell, Album> { cell, indexPath, item in
            cell.configure(content: item)
        }
        let navigationCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Navigation> { cell, indexPath, item in
            var content = cell.defaultContentConfiguration()
            content.image = item.cellContent.0
            content.imageProperties.tintColor = .systemPink
            content.text = item.cellContent.1
            content.textProperties.font = UIFont.preferredFont(forTextStyle: .title3)
            cell.contentConfiguration = content
            cell.accessories = [.disclosureIndicator()]
        }
        let headerRegistration = UICollectionView
            .SupplementaryRegistration<HeaderSupplementaryView>(elementKind: UICollectionView.elementKindSectionHeader) { (supplementaryView, string, indexPath) in
            supplementaryView.label.text = "最近追加した項目"
        }
        // datasource
        dataSource = .init(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
            switch self.sections[indexPath.section] {
                case .recentlyAddAlbums(let items):
                    return collectionView.dequeueConfiguredReusableCell(using: recentlyAddAlbumCellRegistration,
                                                                        for: indexPath,
                                                                        item: items[indexPath.item])
                case .navigation(let items):
                    return collectionView.dequeueConfiguredReusableCell(using: navigationCellRegistration,
                                                                        for: indexPath,
                                                                        item: items[indexPath.item])
            }
        })
        dataSource?.supplementaryViewProvider = {
            $0.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: $2)
        }
        collectionView.dataSource = dataSource
    }

    private func compositionalLayout() -> UICollectionViewCompositionalLayout {
        UICollectionViewCompositionalLayout { (sectionIndex: Int,
                                               layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            switch self.sections[sectionIndex] {
                case .recentlyAddAlbums:
                    let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(72.0),
                                                          heightDimension: .estimated(72.0))
                    let item = NSCollectionLayoutItem(layoutSize: itemSize)
                    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                           heightDimension: .estimated(72.0))
                    let itemCount: Int
                    if layoutEnvironment.container.contentSize.width > 1000 {
                        itemCount = 4
                    } else if layoutEnvironment.container.contentSize.width > 600 {
                        itemCount = 3
                    } else {
                        itemCount = 2
                    }
                    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                                   subitem: item,
                                                                   count: itemCount)
                    let spacing = 16.0
                    group.interItemSpacing = .fixed(spacing)
                    let section = NSCollectionLayoutSection(group: group)
                    section.interGroupSpacing = spacing
                    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: spacing, bottom: 0, trailing: spacing)
                    let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                            heightDimension: .estimated(32))
                    let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize,
                                                                                    elementKind: UICollectionView.elementKindSectionHeader,
                                                                                    alignment: .top,
                                                                                    absoluteOffset: .init(x: 0, y: -spacing))
                    section.boundarySupplementaryItems = [sectionHeader]
                    return section
                case .navigation:
                    let listSection: NSCollectionLayoutSection = .list(using: .init(appearance: .plain),
                                                                       layoutEnvironment: layoutEnvironment)
                    listSection.contentInsets = .init(top: 0, leading: 0, bottom: 24, trailing: 0)
                    return listSection
            }
        }
    }
}

// MARK: - Custom Cell

extension ViewController {
    class AlbumCell: UICollectionViewCell {
        let imageView = UIImageView()
        let albumNameLabel = UILabel()
        let artistNameLabel = UILabel()

        override init(frame: CGRect) {
            super.init(frame: frame)
            // layout
            imageView.translatesAutoresizingMaskIntoConstraints = false
            albumNameLabel.translatesAutoresizingMaskIntoConstraints = false
            artistNameLabel.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview(imageView)
            contentView.addSubview(albumNameLabel)
            contentView.addSubview(artistNameLabel)
            NSLayoutConstraint.activate([
                // imageView constraint
                imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
                imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
                imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
                imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 1.0),
                // albumNameLabel constraint
                albumNameLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 5),
                albumNameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
                albumNameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
                // artistNameLabel constraint
                artistNameLabel.topAnchor.constraint(equalTo: albumNameLabel.bottomAnchor),
                artistNameLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
                artistNameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
                artistNameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            ])
            // Appearance
            imageView.backgroundColor = .systemPink
            imageView.layer.cornerRadius = 8
            artistNameLabel.textColor = .secondaryLabel
        }

        func configure(_ album: Album) {
            self.albumNameLabel.text = album.name
            self.artistNameLabel.text = album.artist.name
        }

        required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    }

    class HeaderSupplementaryView: UICollectionReusableView {
        let label = UILabel()

        override init(frame: CGRect) {
            super.init(frame: frame)
            addSubview(label)
            label.translatesAutoresizingMaskIntoConstraints = false
            label.adjustsFontForContentSizeCategory = true
            NSLayoutConstraint.activate([
                label.topAnchor.constraint(equalTo: topAnchor, constant: 24),
                label.leadingAnchor.constraint(equalTo: leadingAnchor),
                label.trailingAnchor.constraint(equalTo: trailingAnchor),
                label.bottomAnchor.constraint(equalTo: bottomAnchor),
            ])
            if let descriptor = UIFontDescriptor
                .preferredFontDescriptor(withTextStyle: .title2)
                .withSymbolicTraits(.traitBold) {
                label.font = UIFont(descriptor: descriptor, size: 0)
            }
        }
        required init?(coder: NSCoder) { fatalError() }
    }
}

// MARK: - Data Model

struct Album: Identifiable {
    let id: UUID = UUID()
    let name: String
    let artist: Artist
}

struct Artist {
    let name: String
}



```