acb's technical journal

KFIndexBar, a zoomable index bar for collection views

The MPDluxe app, an iOS-based remote controller for the MPD music playing software, uses UICollectionViews to display the contents of directories; these are displayed in one vertically scrolling column on the narrow screen of the iPhone, or a number of columns, scrolling horizontally, on the iPad. Early versions of the app, which worked only on the iPhone, used a UITableView, which provided a convenient index bar down the right-hand side of the view, allowing the user to quickly navigate long directory listings by swiping down a line of headings (in this case, letters of the alphabet). UICollectionView is much more flexible than UITableView, but the downside of this is that conveniences such as index bars are not provided; if you need one, you have to implement it yourself. Thankfully, there exists at least one implementation (Ben Kreeger's BDKCollectionIndexView, a UIControl subclass you can add on top of your collection view). After moving to using UICollectionView, MPDluxe used this class.

The problem with one-level index bars is that they do not cope well with more fine-grained navigation. For example, imagine a long list of thousands of items (such as names) in alphabetical order; scrolling to the first letter gets you only so far. It would be good to be able to zoom in, and navigate, say, between the second letters. (One potential model for how this could be done is the transport control in Apple's QuickTime Player; drag left or right to move the playback position backward or forward. However, if you drag down, then dragging left or right moves the position more finely, letting you hone in more precisely on the position you're looking for.) So I started to write a new index control, which would allow this sort of control, which became KFIndexBar.

KFIndexBar looks much like the index bar in a UITableView; it displays a set of labels over a tinted background. Touching a letter changes its value, allowing the code it connects to to scroll its collection view appropriately. As with BDKCollectionView, it also supports a horizontal orientation, allowing it to display its labels along the bottom of a collection view, rather than down the right-hand side. However, the main user-facing difference is that, if the user touches the index bar and drags to the left, a gap opens below the currently touched top-level index, and fills with intermediate indices between it and the index below it. (For example, in an alphabetical index bar, touching the label for "A" and dragging left might open a set of secondary labels reading "AA", "AD", "AF", and so on; once opened, dragging over these will scroll to the relevant location.) The user can then drag over those, scrolling to any one of them.

Below the cut, I will discuss implementation details:

Unlike UITableView, KFIndexBar gets its indices from a data source, which is anything implementing the KFIndexBarDataSource protocol. This protocol has two methods: the first one, topLevelMarkers(forIndexBar:), returns the list of markers (i.e., labels with offset values) displayed at the top level, when not zoomed in. The second, indexBar(: markersBetween:and:), is called when the index bar needs a list of the second-level markers between two top-level markers, whose offsets are specified. Both functions return an array of KFIndexBar.Marker values; these are defined as follows:

struct KFIndexBar.Marker {
    let label: String
    let offset: Int
}
(Where label is the text displayed and offset is the index into the list of items to scroll to if this label is selected. Currently KFIndexBar only supports one-dimensional lists of items, so indices are Ints, not IndexPaths.)

Implementation details

KFIndexBar is implemented entirely in Swift 4, and uses the language's type system, as seen in the use of the nested struct above. For reasons of compatibility, it is a subclass of UIControl and sends a valueChanged event when the offset changes. The last offset scrolled to may be read from its currentOffset variable.

Internally, most of KFIndexBar's behaviour is governed by a state machine, implemented as a tagged enum of the type KFIndexBar.InteractionState. Each state also has any state-specific data attached to it that is relevant; for example, if the user is zooming in on the inner markers below a top-level marker, the zooming state includes the index of the top-level marker the bar is opening beneath, the extent to which it is opened and representations of the inner markers to display. The InteractionState type looks as follows:

enum InteractionState {
    // Not currently being touched or animating;
    case ready
    // The user is dragging along the top-level markers
    case draggingTop
    // A transient state when the user has dragged to initiate a zoom;
    // this will go to either .zooming (if a zoom is possible) or back to .draggingTop (if not), with the
    // relevant data filled in
    case userDraggedToZoom(underTopIndex: Int)
    // the view is being dragged between top-level and a fully opened zoom level
    case zooming(topIndex: Int, innerMarkers: [DisplayableMarker], extent: CGFloat)
    // the view is fully opened, and the user is selecting from the inner-level items
    case draggingInner(topIndex: Int, innerMarkers: [DisplayableMarker])
    // This state sets up animations and transitions immediately to animatingShut
    case startAnimatingShut(from: CGFloat, topIndex: Int, innerMarkers: [DisplayableMarker])
    case animatingShut(topIndex: Int, innerMarkers: [DisplayableMarker], extent: CGFloat, lastFrameTime: CFTimeInterval)
}
  
var interactionState: InteractionState = .ready {
    didSet(previous) {
        ....
    }
}
Several of these states (userDraggedToZoom and startAnimatingShut) are transitory; upon entering that state, the state machine will immediately proceed to another state. This allows the state machine to set up the new state, gathering its data (i.e., retrieving the inner markers to display and filling in the new zooming state with them), or even to determine which state to enter (i.e., if there are fewer than two markers between any two top-level markers, KFIndexBar should refuse to zoom in between them; in which case, if userDraggedToZoom retrieves a list of fewer than two markers, it will transition back to the draggingTop state.

The current interaction state is kept in the interactionState instance variable. A didSet method function responds to changes in this state, and is where the bulk of the state machine implementing KFIndexBar's behaviour is implemented. It is effectively a large switch statement, using pattern matching to extract the relevant data fields. For example, the userDraggedToZoom logic described above looks like:

var interactionState: InteractionState = .ready {
    didSet(previous) {
        switch(self.interactionState) {
 ...
        case .userDraggedToZoom(let topIndex):
            guard
                let innerMarkers = self.getInnerMarkers(underTopMarker: topIndex),
                innerMarkers.count >= 2
            else {
                self.interactionState = .draggingTop
                break
            }
            let displayables = self.makeDisplayable(innerMarkers)
            self.lineModel.setInnerItemSizes(displayables.map { $0.size }, openBelow: topIndex)
            self.interactionState = .zooming(topIndex: topIndex, innerMarkers: displayables, extent: 0.0)
            break
 . . .
        }
    }
}

With the logic being in the state machine, the event-handling code, which responds to user touches, is made a lot simpler. For example, part of the touch tracking code looks like:

override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
    let loc = touch.location(in: self)
    let zc = self.zoomingCoord(loc), sc = self.selectionCoord(loc)
    let zoomExtent = min(1.0, max(0.0, -(zc / self.zoomDistance)))
        
    switch(self.interactionState) {
    case .draggingTop:
        if let topIndex = self.topLabelIndex(forPosition: sc) {
            if zc >= 0.0, let offset = self.topDisplayableMarkers?[topIndex].offset {
                self.currentOffset = offset
            }
            if zc < 0.0 {
                self.interactionState = .userDraggedToZoom(underTopIndex: topIndex)
            }
        }
        ...
        case .zooming(let topIndex, let innerMarkers, _):
        if zoomExtent == 1.0 {
            self.interactionState = .draggingInner(topIndex: topIndex, innerMarkers: innerMarkers)
            } else if zoomExtent == 0.0 {
            self.interactionState = .draggingTop
        } else {
            self.interactionState = .zooming(topIndex: topIndex, innerMarkers: innerMarkers, extent: zoomExtent)
        }
    }
    ...
}
The first case handles dragging along the top-level bar (setting the current offset if the touch is on the bar itself, and requesting to transition to the zooming state if dragged leftward/upward); the second, transitions from the zooming state (going to dragging on inner labels if dragged fully out of the bar, returning to top-level dragging if dragged fully back, or updating the extent otherwise).

One advantage of using an enum-based state machine to store a user interface element's internal state is that it more precisely models the control's state and eliminates nonsensical values. We are never faced with the question of what the value of the innerMarkers array is when the user is not zooming in or dragging along inner markers, because this value only exists in various states where inner markers exist. Also, the use of data tied to states forces the flow of the data between states to be considered precisely. Were an optional innerMarkers value to exist in all states, there would be the possibility of this value being left uncleared between zoom operations, and not being properly initialised. By forcing the source of a state's data to be specified explicitly, a class of potential bugs is eliminated.

Of course, KFIndexBar cannot store all of its state in a state machine. For one, UIKit interface elements have their own state, such as screen location, opacity and whether they are hidden, which needs to be set. The natural place to set those is at various state transitions. Also, in KFIndexBar, part of the state—specifically, the positions of labels on their lines—is abstracted into its own model, KFIndexBar.LineModel

LineModel, as the name suggests, models the geometry of the index bar in its minimal form: as two one-dimensional lines, along which labels are placed. It is initialised with the length of the lines (i.e., the height or width of the index bar on the screen), and given the sizes (i.e., heights or widths, depending on orientation) of its items (the labels). From this, it computes the labels' midpoints along the lines. It does this for the outer line, when zoomed out, and for the inner line when zoomed in, and from these, it can compute any intermediate zoom position by moving outer labels outward around the centre of the zooming operation and expanding inner labels into the gap. It also computes the offset of the zoomed-in labels, keeping the centre of the inner labels as close to centred around the user's finger position as possible whilst keeping them on screen.

The reason for separating this functionality into LineModel is mostly for testability and reliability. As its responsibility is limited to geometry, and its logic is at least slightly complicated, LineModel lends itself much more to unit testing than a user interface class, which draws on the screen and responds to events, would. And keeping such complex logic outside of user interface code allows for better assurance that this logic will be correct.

KFIndexBar is available on GitHub under a MIT licence. All the code for the control is in one source file, KFIndexBar.swift. It also comes with an example project, demonstrating its use for navigating a list of 1,100 alphabetically ordered surnames.

There are no comments yet on "KFIndexBar, a zoomable index bar for collection views"

Want to say something? Do so here.

Post pseudonymously

Display name:
URL:(optional)
To prove that you are not a bot,
please enter the text in the image on the right
in the field below it.

Your Comment:

Please keep comments on topic and to the point. Inappropriate comments may be deleted.

Note that markup is stripped from comments; URLs will be automatically converted into links.