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 representing information that has a tree structure. An item of information in this tree structure is a node. A node may be a parent, meaning that it has children. A node that is not a parent, meaning that it has no children, is a leaf. A node that has no parent is a root node; it is at the top level of the structure.

Here’s a simple representation of some tree-structured information:

  • Pep
    • Manny
    • Moe
    • Jack
  • Marx
    • Groucho
    • Harpo
    • Chico
    • Other
      • Zeppo
      • Gummo

In that tree, Pep and Marx are root nodes; they have no parent. Manny, Moe, and Jack are children of Pep; they are leaf nodes, meaning they have no children of their own. Similarly, Groucho, Harpo, and Chico are children of Marx and are leaf nodes. But Other is a child of Marx and has children, namely Zeppo and Gummo, who are leaf nodes.

The iOS 14 outline representation of tree structured data is a collection view list that looks a lot like the outline above, except that it is potentially dynamic. A parent can be expanded or collapsed, meaning that it reveals or hides its children.

To cue the user to this ability of parents, a parent can have an outline disclosure control; this control, by default, toggles the visibility of the parent’s children. The convention is that if the disclosure control is pointing to the right, the node has children, but they are hidden; when the control is tapped, it swivels to point down, and the children are revealed.

Section Snapshots

In iOS 14, list collection outlines are implemented through a diffable data source, and in particular, through a new diffable data source feature — section snapshots.

A section snapshot is, as the name implies, a snapshot that applies to just one section of the data. It is of class NSDiffableDataSourceSectionSnapshot. You can use section snapshots even if your data is not hierarchical, and you might prefer to do so, as they permit an arguably cleaner way of communicating the data to and from the diffable data source.

You can just make a section snapshot by instantiating it, or you can obtain one from the diffable data source (once it is populated with sections) by calling snapshot(for:); the parameter is the section identifier.

To apply a section snapshot to the diffable data source, call apply(_:to:animatingDifferences:completion:). The second parameter is the section identifier; if the section doesn’t exist, it is created.

Manipulating the Hierarchy

Now let’s talk about what section snapshots have to do with outlines and hierarchical data.

When you are constructing a section snapshot in code, it has a method for adding items with another item as their parent — add(_:to:). The first parameter is an array of items; the second parameter is the already existing item that is to be the parent. If the second parameter is nil, you’re saying that the items of the first parameter are root items.

A section snapshot also has numerous properties reflecting the possibility that the items might be hierarchical:

  • It has a rootItems property; a root item is an item whose parent is nil (it is at the top level of the outline).
  • It has a visibleItems property; a visible item is either a root item or a child of an expanded item.
  • You can ask for an item’s parent.
  • You can ask for an item’s level; a root item’s level is 0.
  • You can ask whether an item is expanded; an item with no children is considered collapsed.
  • You can replace an item’s children.

You can also obtain an item’s children, but the procedure is a little tricky. You call snapshot(of:includingParent:) on an existing snapshot; the first parameter is the item. This gives you what Apple calls a partial snapshot. The default is not to include the parent, and in that case, the root items of the result are the children.

This Is Boring, Give An Example Already

I think now I’ll give an example, shall I?

Let’s construct a collection view list to display the hierarchical data that I presented earlier. I’ll skip all the petty details, as they are the same as for the example in the earlier article, and concentrate on the cells and the data. My data source consists of String sections and String items. The cell registration object displays the item string in the cell:

let cellreg = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, ip, s in
    var config = cell.defaultContentConfiguration()
    config.text = s
    cell.contentConfiguration = config
}

And now for the moment of truth! This is how I construct the data and apply it to the data source:

var snap = NSDiffableDataSourceSectionSnapshot<String>()
snap.append(["Pep", "Marx"], to: nil) // root
snap.append(["Manny", "Moe", "Jack"], to: "Pep")
snap.append(["Groucho", "Harpo", "Chico", "Other"], to: "Marx")
snap.append(["Zeppo", "Gummo"], to: "Other")
self.datasource.apply(snap, to: "Groups", animatingDifferences: false)

In real life, of course, we would probably start with a node-based data structure, along with a method for parsing that structure and constructing the initial snapshot. But my data here is very simple, and you can see clearly how my construction of the data corresponds to the outline I presented earlier.

The result, though, is a little disappointing:

outline with root only

That’s correct from one point of view: the two root items are displayed. But they are displayed statically. The user has no way to summon the children of the root items; in fact, the user doesn’t even know that the root items have children. The user can’t even tell that this is supposed to be an outline!

Twisting the Night Away

Our mistake is merely that we have neglected to give any cells an outline disclosure control. Let’s go back to the cell registration object and fix that:

let cellreg = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, ip, s in
    var config = cell.defaultContentConfiguration()
    config.text = s
    cell.contentConfiguration = config
    cell.accessories = [.outlineDisclosure()]
}

Okay, the result is a little better:

outline indicators wrong

The user now sees the outline indicators and can tap one to twist open a parent and reveal the children. You can see that the children are automatically indented from the parent, a nice touch that adds clarity to the interface:

outline indicators wrong 2

But we’ve gone overboard, as you can see; every cell has an outline indicator, even cells that have no children. That will never do. What we really wanted was for only cells that have children to have an outline indicator.

If you go back and look carefully at the earlier section that you complained was boring, you will see that I’ve actually told you how to learn whether an item of data has children. Start with the section snapshot for the whole section, which we get from the diffable data source. Now take a partial snapshot from that snapshot, for just the item in question, omitting the parent. If the resulting partial snapshot has items, those are the children; to put it another way, if the count of the partial snapshot’s items is zero, this is a leaf node:

let cellreg = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, ip, s in
    var config = cell.defaultContentConfiguration()
    config.text = s
    cell.contentConfiguration = config
    let snap = self.datasource.snapshot(for: "Groups")
    let snap2 = snap.snapshot(of: s, includingParent: false)
    let hasChildren = snap2.items.count > 0
    cell.accessories = hasChildren ? [.outlineDisclosure()] : []
}

Woohoo! We’ve got a working outline!

a working outline

Only parents now have outline disclosure controls. The user can twist open or closed any of those outline disclosure controls to show or hide the children.

More To Know

You can also expand or collapse a parent item in code; indeed, when the user taps to expand or collapse a parent item, by default the runtime simply does for you what you could have done manually: it takes a snapshot, expands or collapses that item, and applies the resulting snapshot.

To illustrate manual expansion and collapsing of a parent item in code, I’ll tweak the preceding example a little. There’s something about it that I don’t like: the user can select a parent row of the table. That looks wrong. To prevent it, I can implement the usual collection view delegate method shouldSelectItemAt, using the same check that I’ve already demonstrated for finding out whether this is a parent item:

override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
    let snap = self.datasource.snapshot(for: "Groups")
    let s = self.datasource.itemIdentifier(for: indexPath)!
    let snap2 = snap.snapshot(of: s, includingParent: false)
    let hasChildren = snap2.items.count > 0
    return !hasChildren
}

Excellent. But having done that, a further tweak occurs to me. Right now, the only way the user can expand and collapse an item is to click on the outline disclosure control. I’d like to make it so that the user can tap anywhere on a parent cell to toggle its expansion state. (I notice that outlines in Apple’s apps, such as the Files app, actually behave that way; naturally, that makes me jealous.)

We have already implemented shouldSelectItemAt, so we are already getting an event when the user taps a cell. Let’s just modify that implementation to add that functionality. We are already getting a section snapshot (snap) from the diffable data source; that’s where we tell an item to expand or collapse. We must then apply the snapshot back onto the diffable data source:

override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
    var snap = self.datasource.snapshot(for: "Groups")
    let s = self.datasource.itemIdentifier(for: indexPath)!
    let snap2 = snap.snapshot(of: s, includingParent: false)
    let hasChildren = snap2.items.count > 0
    if hasChildren {
        if snap.isExpanded(s) {
            snap.collapse([s])
        } else {
            snap.expand([s])
        }
        self.datasource.apply(snap, to: "Groups")
    }
    return !hasChildren
}

Now the interface is behaving just the way I want it to.

To be notified when the user expands or collapses an item, use the diffable data source’s sectionSnapshotHandlers property; this is a SectionSnapshotHandlers instance, a struct consisting entirely of properties whose values are functions:

  • shouldExpandItem
  • shouldCollapseItem
  • willExpandItem
  • willCollapseItem
  • snapshotForExpandingParent

The last one lets you determine in real time what the user should see when expanding this item. That’s valuable in case actually obtaining the data in question is expensive; instead of obtaining the data and storing it in the diffable data source up front, you obtain it only if the user happens to expand the parent.

Another way to interfere when the user taps a parent item to toggle expansion is to give the outline disclosure control an action handler function. You do that when you initialize the accessory:

let f : () -> () = { /* ... */ }
cell.accessories = [.outlineDisclosure(options: opts, actionHandler: f)]

In that case, you’ve replaced all automatic behavior; collapsing or expanding the item is now completely up to you.

Conclusion

This is the first time that Apple has provided a native interface for representing outlines in the interface of an iOS app. The prospect of being able to use this in your own apps is probably pretty exciting. I can imagine that in some places where you were representing a hierarchy by means of a sequence of table view controllers pushed onto a navigation stack, you might use a single outline instead.

But don’t go overboard. Apple’s own examples are fairly sparse and simple. For instance, take a look at the Files app. The root level, under Browse, is an outline. But the outline is just two levels, and its purpose is mostly to save vertical space. For instance, the first entry, Locations, is like a section header; its children (iCloud Drive, On My iPhone, and Recently Deleted) can be shown or hidden, just as a way of making the whole list longer or shorter. Still, that’s a good use of an outline. A section whose items can be shown or hidden is nothing new, but achieving it took a lot of work; this is a very simple way to do the same kind of thing.

You Might Also Like…

Rant: Xcode and the Protocol Paradox

This is a rant about an extremely useful Xcode feature that completely stops working just when you most need it to work. At the risk of giving the whole story away right at the start, I’ll just give the whole story away right at the start! The useful feature is Xcode’s ability to show you …

Rant: Xcode and the Protocol Paradox Read More »

    Sign Up

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