3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Swift】IGListKitを使ってみる

Posted at

はじめに

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

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

IGListKitとは

IGListKitは、Instagramのチームによって構築されたデータ駆動型のUICollectionViewフレームワークです。このフレームワークでは、UICollectionViewに表示するオブジェクトの配列を提供します。オブジェクトの種類ごとに、アダプタがセクションコントローラと呼ばれるものを作成し、そこにセルを作成するためのすべての詳細が格納されます。
スクリーンショット 2022-05-06 15.27.40.png

IGListKitチュートリアルのメモ

collectionViewの実装

collectionViewを配置するViewControllerimport IGListKit
import UIKit
import IGListKit

class FeedViewController: UIViewController {
collectionViewを用意
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
    }
ListAdapterを定義

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

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
    アダプタを格納するUIViewControllerIGListKitは、このビューコントローラを後で他のビューコントローラに移動するために使用する
  • workingRangeSize
    指定することで可視フレームのすぐ外側にある部分のコンテンツを準備することができる
    スクリーンショット 2022-05-05 17.16.29.png

IGListDiffableに準拠したカスタムモデルを作る

IGListDiffableプロトコルに準拠し、diffIdentifier()isEqual(toDiffableObject:) を実装する必要がある

カスタムモデルの実装
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
  }
}
User
import Foundation

class User: NSObject {
  let id: Int
  let name: String
  
  init(id: Int, name: String) {
    self.id = id
    self.name = name
  }
}
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 を返す必要がある

ダミーデータ生成の実装

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

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もあわせて修正(追加部分のみ)

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

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

ListAdapterDataSourceの必須メソッドを実装

スクリーンショット 2022-05-05 16.36.51.png

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

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

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で実装するメソッドと同じようなものが多い)

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

カスタムセルの実装
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))
    }
}
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)
    }
}

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

SectionControllerの追加

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

カスタムモデルの実装
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
    }
}
WeatherLoader
class WeatherLoader {
    let currentWeather = Weather(
        temperature: 6,
        high: 13,
        low: -69,
        date: Date(),
        condition: .dustStorm
    )
}
カスタムセルの実装
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)
    }
}
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))
    }
}

WeatherSectionControllerの実装
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()で拡張を切り替えているので、拡張フラグに基づいてセルの追加や削除が行われる。

FeedViewControllerWeatherSectionControllerを追加

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()
        }
    }
FeedViewControllerの全体
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
    }
}

SectionControllerを2つ配置した時の画面表示

おわりに

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

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

参考

3
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?