【UIKit】UICollectionViewListCellにボタンを配置する方法

# はじめに
本記事は、`Implementing Modern Collection Views`の[サンプルコード](https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views)を見ていきながら内容を整理し、その後『リストセルにお気に入りボタンを追加する』実装までをまとめたものです。
![スクリーンショット 2022-03-23 17.58.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/c13f58f3-ed75-e822-e5ad-19ea658b10ee.png)

実装部分は、[こちら](#実装)へ

`Implementing Modern Collection Views`の導入部分は👇の記事でまとめました。

https://qiita.com/yoshitaka/items/ea965d4ff5caf5eea4ed

# 環境
Xcode 13.3
Swift 5.6

# 整理
サンプルアプリを起動するとテーマごとのリストが確認できます。
今回は`Lists`の中の`List with Custom Cells`を見てきます。
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/cf6ee19f-c905-9134-b12c-760987b42975.png"> <img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/001efe37-eaeb-bdef-250f-f1f4992c091a.png">

#### データ部分
```swift:CustomCellListViewController
private enum Section: Hashable {
    case main
}

private struct Category: Hashable {
    let icon: UIImage?
    let name: String?
    
    static let music = Category(icon: UIImage(systemName: "music.mic"), name: "Music")
    static let transportation = Category(icon: UIImage(systemName: "car"), name: "Transportation")
    static let weather = Category(icon: UIImage(systemName: "cloud.rain"), name: "Weather")
}

private struct Item: Hashable {
    let category: Category
    let image: UIImage?
    let title: String?
    let description: String?
    init(category: Category, imageName: String? = nil, title: String? = nil, description: String? = nil) {
        self.category = category
        if let systemName = imageName {
            self.image = UIImage(systemName: systemName)
        } else {
            self.image = nil
        }
        self.title = title
        self.description = description
    }
    private let identifier = UUID()
    
    static let all = [
        Item(category: .music, imageName: "headphones", title: "Headphones",
             description: "A portable pair of earphones that are used to listen to music and other forms of audio."),
        Item(category: .music, imageName: "hifispeaker.fill", title: "Loudspeaker",
             description: "A device used to reproduce sound by converting electrical impulses into audio waves."),
        Item(category: .transportation, imageName: "airplane", title: "Plane",
             description: "A commercial airliner used for long distance travel."),
        Item(category: .transportation, imageName: "tram.fill", title: "Tram",
             description: "A trolley car used as public transport in cities."),
        Item(category: .transportation, imageName: "car.fill", title: "Car",
             description: "A personal vehicle with four wheels that is able to carry a small number of people."),
        Item(category: .weather, imageName: "hurricane", title: "Hurricane",
             description: "A tropical cyclone in the Caribbean with violent wind."),
        Item(category: .weather, imageName: "tornado", title: "Tornado",
             description: "A destructive vortex of swirling violent winds that advances beneath a large storm system."),
        Item(category: .weather, imageName: "tropicalstorm", title: "Tropical Storm",
             description: "A localized, intense low-pressure system, forming over tropical oceans."),
        Item(category: .weather, imageName: "snow", title: "Snow",
             description: "Atmospheric water vapor frozen into ice crystals falling in light flakes.")
    ]
}
```

データは`Category`と`Item`に分かれていて、`Category`にはイメージとタイトル、`Item`にはカテゴリ、イメージ、タイトル、説明文を持つ構成になっています。

```swift:CustomCellListViewController
fileprivate extension UIConfigurationStateCustomKey {
    static let item = UIConfigurationStateCustomKey("com.apple.ItemListCell.item")
}

private extension UICellConfigurationState {
    var item: Item? {
        set { self[.item] = newValue }
        get { return self[.item] as? Item }
    }
}
```

カスタムキーを定義、`UICellConfigurationState`にカスタムセルの状態を持たせておく。



#### コレクションビューの部分
```swift:CustomCellListViewController
class CustomCellListViewController: UIViewController {
    
    private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! = nil
    private var collectionView: UICollectionView! = nil
    
    override func viewDidLoad() {
        super.viewDidLoad()
        navigationItem.title = "List with Custom Cells"
        configureHierarchy()
        configureDataSource()
    }
}

extension CustomCellListViewController {
    private func createLayout() -> UICollectionViewLayout {
        let config = UICollectionLayoutListConfiguration(appearance: .plain)
        return UICollectionViewCompositionalLayout.list(using: config)
    }
}

extension CustomCellListViewController {
    private func configureHierarchy() {
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(collectionView)
        collectionView.delegate = self
    }
    
    /// - Tag: CellRegistration
    private func configureDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<CustomListCell, Item> { (cell, indexPath, item) in
            cell.updateWithItem(item)
            cell.accessories = [.disclosureIndicator()]
        }
        
        dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, item: Item) -> UICollectionViewCell? in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
        }
        
        // initial data
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.main])
        snapshot.appendItems(Item.all)
        dataSource.apply(snapshot, animatingDifferences: false)
    }
}
```

`cell.accessories = [.disclosureIndicator()]`の部分はオプションで変更可能です。以下デフォルトでの見た目です。パラメータで色々変えられるので詳しくは公式ドキュメントへ（リンク付けました）

- [`.disclosureIndicator(displayed:options:)`](https://developer.apple.com/documentation/uikit/uicellaccessory/3600870-disclosureindicator)
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/d730f5d3-e3fd-4d99-d693-87f403b67b93.png"> 

- [`.outlineDisclosure(displayed:options:actionHandler:)`](https://developer.apple.com/documentation/uikit/uicellaccessory/3600874-outlinedisclosure)
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/2d7b1c02-ecac-1e70-26e3-6aee908ef1ef.png"> 


- [`.checkmark(displayed:options:)`](https://developer.apple.com/documentation/uikit/uicellaccessory/3600867-checkmark)
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/d443aaea-1748-7e00-583d-a9d2b51f6af9.png"> 

- [`.delete(displayed:options:actionHandler:)`](https://developer.apple.com/documentation/uikit/uicellaccessory/3600869-delete)
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/d1fdfa2f-f975-502d-2b5a-4972fa6180b9.png"> 

- [`.insert(displayed:options:actionHandler:)`](https://developer.apple.com/documentation/uikit/uicellaccessory/3600871-insert)
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/67151414-1d85-9cd9-c1d3-946b12d67ac3.png"> 

- [`.reorder(displayed:options:)`](https://developer.apple.com/documentation/uikit/uicellaccessory/3600875-reorder)
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/d719b809-28cf-48c4-4c60-e6474fb5dd37.png"> 

- [`.multiselect(displayed:options:)`](https://developer.apple.com/documentation/uikit/uicellaccessory/3600873-multiselect)
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/fc587131-7d4d-ae48-b72b-93645f6312c1.png"> 

- [`.label(text:displayed:options:)`](https://developer.apple.com/documentation/uikit/uicellaccessory/3600872-label)
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/2342409a-2475-3436-7447-8fcef3354fe7.png">

- [`.customView(configuration:)`](https://developer.apple.com/documentation/uikit/uicellaccessory/3600868-customview)
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/6dbdf045-9b45-ad9a-d6ae-566b089910be.png"> 

##### 複数追加することもできました

```swift
cell.accessories = [.checkmark(), .outlineDisclosure(), .disclosureIndicator(), .multiselect(displayed: .always), .label(text: "テスト"), .insert(displayed: .always),.reorder(displayed: .always), .delete(displayed: .always),.customView(configuration: .init(customView: image, placement: .leading(displayed: .always)))]
```

![スクリーンショット 2022-03-23 13.19.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/5d09badb-b513-d2c3-e6cd-6123e5f99d99.png)
多すぎてタイトルが見えなくなってしましたが。。。。
ただし、同じもの、例えば`[.checkmark(), .checkmark()]`はできません。

これだけでも色々表示できますが、今回のサンプルでは（さらにカスタマイズしたい場合は）カスタムセルを作っています。



#### カスタムセルの部分
```swift:CustomCellListViewController
private class ItemListCell: UICollectionViewListCell {
    private var item: Item? = nil
    
    func updateWithItem(_ newItem: Item) {
        guard item != newItem else { return }
        item = newItem
        setNeedsUpdateConfiguration()
    }
    
    override var configurationState: UICellConfigurationState {
        var state = super.configurationState
        state.item = self.item
        return state
    }
}
```

この`ItemListCell`は、セルが表示しているアイテムを保持するプロパティを持つ抽象クラスで、サブクラスが設定を更新するときに使用し、セルの設定状態に追加されます。

```swift:CustomCellListViewController
private class CustomListCell: ItemListCell {
    
    private func defaultListContentConfiguration() -> UIListContentConfiguration { return .subtitleCell() }
    private lazy var listContentView = UIListContentView(configuration: defaultListContentConfiguration())
    
    private let categoryIconView = UIImageView()
    private let categoryLabel = UILabel()
    private var customViewConstraints: (categoryLabelLeading: NSLayoutConstraint,
                                        categoryLabelTrailing: NSLayoutConstraint,
                                        categoryIconTrailing: NSLayoutConstraint)?
    
    private func setupViewsIfNeeded() {
        // We only need to do anything if we haven't already setup the views and created constraints.
        guard customViewConstraints == nil else { return }
        
        contentView.addSubview(listContentView)
        contentView.addSubview(categoryLabel)
        contentView.addSubview(categoryIconView)
        listContentView.translatesAutoresizingMaskIntoConstraints = false
        let defaultHorizontalCompressionResistance = listContentView.contentCompressionResistancePriority(for: .horizontal)
        listContentView.setContentCompressionResistancePriority(defaultHorizontalCompressionResistance - 1, for: .horizontal)
        categoryLabel.translatesAutoresizingMaskIntoConstraints = false
        categoryIconView.translatesAutoresizingMaskIntoConstraints = false
        let constraints = (
            categoryLabelLeading: categoryLabel.leadingAnchor.constraint(greaterThanOrEqualTo: listContentView.trailingAnchor),
            categoryLabelTrailing: categoryIconView.leadingAnchor.constraint(equalTo: categoryLabel.trailingAnchor),
            categoryIconTrailing: contentView.trailingAnchor.constraint(equalTo: categoryIconView.trailingAnchor)
        )
        NSLayoutConstraint.activate([
            listContentView.topAnchor.constraint(equalTo: contentView.topAnchor),
            listContentView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            listContentView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            categoryLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            categoryIconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            constraints.categoryLabelLeading,
            constraints.categoryLabelTrailing,
            constraints.categoryIconTrailing
        ])
        customViewConstraints = constraints
    }
    
    private var separatorConstraint: NSLayoutConstraint?
    private func updateSeparatorConstraint() {
        guard let textLayoutGuide = listContentView.textLayoutGuide else { return }
        if let existingConstraint = separatorConstraint, existingConstraint.isActive {
            return
        }
        let constraint = separatorLayoutGuide.leadingAnchor.constraint(equalTo: textLayoutGuide.leadingAnchor)
        constraint.isActive = true
        separatorConstraint = constraint
    }
    
    /// - Tag: UpdateConfiguration
    override func updateConfiguration(using state: UICellConfigurationState) {
        setupViewsIfNeeded()
        
        // Configure the list content configuration and apply that to the list content view.
        var content = defaultListContentConfiguration().updated(for: state)
        content.imageProperties.preferredSymbolConfiguration = .init(font: content.textProperties.font, scale: .large)
        content.image = state.item?.image
        content.text = state.item?.title
        content.secondaryText = state.item?.description
        content.axesPreservingSuperviewLayoutMargins = []
        listContentView.configuration = content
        
        // Get the list value cell configuration for the current state, which we'll use to obtain the system default
        // styling and metrics to copy to our custom views.
        let valueConfiguration = UIListContentConfiguration.valueCell().updated(for: state)
        
        // Configure custom image view for the category icon, copying some of the styling from the value cell configuration.
        categoryIconView.image = state.item?.category.icon
        categoryIconView.tintColor = valueConfiguration.imageProperties.resolvedTintColor(for: tintColor)
        categoryIconView.preferredSymbolConfiguration = .init(font: valueConfiguration.secondaryTextProperties.font, scale: .small)
        
        // Configure custom label for the category name, copying some of the styling from the value cell configuration.
        categoryLabel.text = state.item?.category.name
        categoryLabel.textColor = valueConfiguration.secondaryTextProperties.resolvedColor()
        categoryLabel.font = valueConfiguration.secondaryTextProperties.font
        categoryLabel.adjustsFontForContentSizeCategory = valueConfiguration.secondaryTextProperties.adjustsFontForContentSizeCategory
        
        // Update some of the constraints for our custom views using the system default metrics from the configurations.
        customViewConstraints?.categoryLabelLeading.constant = content.directionalLayoutMargins.trailing
        customViewConstraints?.categoryLabelTrailing.constant = valueConfiguration.textToSecondaryTextHorizontalPadding
        customViewConstraints?.categoryIconTrailing.constant = content.directionalLayoutMargins.trailing
        updateSeparatorConstraint()
    }
}
```

これで見た目がこうなる👇
![スクリーンショット 2022-03-23 13.49.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/005d8a9d-55f7-f731-b2b1-2e2231132fa4.png)

カスタマイズ部分は`categoryLabel`と`categoryIconView`だけなので、[`UIListContentConfiguration`](https://developer.apple.com/documentation/uikit/uilistcontentconfiguration)から変更できるものだけで要件を満たせる場合はその方が良さそう。。。

ちなみにカスタマイズ不要な場合は、
```swift
        let cellRegistration = UICollectionView.CellRegistration<CustomListCell, Item> { (cell, indexPath, item) in
            cell.updateWithItem(item)
            cell.accessories = [.disclosureIndicator()]
        }
```
の部分を
```swift
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in
            var contentConfiguration = UIListContentConfiguration.subtitleCell()
            contentConfiguration.imageProperties.preferredSymbolConfiguration = .init(font: contentConfiguration.textProperties.font, scale: .large)
            contentConfiguration.image = item.image
            contentConfiguration.text = item.title
            contentConfiguration.secondaryText = item.description
            contentConfiguration.axesPreservingSuperviewLayoutMargins = []

            cell.contentConfiguration = contentConfiguration
            cell.accessories = [.disclosureIndicator()]
        }
```

とすることでカスタムセルの実装は不要になる
見た目はこんな👇
![スクリーンショット 2022-03-23 16.29.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/9c5901d6-0157-c6f6-d352-c3cb370cf87d.png)

# 実装
リストセルの左端にお気に入りマーク⭐️ を配置し、タップするとお気に入り追加or解除ができるものを作りたく
上記で整理した内容を踏まえて実装しました。

結果、`.customView(configuration:)`にボタンを入れ込むことで実現できました👏

```swift
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, LessonItem> { cell, indexPath, baseLesson in
            var contentConfiguration = cell.defaultContentConfiguration()
            contentConfiguration.text = baseLesson.title
            cell.contentConfiguration = contentConfiguration
            
            if let lesson = baseLesson.baseLesson.isLesson() {
                // ここから
                let favoriteAction = UIAction(image: lesson.favorite ? UIImage(systemName: "star.fill") : UIImage(systemName: "star"),
                                              handler: { [weak self] _ in
                    guard let self = self else { return }
                    // ここにデータ処理を追加する
                })
                let favoriteButton = UIButton(primaryAction: favoriteAction)
                favoriteButton.tintColor = lesson.favorite ? .systemYellow : .lightGray

                let favoriteAccessory = UICellAccessory.CustomViewConfiguration(
                    customView: favoriteButton,
                    placement: .leading(displayed: .always)
                )
                cell.accessories = [.customView(configuration: favoriteAccessory)]
                // ここまで
            }
            
            cell.backgroundConfiguration = UIBackgroundConfiguration.clear()
        }
```

![スクリーンショット 2022-03-23 17.37.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/30cd679b-7b76-6341-b48b-16e67f10dc32.png)


# おわりに
簡単な実装を大げさに長々書いてしましたが、整理も大切。
引き続き`Modern Collection View`のドキュメントやサンプルコード見ていきたいと思います。

# 参考
https://plum-plus.jp/2020/08/27/%E3%83%A2%E3%83%80%E3%83%B3%E3%81%AAcell%E3%81%AE%E6%A7%8B%E6%88%90/

https://stackoverflow.com/questions/64177711/uicollectionviewlistcell-and-custom-cell-accessory-in-ios-14