【Swift】IGListKitを使ってみる

# はじめに

IGListKitというInstagramが提供しているライブライのチュートリアルをやってみた簡単なまとめです。

どんな感じか掴めればいいなという軽い気持ちで取り組みました。

https://www.raywenderlich.com/9106-iglistkit-tutorial-better-uicollectionviews

# `IGListKit`とは

https://github.com/Instagram/IGListKit

>IGListKitは、Instagramのチームによって構築されたデータ駆動型のUICollectionViewフレームワークです。このフレームワークでは、UICollectionViewに表示するオブジェクトの配列を提供します。オブジェクトの種類ごとに、アダプタがセクションコントローラと呼ばれるものを作成し、そこにセルを作成するためのすべての詳細が格納されます。
![スクリーンショット 2022-05-06 15.27.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/c02f3839-9a5e-da47-db05-de912c5a2332.png)

# `IGListKit`チュートリアルのメモ

### ■ `collectionView`の実装

##### `collectionView`を配置する`ViewController`で`import IGListKit`

```swift
import UIKit
import IGListKit

class FeedViewController: UIViewController {
```

<details><summary>collectionViewを用意</summary>

```swift:FeedViewController
    let collectionView: UICollectionView = {
        let view = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
        view.backgroundColor = .yellow
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(collectionView)
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        collectionView.frame = view.bounds
    }
``` 

</details>

##### `ListAdapter`を定義

`IGListKit`では、`ListAdapter`を使ってコレクションビューを制御し、`ListAdapterDataSource`というプロトコルに準拠したデータソースが必要で、カウントやセルを返す代わりに、配列やセクションコントローラを提供する

```swift:FeedViewController
    lazy var adapter: ListAdapter = {
        return ListAdapter(updater: ListAdapterUpdater(),
                           viewController: self,
                           workingRangeSize: 1)
    }()

    override func viewDidLoad() {
        super.viewDidLoad()       

        adapter.collectionView = collectionView
        adapter.dataSource = self    // ここまでだとエラーが出る
    }
```

- updater
`ListUpdatingDelegate`に準拠したオブジェクトで、行やセクションの更新を扱う
`ListAdapterUpdater` はデフォルトの実装
- viewController
アダプタを格納する`UIViewController`。**IGListKit**は、このビューコントローラを後で他のビューコントローラに移動するために使用する
- workingRangeSize
指定することで可視フレームのすぐ外側にある部分のコンテンツを準備することができる
![スクリーンショット 2022-05-05 17.16.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/dbc7ae9f-f34d-91e1-dd40-de91792ab09e.png)


### ■ `IGListDiffable`に準拠したカスタムモデルを作る
`IGListDiffable`プロトコルに準拠し、`diffIdentifier()` と `isEqual(toDiffableObject:)` を実装する必要がある

<details><summary>カスタムモデルの実装</summary>

```swift:JournalEntry
import Foundation

class JournalEntry: NSObject {
  let date: Date
  let text: String
  let user: User
  
  init(date: Date, text: String, user: User) {
    self.date = date
    self.text = text
    self.user = user
  }
}
```

```swift:User
import Foundation

class User: NSObject {
  let id: Int
  let name: String
  
  init(id: Int, name: String) {
    self.id = id
    self.name = name
  }
}
```

```swift:NSObject+ListDiffable
import Foundation
import IGListKit

// MARK: - ListDiffable
extension NSObject: ListDiffable {
  public func diffIdentifier() -> NSObjectProtocol {
    return self
  }

  public func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
    return isEqual(object)
  }
}
```

対応するセクションコントローラでセルを再読み込みする際には、 isEqual(toDiffableObject:) は false を返す必要がある

</details>


<details><summary>ダミーデータ生成の実装</summary>


動作確認のためのデータ生成は以下のクラスを追加して行うこととする

```swift:JournalEntryLoader
class JournalEntryLoader {
    var entries: [JournalEntry] = []
    
    func loadLatest() {
        let user = User(id: 1, name: "foo")
        let entries = [
            JournalEntry(
                date: Date(timeIntervalSinceNow: -1000000),
                text: "hoge",
                user: user
            ),
            JournalEntry(
                date: Date(timeIntervalSinceNow: -2000000),
                text: "fuga",
                user: user
            )
        ]
        self.entries = entries
    }
}
```

`FeedViewController`もあわせて修正（追加部分のみ）

```swift:FeedViewController
class FeedViewController: UIViewController {
    let loader = JournalEntryLoader()    //追加

    override func viewDidLoad() {
        super.viewDidLoad()
        
        loader.loadLatest()    //追加
    }
}
```

</details>



### ■ `ListAdapterDataSource`の必須メソッドを実装
![スクリーンショット 2022-05-05 16.36.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/499bd2fa-c0d8-af7a-9a82-1141db2fb7fe.png)

- `objects(for:)` 
コレクションビューに表示されるべきデータオブジェクトの配列を返す
- `listAdapter(_:sectionControllerFor:)`
各データオブジェクトに対して、セクションコントローラの新しいインスタンスを返す
- `emptyView(for:) `
リストが空になったときに表示するビューを返す（nilでもよい）


```swift: FeedViewController
extension FeedViewController: ListAdapterDataSource {
    func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
        return loader.entries
    }
    
    func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
        return JournalSectionController()    // ここまでだとエラーが出る
    }
    
    func emptyView(for listAdapter: ListAdapter) -> UIView? {
        return nil
    }
}
```

### ■ `SectionController`の実装

セクションコントローラは、データオブジェクトが与えられたときに、 コレクションビューのセクション内のセルを設定し、制御する抽象化されたもの

```swift
import IGListKit

class JournalSectionController: ListSectionController {
    var entry: JournalEntry!
    override init() {
        super.init()
        inset = UIEdgeInsets(top: 0, left: 0, bottom: 15, right: 0)
    }
}

extension JournalSectionController {
    override func numberOfItems() -> Int {
        2
    }
    override func sizeForItem(at index: Int) -> CGSize {
        guard let context = collectionContext else { return .zero }
        let width = context.containerSize.width
        
        if index == 0 {
            return CGSize(width: width, height: 30)
        } else {
            return CGSize(width: width, height: 50)
        }
    }
    override func cellForItem(at index: Int) -> UICollectionViewCell {
        let cellClass: AnyClass = index == 0 ? JournalEntryDateCell.self : JournalEntryCell.self
        let cell = collectionContext!.dequeueReusableCell(of: cellClass, for: self, at: index)
        
        if let cell = cell as? JournalEntryDateCell {
            cell.label.text = entry.date.description
        } else if let cell = cell as? JournalEntryCell {
            cell.label.text = entry.text
        }
        return cell
    }
    override func didUpdate(to object: Any) {
        entry = object as? JournalEntry
    }
}
```

（`UICollectionViewDataSource`,`UICollectionViewDelegateFlowLayout`で実装するメソッドと同じようなものが多い）

**IGListKit**は`didUpdate(to:)`を呼び出して、オブジェクトをセクションコントローラに渡す。このメソッドは常にセルプロトコルのどのメソッドよりも前に呼ばれる

<details><summary>カスタムセルの実装</summary>

```swift:JournalEntryDateCell
class JournalEntryDateCell: UICollectionViewCell {
    let label: UILabel = {
        let label = UILabel()
        label.backgroundColor = .clear
        label.textColor = UIColor.black
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.backgroundColor = UIColor.orange
        contentView.addSubview(label)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        label.frame = bounds.inset(by: UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10))
    }
}
``` 

```swift:
class JournalEntryCell: UICollectionViewCell {
    static let inset = UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 15)
    
    let label: UILabel = {
        let label = UILabel()
        label.backgroundColor = .clear
        label.numberOfLines = 0
        label.textColor = .black
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.backgroundColor = UIColor.red
        contentView.addSubview(label)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        label.frame = bounds.inset(by: JournalEntryCell.inset)
    }
}
```

</details>

### ■ ここまでの実装で画面表示を確認

<img width="300" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/62bfcc87-2b5c-830e-731a-c5665389eec7.png">

### ■ `SectionController`の追加

同じ流れで`WeatherSectionController`を実装していく

<details><summary>カスタムモデルの実装</summary>

```swift:Weather
enum WeatherCondition: String {
    case sunny = "Sunny"
    case dustStorm = "Dust Storm"
    
    var emoji: String {
        switch self {
        case .sunny: return "☀️"
        case .dustStorm: return "🌪"
        }
    }
}

class Weather: NSObject {
    let temperature: Int
    let high: Int
    let low: Int
    let date: Date
    let condition: WeatherCondition
    
    init(
        temperature: Int,
        high: Int,
        low: Int,
        date: Date,
        condition: WeatherCondition
    ) {
        self.temperature = temperature
        self.high = high
        self.low = low
        self.date = date
        self.condition = condition
    }
}
```

```swift:WeatherLoader
class WeatherLoader {
    let currentWeather = Weather(
        temperature: 6,
        high: 13,
        low: -69,
        date: Date(),
        condition: .dustStorm
    )
}
```

</details>

<details><summary>カスタムセルの実装</summary>

```swift:WeatherSummaryCell
class WeatherSummaryCell: UICollectionViewCell {
    private let expandLabel: UILabel = {
        let label = UILabel()
        label.backgroundColor = .clear
        label.textColor = UIColor.black
        label.textAlignment = .center
        label.text = ">>"
        label.sizeToFit()
        return label
    }()
    
    let titleLabel: UILabel = {
        let label = UILabel()
        label.backgroundColor = .clear
        label.numberOfLines = 0
        
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.paragraphSpacing = 4
        let subtitleAttributes = [
            NSAttributedString.Key.foregroundColor: UIColor.blue,
            NSAttributedString.Key.paragraphStyle: paragraphStyle
        ]
        let titleAttributes = [
            NSAttributedString.Key.foregroundColor: UIColor.white
        ]
        let attributedText = NSMutableAttributedString(string: "LATEST\n", attributes: subtitleAttributes)
        attributedText.append(NSAttributedString(string: "WEATHER", attributes: titleAttributes))
        label.attributedText = attributedText
        label.sizeToFit()
        
        return label
    }()
    
    func setExpanded(_ expanded: Bool) {
        expandLabel.transform = expanded ? CGAffineTransform(rotationAngle: CGFloat.pi / 2) : .identity
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(expandLabel)
        contentView.addSubview(titleLabel)
        contentView.backgroundColor = UIColor.white
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        titleLabel.frame = CGRect(x: 10, y: 0, width: titleLabel.bounds.width, height: bounds.height)
        expandLabel.center = CGPoint(x: bounds.width - expandLabel.bounds.width / 2 - 10, y: bounds.height / 2)
    }
}
```

```swift:WeatherDetailCell
class WeatherDetailCell: UICollectionViewCell {
    let titleLabel: UILabel = {
        let label = UILabel()
        label.backgroundColor = .clear
        label.textColor = UIColor.black
        return label
    }()
    
    let detailLabel: UILabel = {
        let label = UILabel()
        label.backgroundColor = .clear
        label.textColor = UIColor.red
        label.textAlignment = .right
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(titleLabel)
        contentView.addSubview(detailLabel)
        contentView.backgroundColor = UIColor.green
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        titleLabel.frame = bounds.inset(by: UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10))
        detailLabel.frame = bounds.inset(by: UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10))
    }
}

```

</details>

<details><summary>WeatherSectionControllerの実装</summary>

```swift:WeatherSectionController
import IGListKit

class WeatherSectionController: ListSectionController {
    var weather: Weather!
    var expanded = false
    
    override init() {
        super.init()
        inset = UIEdgeInsets(top: 20, left: 0, bottom: 80, right: 0)
    }
}

// MARK: - Data Provider
extension WeatherSectionController {
    override func didUpdate(to object: Any) {
        weather = object as? Weather
    }
    
    override func numberOfItems() -> Int {
        return expanded ? 3 : 1
    }
    
    override func sizeForItem(at index: Int) -> CGSize {
        guard let context = collectionContext else {
            return .zero
        }
        let width = context.containerSize.width
        if index == 0 {
            return CGSize(width: width, height: 70)
        } else {
            return CGSize(width: width, height: 40)
        }
    }
    
    override func cellForItem(at index: Int) -> UICollectionViewCell {
        let cellClass: AnyClass = index == 0 ? WeatherSummaryCell.self : WeatherDetailCell.self
        let cell = collectionContext!.dequeueReusableCell(of: cellClass, for: self, at: index)
        
        if let cell = cell as? WeatherSummaryCell {
            cell.setExpanded(expanded)
        } else if let cell = cell as? WeatherDetailCell {
            let title: String, detail: String
            switch index {
            case 1:
                title = "HIGH"
                detail = "\(weather.high) C"
            case 2:
                title = "LOW"
                detail = "\(weather.low) C"
            default:
                title = "n/a"
                detail = "n/a"
            }
            cell.titleLabel.text = title
            cell.detailLabel.text = detail
        }
        return cell
    }
    
    override func didSelectItem(at index: Int) {
        collectionContext?.performBatch(animated: true, updates: { batchContext in
            self.expanded.toggle()
            batchContext.reload(self)
        }, completion: nil)
    }
}
```

`performBatch(animated:updates:completion:)` は、セクション内の更新をバッチ処理し、1つのトランザクションで実行する。セクションコントローラでセルの内容や数が変更されるたびに、 使用することができ、`numberOfItems()`で拡張を切り替えているので、拡張フラグに基づいてセルの追加や削除が行われる。

</details>


### ■ `FeedViewController`に`WeatherSectionController`を追加

```swift:FeedViewController
    func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
        var items: [ListDiffable] = [weatherLoader.currentWeather]
        items += loader.entries as [ListDiffable]
        return items
    }
    
    func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
        if object is Weather {
          return WeatherSectionController()
        } else {
          return JournalSectionController()
        }
    }
```

<details><summary>FeedViewControllerの全体</summary>

```swift:FeedViewController
import UIKit
import IGListKit

class FeedViewController: UIViewController {
    let loader = JournalEntryLoader()
    let weatherLoader = WeatherLoader()
    let collectionView: UICollectionView = {
        let view = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
        view.backgroundColor = .yellow
        return view
    }()
    
    lazy var adapter: ListAdapter = {
        return ListAdapter(updater: ListAdapterUpdater(),
                           viewController: self,
                           workingRangeSize: 0)
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        loader.loadLatest()
        view.addSubview(collectionView)
        adapter.collectionView = collectionView
        adapter.dataSource = self
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        collectionView.frame = view.bounds
    }
}

extension FeedViewController: ListAdapterDataSource {
    func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
        var items: [ListDiffable] = [weatherLoader.currentWeather]
        items += loader.entries as [ListDiffable]
        return items
    }
    
    func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
        if object is Weather {
          return WeatherSectionController()
        } else {
          return JournalSectionController()
        }
    }
    
    func emptyView(for listAdapter: ListAdapter) -> UIView? {
        return nil
    }
}

```

</details>


### ■ `SectionController`を２つ配置した時の画面表示
<img width="300" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/f2d3bf92-e7d5-1362-9dc1-4dc1e43fe047.png">


# おわりに

チュートリアル通りに全て実装できた訳ではありませんが、一通りやったことで使い方は理解できました。
ドキュメントを確認する限り他にもできることがありそうなので、引き続きやっていきたいと思います。

チュートリアルやり終わってから見つけましたが、こちらの記事がとてもわかりやすかったです。

https://qiita.com/_atsushisakai/items/524f2a48e7f671ef61d8


# 参考

https://www.raywenderlich.com/9106-iglistkit-tutorial-better-uicollectionviews

https://instagram.github.io/IGListKit/getting-started.html

https://qiita.com/yuzushioh/items/788acebbf0c3b53caf6c