LoginSignup
2
1

CompositionalLayoutでiOS Music AppのUIを再現

Last updated at Posted at 2023-02-10

完成品

CompositionalLayout組む際に毎回どう実装するんだっけ?となるケースが多く、Appleのサンプルコードも参考になるんですが、ちょっと足りない要素があるので自分のリファレンス用にMusicアプリのUIを再現してメモ

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

UICollectionViewDiffableDataSource

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

    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>?

全体コード



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
}



2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1