Mimicking Apple’s App Store Interface on tvOS

In this post, we will be focusing on mimicking the UI of Apple’s App Store on the tvOS, by referencing an app we developed here at Hipo, called Popcorn Trailers. One of the features of this app was an infinitely scrolling view, similar to the tvOS App Store, that displayed movie related content. Now, infinite scrollers are no strangers to seasoned iOS developers, with multiple approaches being easy to find online, but how should one approach this problem when developing a solution on tvOS, with its slightly different interaction model?

Here is an example of the UI an app like this may have. Here we have rows of horizontal collection views, housed in one large vertical UICollectionView. Our main point of interest is the topmost row, which is going to be an infinitely scrolling UICollectionView, a so-called “carousel”.

alt

Before we begin tackling the scrolling logic, let’s create a few extension methods that will help us in a moment. Create an array extension that contains the following function:

extension Array {

func shiftRight(by amount: Int = 1) -> [Element] {
    var amountMutable = amount

    if (amountMutable < 0) {
        amountMutable += count
    }

    return Array(self[amountMutable ..< count] + self[0 ..<                           amountMutable])
    }

    mutating func shiftRightInPlace(amount: Int = 1) {
    self = shiftRight(by: amount)
    }
}

As the names suggest, these functions will allow us to shift an array forwards and backwards by a specified amount, in place if we choose to. We will be using these functions to manipulate the datasource of our carousel collectionView at critical points to create the illusion of scrolling in one direction indefinitely.

As per our target design, we need to show the previous and next views along with the current view in our carousel. In order to accomplish that and also to make it easier to manage scrolling logic, we will construct our data source to contain 3 copies of each item we wish to display. Simply add your items to your datasource array 3 times like this:

carouselElements.append(contentsOf: elements)
carouselElements.append(contentsOf: elements)
carouselElements.append(contentsOf: elements)

Doing so will allow us to use the above extension functions to shift forwards or backwards at certain indexPaths seamlessly so that we can keep going in the same direction.

Now that our data source is ready, we can begin working on managing focus while scrolling. We will define an enum to describe the direction in which we should shift when we reach the furthest point in a given direction.

enum CarouselShiftDirection {
case right
case left
}

In tvOS, we are able to redirect focus to desired UI elements by using the setNeedsFocusUpdate() method in conjunction with the preferredFocusedView property. We need to override our carousel collection view’s preferredFocusedView property so that it returns the correct view depending on scroll direction when we call setNeedsFocusUpdate().

override var preferredFocusedView: UIView? {
    switch (shiftDirection) {
    case .left:
        return collectionView.cellForItem(at: IndexPath(item: elements.count + 1, section: 0))
    case .right:
        return collectionView.cellForItem(at: IndexPath(row: elements.count, section: 0))
}

With this implementation, calling setNeedsFocusUpdate() will transfer focus to the views returned based on shift direction. We want each item to be centred in view when focused and to handle cases where the user stops scrolling halfway into an item. So we are going to disable scrolling for the collectionView by setting isScrollEnabled = false. In the following delegate method:

collectionView(_ collectionView: UICollectionView,
                    didUpdateFocusIn context: UICollectionViewFocusUpdateContext,
                    with coordinator: UIFocusAnimationCoordinator) 

we will call:

if let focusedIndexPath = context.nextFocusedIndexPath {

currentIndexPath = focusedIndexPath

collectionView.scrollToItem(at: focusedIndexPath,
                            at: .centeredHorizontally,
                            animated: true)                
}

ensuring that focused items are always in the center while also keeping a reference to the current focused indexPath, which we will need in a moment.

alt

The last thing we need to do is to trigger the shift in direction when the user scrolls to a certain point. Since we are using a focus based scrolling approach, we can place this logic inside

scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {

if (currentIndexPath.row == carouselElements.count - (elements.count)) {

            carouselElements.shiftRightInPlace(amount: elements.count)

               collectionView.reloadData()
            collectionView.scrollToItem(at: IndexPath(item: elements.count, section: 0),
at: .centeredHorizontally,
                                                 animated: false)

            currentIndexPath = IndexPath(item: elements.count, section: 0)

            shiftDirection = .right

            setNeedsFocusUpdate()

        } else if (currentIndexPath.row == 1) {

            carouselElements.shiftRightInPlace(amount: -elements.count)

            collectionView.reloadData()
            collectionView.scrollToItem(at: IndexPath(item: elements.count + 1, section: 0),
                                                    at: .centeredHorizontally,
                                                 animated: false)

            currentIndexPath = IndexPath(item: elements.count + 1, section: 0)

            shiftDirection = .left

            setNeedsFocusUpdate()
    }

Let’s go over what this code does. First, we check to see if the currently focused indexPath is at one of the points where we need to shift to the other direction. Then, we modify our datasource array accordingly and call reloadData() to update our collectionView. We silently scroll to the new indexPath without animation. Since this indexPath will contain a cell with identical contents to the one we are currently displaying, the change will not be noticeable. Finally, we update currentIndexPath, set our shiftDirection and call setNeedsFocusUpdate() to transfer focus to the view returned by the preferredFocusedView property, which, if everything is done correctly, should be the cell we just scrolled to.

We now have a collectionView that can scroll indefinitely in either direction.