はじめに
IGListKitというInstagramが提供しているライブライのチュートリアルをやってみた簡単なまとめです。
どんな感じか掴めればいいなという軽い気持ちで取り組みました。
IGListKit
とは
IGListKitは、Instagramのチームによって構築されたデータ駆動型のUICollectionViewフレームワークです。このフレームワークでは、UICollectionViewに表示するオブジェクトの配列を提供します。オブジェクトの種類ごとに、アダプタがセクションコントローラと呼ばれるものを作成し、そこにセルを作成するためのすべての詳細が格納されます。
IGListKit
チュートリアルのメモ
■ collectionView
の実装
collectionView
を配置するViewController
でimport IGListKit
import UIKit
import IGListKit
class FeedViewController: UIViewController {
collectionViewを用意
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
というプロトコルに準拠したデータソースが必要で、カウントやセルを返す代わりに、配列やセクションコントローラを提供する
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
指定することで可視フレームのすぐ外側にある部分のコンテンツを準備することができる
■ IGListDiffable
に準拠したカスタムモデルを作る
IGListDiffable
プロトコルに準拠し、diffIdentifier()
と isEqual(toDiffableObject:)
を実装する必要がある
カスタムモデルの実装
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
}
}
import Foundation
class User: NSObject {
let id: Int
let name: String
init(id: Int, name: String) {
self.id = id
self.name = name
}
}
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 を返す必要がある
ダミーデータ生成の実装
動作確認のためのデータ生成は以下のクラスを追加して行うこととする
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
もあわせて修正(追加部分のみ)
class FeedViewController: UIViewController {
let loader = JournalEntryLoader() //追加
override func viewDidLoad() {
super.viewDidLoad()
loader.loadLatest() //追加
}
}
■ ListAdapterDataSource
の必須メソッドを実装
-
objects(for:)
コレクションビューに表示されるべきデータオブジェクトの配列を返す -
listAdapter(_:sectionControllerFor:)
各データオブジェクトに対して、セクションコントローラの新しいインスタンスを返す -
emptyView(for:)
リストが空になったときに表示するビューを返す(nilでもよい)
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
で実装するメソッドと同じようなものが多い)
IGListKitはdidUpdate(to:)
を呼び出して、オブジェクトをセクションコントローラに渡す。このメソッドは常にセルプロトコルのどのメソッドよりも前に呼ばれる
カスタムセルの実装
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
を実装していく
カスタムモデルの実装
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
}
}
class WeatherLoader {
let currentWeather = Weather(
temperature: 6,
high: 13,
low: -69,
date: Date(),
condition: .dustStorm
)
}
カスタムセルの実装
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)
}
}
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の実装
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()
で拡張を切り替えているので、拡張フラグに基づいてセルの追加や削除が行われる。
■ FeedViewController
にWeatherSectionController
を追加
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の全体
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つ配置した時の画面表示

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