Collection View Lists in iOS 14, Part 2

In an earlier article, I introduced collection view lists, a new feature of iOS 14. I showed how you can easily make a collection view that looks pretty much just like a table view. Now I’d like to talk about some additional table view appearances and behaviors and how collection view lists implement them.

List Cells

At the end of the earlier article, I introduced UICollectionViewListCell, the collection view cell subclass intended for use with collection view lists. Let’s start by exploring this class.

What does a collection view list cell give us that a plain collection view cell does not? For one thing, it comes with a defaultContentConfiguration, as I pointed out in the earlier article. It also gives us control over indentation level and separator inset:

  • indentationLevel
  • indentationWidth
  • indentsAccessories
  • separatorLayoutGuide

In addition, it has an accessories property. This is an array of UICellAccessory objects. These are powerful little objects, so let’s talk about them now.

Accessorize This

Using the accessories array property of a UICollectionViewListCell, we can make our collection view list look and behave a lot more like a table view. To create an accessory to go into the array, we call a static method of UICellAccessory:

  • disclosureIndicator(displayed:options:)
  • checkmark(displayed:options:)
  • delete(displayed:options:actionHandler:) (leading Minus button)
  • insert(displayed:options:actionHandler:) (leading Plus button)
  • reorder(displayed:options:)
  • multiselect(displayed:options:) (leading circle with checkmark)
  • outlineDisclosure(displayed:options:actionHandler:)
  • label(text:displayed:options:) (brief text)
  • customView(configuration:) (any UIView)

Here’s what the parameters mean:

  • displayed: — Lets you specify the cell states in which the accessory should be visible: .always, .whenEditing, and .whenNotEditing. The default, if you omit this parameter, will be the value appropriate to the particular accessory; a checkmark’s default is .always, but a delete button’s default is .whenEditing.

  • options: — The options: depend on the particular accessory and have mostly to do with color and layout.

  • actionHandler: — Some interactive accessories let you intervene with a custom response when the user taps the accessory. For the most part, you won’t need to do so; for example, a delete accessory, by default, when the user taps it, reveals the trailing swipe actions, which is usually what you want.

  • text: — The text for the label accessory.

  • configuration: — This is how you set up a custom view accessory. It’s a UICellAccessory.CustomViewConfiguration, which is a value type (a bunch of properties). The most important property is the customView! You can also configure the view’s size, position, and tint color.

Most of the parameters are optional; it will often be sufficient to call the static method with no parameters at all.

What a Drag

To illustrate the use of accessories, let’s start by allowing the user to drag cells to reposition them.

Our first step is to give our cells a reorder control. This is the little view that looks like three lines, familiar from table views. The user drags on this view to drag the cell. I’ll add the accessory to every cell, at the time I register and configure the cell:

let reorder = UICellAccessory.reorder()
cell.accessories = [reorder]

We run the app, and we don’t see any reorder control in the cells. That’s because the default, which we accepted by calling reorder with no parameters, is that drag accessories are visible only when the collection view is in edit mode. So if we want to proceed, we need a way to get the collection view into edit mode!

Unfortunately, there is no default way to do that. If we’re using a collection view controller, it comes with an editButtonItem that we can put into the interface; when tapped, it toggles between saying Edit and Done. But unlike a table view controller, a collection view controller’s edit button item doesn’t actually do anything. I regard that as a bug, but I also have a way to work around it. First, when we retrieve the edit button item, we’ll give it an action that toggles the collection view controller’s isEditing property:

self.navigationItem.rightBarButtonItem = self.editButtonItem
self.navigationItem.rightBarButtonItem?.primaryAction = UIAction(title:"Edit") { _ in
    self.setEditing(!self.isEditing, animated: true)
}

Second, we override the collection view controller’s setEditing(_:animated:) method so that it changes the collection view’s isEditing property to match:

override func setEditing(_ editing: Bool, animated: Bool) {
    super.setEditing(editing, animated:animated)
    self.collectionView.isEditing = editing
}

Okay, that was really annoying to have to do, but it wasn’t difficult. And now we run the app and tap the Edit button — and presto, the drag accessories appear! But they still don’t do anything; the user cannot drag one to reorder the cells. That’s because, in iOS 14, what makes a collection view cell draggable when you are using a diffable data source is the diffable data source’s reordering handlers. In particular, we have to implement the canReorderItem handler:

self.datasource.reorderingHandlers.canReorderItem = { item in return true }

The next step is: nothing! Our cells are now draggable. The user can rearrange them, and the diffable data source is rearranged to match whatever the user did, automatically.

In our list of U.S. states, it isn’t very nice for the user to move a state out of its section. If a state name begins with “A”, it should not wind up in the “C” section. So let’s also implement the delegate method to prevent that:

override func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
    print("here")
    if originalIndexPath.section == proposedIndexPath.section {
        return proposedIndexPath
    }
    return originalIndexPath
}

Honey, I Deleted California!

Adding a delete (Minus) button is just as easy as adding a reorder control. It’s just another accessory:

let reorder = UICellAccessory.reorder()
let delete = UICellAccessory.delete()
cell.accessories = [delete, reorder]

We put the list into edit mode, and we see the Minus button at the left of every cell. We tap the Minus button — and nothing happens. That’s because this button reveals the cell’s trailing swipe actions, and we haven’t given the cells any trailing swipe actions. Let’s fix that.

A swipe action is a feature of the UICollectionLayoutListConfiguration that we created right at the start before we even initialized our collection view layout. (Does anyone other than me think that the distribution of responsibilities here is really confusing?) It has leadingSwipeActionsConfigurationProvider and trailingSwipeActionsConfigurationProvider properties that are functions taking an index path. Your function can return different swipe actions, or nil, for different rows of the list.

At this point we’re working in the world of UIContextualActions, familiar from table views, so I presume I don’t need to tell you any more about that. Let’s just go ahead and give our cells a Delete trailing action:

var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.trailingSwipeActionsConfigurationProvider = { ip in
    let delete = UIContextualAction(style: .destructive, title: "Delete") { action, view, completion in
        completion(true)
    }
    let swipe = UISwipeActionsConfiguration(actions: [delete])
    return swipe
}
// ... and so on

We run the app, and sure enough, when we put the list into edit mode, the Minus button is present, and when we tap the Minus button, the cell slides left, and the Delete button is visible. We tap the Delete button and the cell slides back into place. Everything is working perfectly — except, of course, that the cell is not actually being deleted. Let’s fix that. Instead of just calling completion(true), I’ll also call a method delete(at:), like this:

let delete = UIContextualAction(style: .destructive, title: "Delete") { action, view, completion in
    self.delete(at: ip)
    completion(true)
}

Of course, I also have to write that method. But that’s easy if all I want to do is delete the item. I’ve got the index path, I’ve got a diffable data source, so away we go:

func delete(at ip: IndexPath) {
    var snap = self.datasource.snapshot()
    if let ident = self.datasource.itemIdentifier(for: ip) {
        snap.deleteItems([ident])
    }
    self.datasource.apply(snap)
}

In real life, I would prefer that when we delete the last remaining item in a section, the section should also be deleted. I have a way to do that, but I think I’ll leave it as an exercise for the reader.

Under the Table

Our collection view list is looking and behaving just like a table view.

looks like a duck

The user will never know, or care, what sort of view this is; it looks and feels familiar. There’s more to explore, of course, but that should be sufficient to get you started with list collection views.

You Might Also Like…

Collection View Outlines in iOS 14

In an earlier article, I introduced collection view lists, new in iOS 14, which allow a collection view, or a section of a collection view, to imitate a table view. Collection view lists are the basis for yet another new iOS 14 feature: outlines. As Lovely as a Tree An outline is a way of …

Collection View Outlines in iOS 14 Read More »

    Sign Up

    Get more articles and exclusive content that takes your iOS skills to the next level.