Active Filters: Collection Views

Today we'll continue looking at UICollectionView's more advanced capabilities by checking out a little-known feature hiding inside UICollectionViewController.

It allows us to automatically animate the transition between layouts when pushing on to a navigation controller stack. Neat!

Let's give it a try.

We'll start with the same simple baseline collection view controller we used in Bite #306, and Bite #307, SquaresViewController.

It looks like this:

class SquaresViewController: UICollectionViewController {
  var items = [Item]()

  override func collectionView(
    _ collectionView: UICollectionView, 
    numberOfItemsInSection section: Int
  ) -> Int {
    return items.count
  }

  override func collectionView(
    _ collectionView: UICollectionView, 
    cellForItemAt indexPath: IndexPath
  ) -> UICollectionViewCell {
    let cell = collectionView
      .dequeueReusableCell(
        withReuseIdentifier: "ItemCell", 
        for: indexPath
      )

    cell.backgroundColor = items[indexPath.item].color

    return cell
  }
}

Nothing fancy, just standard UICollectionViewController stuff.

Next, we'll subclass it twice. Once for our "small" cells:

class SmallViewController : SquaresViewController {
  init() {
    let layout = UICollectionViewFlowLayout()
    layout.itemSize = CGSize(width: 50, height: 50)

    super.init(collectionViewLayout: layout)

    items = (0...50).map { _ in Item(color: .random()) }
  }

We'll fill it up with some random items, like we did previously, here in our "small" subclassed view controller.

Then, when a user selects one of the cells, we'll push on a "big" view controller, setting its items property to our own:

  override func collectionView(
    _ collectionView: UICollectionView, 
    didSelectItemAt indexPath: IndexPath
  ) {
    let bigVC = BigViewController()

    bigVC.items = items

    navigationController?
      .pushViewController(bigVC, animated: true)
  }
}

Again, nothing really too crazy here. Lastly, before we can try it out, lets make our BigViewController:

class BigViewController : SquaresViewController {
  init() {
    let layout = UICollectionViewFlowLayout()

    layout.itemSize = CGSize(width: 100, height: 100)

    super.init(collectionViewLayout: layout)
  }

We'll make our layout's itemSize property a little larger than before, then we'll override collectionView(_:didSelectItemAt:) one more time.

This time we'll simply call popViewController:

  override func collectionView(
    _ collectionView: UICollectionView, 
    didSelectItemAt indexPath: IndexPath
  ) {
    navigationController?.popViewController(animated: true)
  }
}

If we build and run now, everything works as expected, but there's no fancy transition happening. Just a regular navigation controller push.

Let's fix this.

We'll add one line of code to SmallViewController's init function, setting useLayoutToLayoutNavigationTransitions to false:

class SmallViewController : SquaresViewController {
  init() {
    let layout = UICollectionViewFlowLayout()    
    layout.itemSize = CGSize(width: 50, height: 50)

    super.init(collectionViewLayout: layout)

    useLayoutToLayoutNavigationTransitions = false
    // .. 
  }

  // ..

Then we'll do set the same property to the true in BigViewController:

class BigViewController : SquaresViewController {
  init() {
    let layout = UICollectionViewFlowLayout()
    layout.itemSize = CGSize(width: 100, height: 100)

    super.init(collectionViewLayout: layout)

    useLayoutToLayoutNavigationTransitions = true
  }

  // ..

That's it. UIKit will notice that we've set our BigViewController's useLayoutToLayoutNavigationTransitions to true, and will automatically animate the transition between the two layouts.

Note that the automatic transition animation that UIKit renders is smart enough to make sure the selected cell is still visible after we push in. A nice touch.

It's always fun to discover the handy little behaviors hiding out in to UIKit.

Finally, a couple of important notes:

First, the same UICollectionView is actually reused when all of this magic happens. The pushed on view controller will not create its own. This may or may not matter for any given app, but good to know.

Second, the root view controller (SmallViewController in our case) will still be set as the delegate and dataSource when the new view controller is pushed on.

If we needed to change this behavior, we could conform to the UINavigationControllerDelegate protocol and change these values each time a different view controller is about to be be shown. The code might look something like this:

extension SquaresViewController : UINavigationControllerDelegate {
  func navigationController(
    _ navigationController: UINavigationController, 
    willShow viewController: UIViewController, 
    animated: Bool
  ) {
    guard let squaresVC = viewController as? SquaresViewController else { return }

    squaresVC.collectionView?.delegate = squaresVC
    squaresVC.collectionView?.dataSource = squaresVC
  }
}

That's all for today, have a specific UICollectionView question you'd like answered? Send it along!.

Download the Xcode project we built in this Bite right here.

Today we'll continue our look at UICollectionView by diving into animation. Let's begin.

We'll start with the same simple baseline collection view controller we made at the beginning of Bite #306.

Now, we'll add two new collection view layouts to our view controller:

var small: UICollectionViewFlowLayout = {
  let layout = UICollectionViewFlowLayout()

  layout.itemSize = CGSize(width: 75, height: 75)

  return layout
}()

var big: UICollectionViewFlowLayout = {
  let layout = UICollectionViewFlowLayout()

  layout.itemSize = CGSize(width: 150, height: 150)

  return layout
}()

Then, we'll override init on our view controller, and hand it our "small" layout:

init() {
  super.init(collectionViewLayout: small)
}

Nice. At this point we have a basic collection view controller, scrolling our small cells:

Now, we'll add some code to toggle between our two layouts when the user taps any cell:

override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
  let newLayout = (collectionView.collectionViewLayout == small ? big : small)

  collectionView.setCollectionViewLayout(newLayout, animated: true)
}

Now we can toggle back and forth between our two layouts with a nice looking animation. Very cool.

It's super handy how the setCollectionViewLayout function is smart enough to alter the animtion so the cell we selected is still in view once the animation completes.

We can see this effect exaggerated when we bump up the itemSize of our "big" layout:

var big: UICollectionViewFlowLayout = {
  let layout = UICollectionViewFlowLayout()

  layout.itemSize = CGSize(width: 300, height: 300)

  return layout
}()

Nice!

Download the Xcode project we built in this Bite right here.

One final note here: This just one approach. There are tons of different ways to achieve this (and similar) behavior. We'll take a look at more as we dive deeper into collection views in upcoming Bites.

Have a specific UICollectionView question you'd like answered? Send it along!.

UICollectionView has always been a powerhouse. Its variety and versatility are tough to understate.

We've covered it a bit here on LBOC, but we've still only just scratched the surface. Today we'll begin to dive deeper in what all it can do by customizing how new collection view cells animate when they're being inserted. Let's begin!

We'll start by creating a new single view app in Xcode.

We'll drag out a UICollectionViewController in Interface Builder, set its class to be the ViewController one that comes with the Xcode template, then update ViewController.swift to have some basics:

class ViewController: UICollectionViewController {
  var items = [Item]()

  func addItem() { items.append(Item(color: .random())) }

  override func viewDidLoad() {
    super.viewDidLoad()
    for _ in 0...10 { addItem() }
  }

  override func collectionView(
    _ collectionView: UICollectionView, 
    numberOfItemsInSection section: Int
  ) -> Int {
    return items.count
  }

  override func collectionView(
    _ collectionView: UICollectionView, 
    cellForItemAt indexPath: IndexPath
  ) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(
      withReuseIdentifier: "ItemCell", 
      for: indexPath
    )

    cell.contentView
      .backgroundColor = items[indexPath.item].color

    return cell
  }  
}

Next, we'll embed it in a UINavigationController, and add a UIBarButtonItem to add new items.

We'll Control+Drag the bar button item into our ViewController.swift as a new add: action. We'll wire up it like so:

@IBAction func add(_ sender: UIBarButtonItem) {
  addItem()

  let indexPath = IndexPath(
    item: self.items.count - 1, 
    section: 0
  )

  collectionView?.performBatchUpdates({
    self.collectionView?.insertItems(at: [indexPath])
  }, completion: nil)
}

With this, we have a nice mostly-"boilerplate" setup. Just some square, randomly-colored, cells that fade in when they're inserted:

Not bad. Now let's customize the behavior of those cells as they're being inserted. For this, we'll need our own custom UICollectionViewLayout subclass.

Specifically we'll subclass UICollectionViewFlowLayout and override a few functions to customize the behavior of inserted cells.

class CustomFlowLayout : UICollectionViewFlowLayout {
  var insertingIndexPaths = [IndexPath]()
}

We've added a property to keep track of which index paths are being inserted during each batch update of the collection view.

To populate our property we'll need to override a couple of functions:

override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
  super.prepare(forCollectionViewUpdates: updateItems)

  insertingIndexPaths.removeAll()

  for update in updateItems {
    if let indexPath = update.indexPathAfterUpdate,
                       update.updateAction == .insert {
      insertingIndexPaths.append(indexPath)
    }
  }
}

override func finalizeCollectionViewUpdates() {
  super.finalizeCollectionViewUpdates()

  insertingIndexPaths.removeAll()
}

Nice. Nothing fancy here, we're just collecting the inserted index paths at the beginning of each update, then clearing them out at the end.

The real magic happens when we override one last function:

override func initialLayoutAttributesForAppearingItem(
  at itemIndexPath: IndexPath
) -> UICollectionViewLayoutAttributes? {
  let attributes = super.initialLayoutAttributesForAppearingItem(at: itemIndexPath)

  if insertingIndexPaths.contains(itemIndexPath) {
    attributes?.alpha = 0.0
    attributes?.transform = CGAffineTransform(
      scaleX: 0.1, 
      y: 0.1
    )
  }

  return attributes
}

What we've done here is to grab the layout attributes our collection view was going to use for the newly inserted item, and modify them slightly, before passing them along back to the system.

Here we've added a transform to scale the item down when it is first added, this will give us a nice "zoom up" effect as each item is added:

That looks neat, but let's try for something even funkier. We can change our transform line to:

attributes?.transform = CGAffineTransform(
  translationX: 0, 
  y: 500.0
)

With this one change, we can achieve an entirely different effect:

Neat!

Download the Xcode project we built in this Bite right here.

That's all for today. Have a specific UICollectionView question you'd like answered? Send it along!.

In Bite #241, we learned about all the great improvements to UICollectionView's cell lifecycle in iOS 10. Today we'll check out another iOS 10 improvement to UICollectionView: The prefetchDataSource! Let's get started.

UICollectionView gains a new property this year called prefetchDataSource. Just like the existing delegate and dataSource properties, we an simply set it to some object that implements the new UICollectionViewDataSourcePrefetching protocol.

This protocol is brand new in iOS 10, and requires we implement just one new function:

public func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath])

When this function is called, we can examine the indexPaths array we're passed in to know which cells are "coming up soon" and thus which cells we should probably begin loading the data for. The indexPaths will be ordered "ascending by geometric distance" which basically means the indexPaths that need to be displayed on screen soonest will be first.

Let's use this new functionality to let our UICollectionView pre-load the images that go in our cells, This way when they scroll on screen, the images will already be visible and won't have to "load in" in front of the user. Classy! 🎩

class SpaceshipsViewController : UICollectionViewController {
  override func viewDidLoad() {
    super.viewDidLoad()

    collectionView?.prefetchDataSource = self
  }
}

extension SpaceshipsViewController : UICollectionViewDataSourcePrefetching {
  func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
    ImagePreloader.preloadImagesForIndexPaths(indexPaths) // happens on a background queue
  }

  func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
    ImagePreloader.cancelPreloadImagesForIndexPaths(indexPaths) // again, happens on a background queue
  }
}

That's literally it. We make sure to implement the optional cancelPrefetchingForItems function so that we don't waste resources preloading images the user won't actually see.

UICollectionViewDataSourcePrefetching doesn't replace our existing data loading architecture/functionality. It's essentially a hint as to which cells will be displayed soon, and which ones we might want to begin loading content for.

Finally, just like in Bite #241, we can opt-out of this new prefetching behavior by either simply leaving prefetchDataSource set to nil, or by setting isPrefetchingEnabled to false.

Oh one more thing: All of this works exactly the same on UITableView too now, thanks to the (almost identical) new UITableViewDataSourcePrefetching protocol.

UICollectionView is one of the most important tools in our toolbox as iOS developers. Achieving smooth scrolling performance is a badge of honor for many of us. This year in iOS 10, Apple has made substantial changes to how cells make their way into collection views.

Today we'll take a look at these changes, and how we can take advantage of them in our apps. Let's dive in! 🏊

Here's the TLDR:

UICollectionView will now call cellForItemAtIndexPath: way before it used to. Sometimes the cells we return from that function won't even end up being displayed. We should proceed accordingly.

This dramatically improves scrolling performance as the system intelligently manages everything, giving us beautiful, buttery-smooth, 60fps scrolling. Neat!

Here's the slightly more wordy version:

In iOS 10, Apple has enhanced UICollectionView's ability to "pre-load" cells before they appear on screen.

Essentially, UICollectionView is now aware of the direction the user is scrolling, and will "look ahead" to cells that will be coming on to screen soon, call the appropriate existing delegate functions (like cellForItemAtIndexPath:) as needed.


Let's look at how the UICollectionViewCell lifecycle has changed in iOS 10. We'll start with how things are today, in iOS 9:

First, cellForItemAtIndexPath: is called, and we dequeue a cell. UICollectionView pulls it from the reuse queue, and calls prepareForReuse on it.

Next, we'll configure the cell to our needs. Setting the content of labels, etc.

Then, right before the cell is scrolled into view, willDisplayCell:atIndexPath: is called.

The user looks at the beautiful cell we've created until they grow tired of it, and eventually scroll it off screen.

At this point, we'll get a didEndDisplayingCell:atIndexPath: call, and our cell will re-enter the reuse queue.

If the user changes their mind and scrolls back up, the whole process starts over with cellForItemAtIndexPath: 😨


Now, how about in iOS 10? Next we'll look at how this process has improved:

In iOS 10, things are mostly the same, all the way up until the point where the cell goes off screen at the end.

Yes, didEndDisplayingCell:atIndexPath: is called as usual, but the cell is not immediately put into the reuse queue.

Instead, the system keeps it around for a bit. This way, if the user starts scrolling the other direction, the cell is already ready to be displayed on screen again. Very cool!


UICollectionView also has a new trick when working with multi-column layouts.

(This is where the β€œprefetching” bit comes in).

In these layouts, the system will optimize for scrolling performance by sending cells through their lifecycle sooner, at times determined intelligently by the system.

Cells will go through their lifecycle one at a time, each being created via an incoming call to cellForItemAtIndexPath:.

Later, once the entire "row" of cells is about to be scrolled on to screen, willDisplayCell:atIndexPath: will be called on each cell.

Apple describes cell pre-fetching as an "adaptive" technology. In other words, it will respond to how users are interacting with our apps.

For example, UICollectionView will try to look for "down time" when the user isn't scrolling (or at least isn't scrolling quickly) to prefetch some cells. If the user begins wildly scrolling very quickly, cell prefetching will stop until they slow down.


Finally, here's some general advice and info that we should consider when architecting our UICollectionView code going forward:

  • Set up our cells in cellForRowIndexPath:, try to keep the willDisplay/didEndDisplay callbacks light weight.

  • We should now expect some cells to be created (cellForItemAtIndexPath:) but never displayed.

If for some wacky reason we didn't want this wonderful new behavior, we can set our collection view's isPrefetchingEnabled property to false (it defaults to true in iOS 10).

These improvements are obviously really great, but what about actually doing the work of loading the data that gets displayed in our cells. Well, for that you'll have to come back for tomorrow's Bite!

In iOS 9, UICollectionView learned to interactively re-order cells. Let's take a look.

Before we dive in, one quick note: If we just need re-orderable cells, adopting this functionality can be incredibly simple:

override func collectionView(collectionView: UICollectionView, moveItemAtIndexPath source: NSIndexPath, toIndexPath destination: NSIndexPath) {
  let person = crew.removeAtIndex(source.item)
  crew.insert(person, atIndex: destination.item)
}

UICollectionViewController's new property called installsStandardGestureForInteractiveMovement defaults to true, so once we implement delegate function above, we're good to go.

To customize things further, we'll need to manually call UICollectionView's new interactive movement functions. Let's look at a very rough idea of how we might make the picked up cell "hover" and the others "wiggle" like iOS's home screen.

func longPressed(gesture: UILongPressGestureRecognizer) {
  let location = gesture.locationInView(collectionView)
  movingIndexPath = collectionView.indexPathForItemAtPoint(location)

  switch(gesture.state) {
  case .Began:
    guard let indexPath = movingIndexPath else { break }
    setEditing(true, animated: true)
    collectionView.beginInteractiveMovementForItemAtIndexPath(indexPath)
    pickedUpCell()?.stopWiggling()
    animatePickingUpCell(pickedUpCell())
  case .Changed:
    collectionView.updateInteractiveMovementTargetPosition(location)
  case .Ended:
    collectionView.endInteractiveMovement()
    animatePuttingDownCell(pickedUpCell())
    movingIndexPath = nil
  default:
    collectionView.cancelInteractiveMovement()
    animatePuttingDownCell(pickedUpCell())
    movingIndexPath = nil
  }
}

override func setEditing(editing: Bool, animated: Bool) {
  super.setEditing(editing, animated: true)
  startWigglingAllVisibleCells()
}

We'll also start/stop wiggling when we dequeue cells. Lastly, we'll apply the same changes that are in animatePickingUpCell to the cell's layout attributes. To do this we can subclass UICollectionViewFlowLayout and override layoutAttributesForInteractivelyMovingItemAtIndexPath.

After all that's done, this is the outcome:

Here's a direct link to this example video, just in case.

Download the project at j.mp/bite104 to see a complete working example.

Update on March 22nd, 2019: Thanks to reader Le Zeng, the project download now supports Swift 4.2. Thanks so much!