IGListKit Tutorial: Better UICollectionViews

In this IGListKit tutorial, you’ll learn to build better, more dynamic UICollectionViews with Instagram’s data-driven framework.

Version

  • Swift 4.2, iOS 12, Xcode 10
Update note: Ron Kliffer updated this tutorial for Xcode 10, Swift 4.2 and iOS 12. Ryan Nystrom wrote the original.

Each app starts off the same way: a few screens, some buttons and maybe a list or two. But as time goes on and the app grows, features start to creep their way in. Your clean data sources start to crumble under the pressure of deadlines and product managers. After a while, you’re left with the massive view controller ruins to maintain. Lucky for you, there’s a solution to that problem!

Instagram created IGListKit to make feature creep and massive view controllers a thing of the past when working with UICollectionView. By creating lists with IGListKit, you can build apps with decoupled components, blazing-fast updates and support for any type of data.

In this tutorial you will refactor a basic UICollectionView to use IGListKit, then extend the app and take it out of this world!

Getting Started

You are one of NASA’s top software engineers and on staff for the latest manned mission to Mars. The team already built the first version of the Marslink app.

Use the Download Materials button at the top or bottom of this tutorial to download it. After you’ve downloaded the project, open Marslink.xcworkspace, then build and run the app.

IGListKit

So far, the app just shows a list of astronaut journal entries.

You’re tasked with adding new features to this app whenever the crew needs them. Familiarize yourself with the project by opening ClassicFeedViewController.swift and having a look around.

If you’ve ever worked with UICollectionView, what you see looks pretty standard:

  • ClassicFeedViewController is a UIViewController subclass that implements UICollectionViewDataSource in an extension.
  • viewDidLoad() creates a UICollectionView, registers cells, sets the data source and adds it to the view hierarchy.
  • The loader.entries array powers the number of sections, each having just two cells (one for the date and one for the text).
  • Date cells contain the Sol date and text entry cells with Journal text.
  • collectionView(_:layout:sizeForItemAt:) returns a fixed size for the date cell and calculates the size of the text for the actual entry.

Everything seems to be working just fine, but the mission director comes up with some urgent product update requests:

An astronaut has just become stranded on Mars. We need you to add a weather module and real-time chat. You have 48 hours.

Engineers from JPL have some of these systems working, but they need your help adding them to the app.

If all the pressure of bringing an astronaut home wasn’t enough, NASA’s head designer just handed you requirements that each subsystem’s update in the app has to be animated, which means no reloadData().

How in the world are you supposed to integrate these new modules into an existing app and make all the transitions animated? The astronaut only has so many potatoes!

Introducing IGListKit

While UICollectionView is an incredibly powerful tool, with great power comes great responsibility. Keeping your data source and the view in sync is of utmost importance, but disconnects here commonly cause crashes.

IGListKit is a data-driven UICollectionView framework built by the team at Instagram. With this framework, you provide an array of objects to display in UICollectionView. For each type of object, an adapter creates something called a section controller, which has all of the details for creating cells.

IGListKit

IGListKit automatically diffs your objects and performs animated batch updates on the UICollectionView for whatever changed. This way you never have to write batch updates yourself, avoiding the issues listed under caveats here.

Adding IGListKit to a UICollectionView

IGListKit does all the hard work of identifying changes in a collection and updating the appropriate rows with animation. It is also structured to easily handle multiple sections with different data and UI. With that in mind, it’s a perfect solution to the new batch of requirements—so it’s time to start implementing it!

With Marslink.xcworkspace still open, right-click on the ViewControllers group and select New File. Add a new Cocoa Touch Class that subclasses UIViewController named FeedViewController and ensure the language is set to Swift.

Open AppDelegate.swift and find application(_:didFinishLaunchingWithOptions:). Find the line that pushes ClassicFeedViewController() onto the navigation controller, and replace it with this:

nav.pushViewController(FeedViewController(), animated: false)

FeedViewController is now the root view controller. You’ll keep ClassicFeedViewController.swift around for reference, but FeedViewController is where you’ll implement the new IGListKit-powered collection view.

Build and run and make sure a new, empty view controller shows up on screen.

IGListKit

Adding the Journal Loader

Open FeedViewController.swift and add the following property to the top of FeedViewController:

let loader = JournalEntryLoader()

JournalEntryLoader is a class that loads hard-coded journal entries into an entries array.

Add the following to the bottom of viewDidLoad():

loader.loadLatest()

loadLatest() is a JournalEntryLoader method that loads the latest journal entries.

Adding the Collection View

It’s time to start adding some IGListKit-specific controls to the view controller. Before you do, you need to import the framework. Near the top of FeedViewController.swift, add a new import:

import IGListKit
Note: The project in this tutorial uses CocoaPods to manage dependencies. IGListKit is written in Objective-C, so if you manually add it to your project, you’ll need to insert #import into your bridging header.

Add an initialized collectionView constant to the top of FeedViewController:

// 1
let collectionView: UICollectionView = {
  // 2
  let view = UICollectionView(
    frame: .zero, 
    collectionViewLayout: UICollectionViewFlowLayout())
  // 3
  view.backgroundColor = .black
  return view
}()

Here’s what this code does:

  1. IGListKit uses a regular UICollectionView and adds its own functionality on top of it, as you will see later on.
  2. Start with a zero-sized rect, since the view isn’t created yet. It uses a UICollectionViewFlowLayout just as the ClassicFeedViewController did.
  3. Set the background color to NASA-approved black.

Add the following to the bottom of viewDidLoad():

view.addSubview(collectionView)

This adds the new collectionView to the controller’s view.

Below viewDidLoad(), add the following:

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

This overrides viewDidLayoutSubviews(), setting the collectionView frame to match the view bounds.

ListAdapter and Data Source

With UICollectionView, you need some sort of data source that adopts UICollectionViewDataSource. Its job is to return section and row counts as well as individual cells.

In IGListKit, you use a ListAdapter to control the collection view. You still need a data source that conforms to the protocol ListAdapterDataSource, but instead of returning counts and cells, you provide arrays and section controllers (more on this later).

For starters, in FeedViewController.swift add the following at the top of FeedViewController:

lazy var adapter: ListAdapter = {
  return ListAdapter(
  updater: ListAdapterUpdater(),
  viewController: self, 
  workingRangeSize: 0)
}()

This creates an initialized variable for the ListAdapter. The initializer requires three parameters:

  1. updater is an object conforming to ListUpdatingDelegate, which handles row and section updates. ListAdapterUpdater is a default implementation that’s suitable for your usage.
  2. viewController is a UIViewController that houses the adapter. IGListKit uses this view controller later for navigating to other view controllers.
  3. workingRangeSize is the size of the working range, which allows you to prepare content for sections just outside of the visible frame.
Note: Working ranges are a more advanced topic not covered by this tutorial. However there’s plenty of documentation and even an example app in the IGListKit repo!

Add the following to the bottom of viewDidLoad():

adapter.collectionView = collectionView
adapter.dataSource = self

This connects the collectionView to the adapter. It also sets self as the dataSource for the adapter — resulting in a compiler error, because you haven’t conformed to ListAdapterDataSource yet.

Fix this by extending FeedViewController to adopt ListAdapterDataSource. Add the following to the bottom of the file:

// MARK: - ListAdapterDataSource
extension FeedViewController: ListAdapterDataSource {
  // 1
  func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
    return loader.entries
  }
  
  // 2
  func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) 
  -> ListSectionController {
    return ListSectionController()
  }
  
  // 3
  func emptyView(for listAdapter: ListAdapter) -> UIView? {
    return nil
  }
}
Note: IGListKit makes heavy use of required protocol methods. Even though you might end up with empty methods, or ones that return nil, you don’t have to worry about silently missing methods or fighting a dynamic runtime. It makes using IGListKit very hard to mess up.

FeedViewController now conforms to ListAdapterDataSource and implements its three required methods:

  • objects(for:) returns an array of data objects that should show up in the collection view. You provide loader.entries here as it contains the journal entries.
  • For each data object, listAdapter(_:sectionControllerFor:) must return a new instance of a section controller. For now you’re returning a plain ListSectionController to appease the compiler. In a moment, you’ll modify this to return a custom journal section controller.
  • emptyView(for:) returns a view to display when the list is empty. NASA is in a bit of a time crunch, so they didn’t budget for this feature.

Creating Your First Section Controller

A section controller is an abstraction that, given a data object, configures and controls cells in a section of a collection view. This concept is similar to a view-model that exists to configure a view: the data object is the view-model and the cells are the view. The section controller acts as the glue between the two.

In IGListKit, you create a new section controller for different types of data and behavior. JPL engineers already built a JournalEntry model, so you need to create a section controller that can handle it.

Right-click on the SectionControllers group and select New File. Create a new Cocoa Touch Class named JournalSectionController that subclasses ListSectionController.

Xcode doesn’t automatically import third-party frameworks, so in JournalSectionController.swift, add a line at the top:

import IGListKit

Add the following properties to the top of JournalSectionController:

var entry: JournalEntry!
let solFormatter = SolFormatter()

JournalEntry is a model class that you’ll use when implementing the data source. The SolFormatter class provides methods for converting dates to Sol format. You’ll need both shortly.

Also inside JournalSectionController, override init() by adding the following:

override init() {
  super.init()
  inset = UIEdgeInsets(top: 0, left: 0, bottom: 15, right: 0)
}

Without this, the cells between sections will butt up next to each other. This adds 15 point padding to the bottom of JournalSectionController objects.

Your section controller needs to override four methods from ListSectionController to provide the actual data for the adapter to work with.
Add the following extension to the bottom of the file:

// MARK: - Data Provider
extension JournalSectionController {
  override func numberOfItems() -> Int {
    return 2
  }
  
  override func sizeForItem(at index: Int) -> CGSize {
    return .zero
  }
  
  override func cellForItem(at index: Int) -> UICollectionViewCell {
    return UICollectionViewCell()
  }
  
  override func didUpdate(to object: Any) {
  }  
}

All methods are stub implementations except for numberOfItems(), which simply returns 2 for a date and text pair. If you refer back to ClassicFeedViewController.swift, you’ll notice that you also return 2 items per section in collectionView(_:numberOfItemsInSection:). This is basically the same thing!

In didUpdate(to:), add the following:

entry = object as? JournalEntry

IGListKit calls didUpdate(to:) to hand an object to the section controller. Note this method is always called before any of the cell protocol methods. Here, you save the passed object in entry.

Note: Objects can change multiple times during the lifetime of a section controller. This only happens when you start unlocking more advanced features of IGListKit, like custom model diffing. You won’t have to worry about diffing in this tutorial.

Now that you have some data, you can start configuring your cells. Replace the placeholder implementation of cellForItem(at:) with the following:

// 1
let cellClass: AnyClass = index == 0 ? JournalEntryDateCell.self : JournalEntryCell.self
// 2
let cell = collectionContext!.dequeueReusableCell(of: cellClass, for: self, at: index)
// 3
if let cell = cell as? JournalEntryDateCell {
  cell.label.text = "SOL \(solFormatter.sols(fromDate: entry.date))"
} else if let cell = cell as? JournalEntryCell {
  cell.label.text = entry.text
}
return cell

IGListKit calls cellForItem(at:) when it requires a cell at a given index in the section. Here’s how the code works:

  1. If the index is the first, use a JournalEntryDateCell cell, otherwise use a JournalEntryCell cell. Journal entries always appear with a date followed by the text.
  2. Dequeue the cell from the reuse pool using the cell class, a section controller (self) and the index.
  3. Depending on the cell type, configure it using the JournalEntry you set earlier in didUpdate(to object:).

Next, replace the placeholder implementation of sizeForItem(at:) with the following:

// 1
guard 
  let context = collectionContext, 
  let entry = entry 
  else { 
    return .zero
}
// 2
let width = context.containerSize.width
// 3
if index == 0 {
  return CGSize(width: width, height: 30)
} else {
  return JournalEntryCell.cellSize(width: width, text: entry.text)
}

How this code works:

  1. The collectionContext is a weak variable and must be nullable. Though it should never be nil, it’s best to take precautions and Swift guard makes that simple.
  2. ListCollectionContext is a context object with information about the adapter, collection view and view controller that’s using the section controller. Here you get the width of the container.
  3. If the first index (a date cell), return a size as wide as the container and 30 points tall. Otherwise, use the cell helper method to calculate the dynamic text size of the cell.

This pattern of dequeuing a cell of different types, configuring and returning sizes should all feel familiar if you’ve ever worked with UICollectionView before. Again, you can refer back to ClassicFeedViewController and see that a lot of this code is almost exactly the same.

Now you have a section controller that receives a JournalEntry object and returns and sizes two cells. It’s time to bring it all together.

Back in FeedViewController.swift, replace the contents of listAdapter(_:sectionControllerFor:) with the following:

return JournalSectionController()

Whenever IGListKit calls this method, it returns your new journal section controller.

Build and run the app. You should see a list of journal entries:

IGListKit

Adding Messages

JPL engineering is pretty happy that you got the refactor done so quickly, but they really need to establish communication with the stranded astronaut. They’ve asked you to integrate the messaging module ASAP.

Before you add any views, you first need the data.

Open FeedViewController.swift and add a new property to the top of FeedViewController:

let pathfinder = Pathfinder()

PathFinder() acts as a messaging system, and represents the physical Pathfinder rover the astronaut dug up on Mars.

Locate objects(for:) in your ListAdapterDataSource extension and modify the contents to match the following:

var items: [ListDiffable] = pathfinder.messages
items += loader.entries as [ListDiffable]
return items

You might recall that this method provides data source objects to your ListAdapter. The modification here adds the pathfinder.messages to items to provide messages for a new section controller.

Note: You have to cast the entries array to make the Swift compiler happy. The objects already conform to IGListDiffable.

Right-click the SectionControllers group to create a new ListSectionController subclass named MessageSectionController. Add the IGListKit import to the top:

import IGListKit

With the compiler happy, you’ll leave the rest unchanged for now.

Go back to FeedViewController.swift and update listAdapter(_:sectionControllerFor:) in the ListAdapterDataSource extension so it appears as follows:

if object is Message {
  return MessageSectionController()
} else {
  return JournalSectionController()
}

This now returns the new message section controller if the data object is of type Message.

The JPL team wants you to set up MessageSectionController with the following requirements:

  • Receives a Message.
  • Has a bottom inset of 15 points.
  • Returns a single cell sized using the MessageCell.cellSize(width:text:) method.
  • Dequeues and configures a MessageCell using the Message object’s text and user.name values to populate labels.
  • Displays the Message object’s user.name value in all capitals.

Give it a shot! The team drafted up a solution below in case you need help.

[spoiler title=”MessageSectionController”]

import IGListKit

class MessageSectionController: ListSectionController {
  var message: Message!
  
  override init() {
    super.init()
    inset = UIEdgeInsets(top: 0, left: 0, bottom: 15, right: 0)
  }
}

// MARK: - Data Provider
extension MessageSectionController {
  override func numberOfItems() -> Int {
    return 1
  }
  
  override func sizeForItem(at index: Int) -> CGSize {
    guard let context = collectionContext else {
      return .zero
    }
    return MessageCell
      .cellSize(width: context.containerSize.width, text: message.text)
  }
  
  override func cellForItem(at index: Int) -> UICollectionViewCell {
    let cell = collectionContext?
      .dequeueReusableCell(of: MessageCell.self, for: self, at: index) 
        as! MessageCell
    cell.messageLabel.text = message.text
    cell.titleLabel.text = message.user.name.uppercased()
    return cell
  }
  
  override func didUpdate(to object: Any) {
    message = object as? Message
  }  
}

[/spoiler]

Once you’re ready, build and run to see messages integrated into the feed!

IGListKit

Weather on Mars

Your astronaut needs to be able to get the current weather in order to navigate around obstacles like dust storms. JPL built another module that displays the current weather. There’s a lot of information in there though, so they ask that the weather only display when tapped.

IGListKit

Create one last section controller named WeatherSectionController. Start the class off with an initializer and some variables:

import IGListKit

class WeatherSectionController: ListSectionController {
  // 1
  var weather: Weather!
  // 2
  var expanded = false
  
  override init() {
    super.init()
    // 3
    inset = UIEdgeInsets(top: 0, left: 0, bottom: 15, right: 0)
  }
}

What this code does:

  1. This section controller will receive a Weather object in didUpdate(to:).
  2. expanded is a Bool used to track whether the astronaut has expanded the weather section. You initialize it to false so the detail cells are initially collapsed.
  3. Just like the other sections, use a bottom inset of 15 points.

Now add an extension to WeatherSectionController and override three methods:

// MARK: - Data Provider
extension WeatherSectionController {
  // 1
  override func didUpdate(to object: Any) {
    weather = object as? Weather
  }

  // 2
  override func numberOfItems() -> Int {
    return expanded ? 5 : 1
  }
  
  // 3
  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)
    }
  }
}

Here’s how this works:

  1. In didUpdate(to:), you save the passed Weather object.
  2. If you’re displaying the expanded weather, numberOfItems() returns five cells that will contain different pieces of weather data. If not expanded, you need only a single cell to display a placeholder.
  3. The first cell should be a little larger than the others, as it displays a header. You don’t have to check the state of expanded because that header cell is the first cell in either case.

Next you need to implement cellForItem(at:) to configure the weather cells. Here are some detailed requirements:

  • The first cell should be of type WeatherSummaryCell, others should be WeatherDetailCell.
  • Configure the weather summary cell with cell.setExpanded(_:).
  • Configure four different weather detail cells with the following title and detail labels:
    1. “Sunrise” with weather.sunrise
    2. “Sunset” with weather.sunset
    3. “High” with "\(weather.high) C"
    4. “Low” with "\(weather.low) C"

Give this cell setup a shot. The solution is just below.

[spoiler title=”WeatherSectionController.cellForItem(at index:)”]

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 = "SUNRISE"
      detail = weather.sunrise
    case 2:
      title = "SUNSET"
      detail = weather.sunset
    case 3:
      title = "HIGH"
      detail = "\(weather.high) C"
    case 4:
      title = "LOW"
      detail = "\(weather.low) C"
    default:
      title = "n/a"
      detail = "n/a"
    }
    cell.titleLabel.text = title
    cell.detailLabel.text = detail
  }
  return cell
}

[/spoiler]

The last thing that you need to do is toggle the section expanded and update the cells when tapped. Override another method from ListSectionController:

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:) batches and performs updates in the section in a single transaction. You can use this whenever the contents or number of cells changes in the section controller. Since you toggle the expansion with numberOfItems(), this will add or remove cells based on the expanded flag.

Return to FeedViewController.swift and add the following near the top of FeedViewController, with the other properties:

let wxScanner = WxScanner()

WxScanner is the model object for weather conditions.

Next, update objects(for:) in the ListAdapterDataSource extension so that it looks like the following:

// 1
var items: [ListDiffable] = [wxScanner.currentWeather]
items += loader.entries as [ListDiffable]
items += pathfinder.messages as [ListDiffable]
// 2
return items.sorted { (left: Any, right: Any) -> Bool in
  guard let 
    left = left as? DateSortable, 
    let right = right as? DateSortable 
    else {
      return false
  }
  return left.date > right.date
}

You’ve updated the data source method to include currentWeather. Here are details on what this does:

  1. Adds the currentWeather to the items array.
  2. All the data conforms to the DataSortable protocol, so this sorts the data using that protocol. This ensures data appears chronologically.

Finally, update listAdapter(_:sectionControllerFor:) to appear as follows:

if object is Message {
  return MessageSectionController()
} else if object is Weather {
  return WeatherSectionController()
} else {
  return JournalSectionController()
}

This returns a WeatherSectionController when a Weather object appears.

Build and run again. You should see the new weather object at the top. Try tapping on the section to expand and contract it.

IGListKit

Performing Updates

JPL is ecstatic about your progress!

While you were working, the director of NASA coordinated a rescue operation for the astronaut, requiring him to launch and intercept with another ship! It’s going to be a complicated launch, so he’ll have to liftoff at precisely the right time.

JPL engineering extended the messaging module with real-time chat and they are asking you to integrate it.

Open FeedViewController.swift and add the following lines to the end of viewDidLoad():

pathfinder.delegate = self
pathfinder.connect()

The Pathfinder module is all patched up with real-time support. All you need to do is connect to the unit and respond to delegate events.

Add the following extension to the bottom of the file:

// MARK: - PathfinderDelegate
extension FeedViewController: PathfinderDelegate {
  func pathfinderDidUpdateMessages(pathfinder: Pathfinder) {
    adapter.performUpdates(animated: true)
  }
}

FeedViewController now conforms to PathfinderDelegate. The single method performUpdates(animated:) tells the ListAdapter to ask its data source for new objects and then update the UI. This handles objects that are deleted, updated, moved or inserted.

Build and run to see the captain’s messages updating! All you had to do was add one single method for IGListKit to determine what has changed in the data source and animate the changes when new data arrives:

IGListKit

All you need to do now is transmit the latest build to the astronaut and he’ll be coming home. A job well done!

Where to Go From Here?

You can download the completed version of the project using the Download Materials button at the top or bottom of this tutorial.

Aside from bringing a stranded astronaut home, you’ve learned a lot about the basic features of IGListKit: section controllers, adapters and how to bring them all together. There are other important features in IGListKit like supplementary views and display events.

You can read and watch more about the origin of IGListKit at Instagram from a talk published by Realm. This talk covers a lot of the common UICollectionView problems that apps experience as they get larger and more complex.

If you’re interested in helping contribute to IGListKit, the team set up starter-task tags on GitHub for an easy way to get started.

If you have any questions or comments about this tutorial or working with IGListKit in general, please join the forum discussion below!

Contributors

Comments