Upcoming and OnDemand Webinars View full list

Custom Collection View Layouts with tvOS: Part 2

Nick Teissler

The tvOS focus engine and collection views can bring a robust and interactive control to the television screen, but to design any layout that is non-linear, you will need to create a custom layout. Part 1 of this series walked through creating a custom collection view layout that arranged a variable number of cells in a wheel.

In this part, we’ll add rotation animations so that focus remains in the same place and the content cycles around underneath it. We’ll also walk though how to guide focus around your app with focus engine properties and callbacks. To follow along, the source code for this tutorial can be found on GitHub here.

Adding Scroll-Rotation

Moving focus around the wheel worked well, but another common way to naviagte on tvOS is to move view underneath a stationary area of focus. When a user ‘scrolls’ around in our collection view, we want the content to move and focus remain stationary as shown below.

stationaryFocus

UICollectionView has a method scrollToItem(at:at:animated:) that is responsible for scrolling the collection view contents to the specified item. We’ll override this method to rotate the collection view to bring the specified cell into focus. Subclass UICollectionView and add the method header and a property to track the current orientation of the collection view.

class WheelCollectionView: UICollectionView {

    var orientationRadians: CGFloat = 0.0 {
        didSet {
            transform = CGAffineTransform(rotationAngle: orientationRadians)
        }
    }
    
    override func scrollToItem(at indexPath: IndexPath, at scrollPosition: UICollectionViewScrollPosition, animated: Bool) {
        
    }
}

The didSet on orientationRadians rotates the entire view. We’ll wrap any calls to set this property in an animation closure provided by the coordinator object passed as an argument in focus engine callbacks. When scrollToItem(as:at:animated:) is called, the view should to position the focused item at 3 o’clock (0 radians on the unit circle).

To define the scroll behavior, we need a mathematical function of the indexPath.row variable that will output an angle with range 0-2π, with an offset to have the focused view’s center at 3 o’clock, rather than it’s top left corner.

index f(index)
0 11π/6
1 9π/6
2 7π/6
3 5π/6
4 3π/6
5 1π/6

The function that gives this is: π * ( 2 – ( 2 * index + 1) / n ). We can implement scrollToItem(at:at:animated:) now.

override func scrollToItem(at indexPath: IndexPath, at scrollPosition: UICollectionViewScrollPosition, animated: Bool) {
    let n = CGFloat(numberOfItems(inSection: indexPath.section))
    let index = CGFloat(indexPath.row)
    let radians = .pi * (2.0 - (2.0 * index + 1) / n)
    orientationRadians = radians
    
    for cell in self.visibleCells as! [CollectionViewWheelCell] {
        cell.cellImage?.transform = CGAffineTransform(rotationAngle: -CGFloat(radians))
    }
}

You’ll notice a counter-rotation of the cells in the opposite direction. To convince yourself this is needed, comment this out later. scrollToItem(at:at:animated:) isn’t called for us. We need to call it everytime the user updates focus. The focus engine provides didUpdateFocus(in:with:) for all UIFocusEnvironment adopting classes. We’ll use this to trigger a scroll guarding on the condition that the next focuesed view is a cell in the collection view.

override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
        guard let cell = context.nextFocusedView as? CollectionViewWheelCell,
            let index = indexPath(for: cell) else {
            return
        }
       
        coordinator.addCoordinatedAnimations({
            // Adjust the animation speed following Apple's guidelines:
            // https://developer.apple.com/reference/uikit/uifocusanimationcoordinator
            let duration : TimeInterval = UIView.inheritedAnimationDuration;
            UIView.animate(withDuration: (4.0*duration), delay: 0.0, options: .overrideInheritedDuration, animations: {
                self.scrollToItem(at: index, at: .right, animated: true)
            }, completion: nil)
        }, completion: nil)
    }

Don’t forget to change the type of collectionView in ViewController.swift and Main.storyboard to your UICollectionView subclass. Build and run to see your collection view at work.

Send in the Split View Controller

It’s now time to add more elements to the screen to make this control practical for use in an application. In interface builder, drag a split view controller on the screen. Delete the table view controller and replace it with a segue to your original view controller with the collection view. At this point, make sure the “Scrolling Enabled” box is unchecked in the collection view attributes inspector, as it can lead to unexpected behaviors if the collection view is too close to the edge of the screen.

Reconstrain your collection view if necessary to be contained in the left section of the screen. Delete the segue between the split view controller and the empty view controller. Reassign the initial view controller and build and run. You should see the same behavior as before, but now we have some space to grow on the screen and remain organized.

We’ll use a table view with colored cells and no data to occupy the right side of
the split view controller. Create a view controller with a table view to your liking. Be sure to add a cellColor property for visualization purposes later.

class ColorViewController: UIViewController, UITableViewDataSource, UITableViewDelegate  {

    @IBOutlet var tableView: UITableView!
    var cellColor: UIColor = UIColor.clear
    
    override func viewDidLoad() {
        tableView.dataSource = self
        tableView.delegate = self
        tableView.rowHeight = 190
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell")!
        cell.contentView.backgroundColor = cellColor
        return cell
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 20
    }
}

Configure the right side of the split view controller in interface builder with a
constrained table view and hook up the outlet.

Interface builder configuration after adding the split view controller

When a cell from the collection view is selected with a press on the remote, the
split view controller will load that cell’s content. We’ll handle these callbacks
in ViewController. Add conformance to UICollectionViewDelegate and the required delegate methods and set the collection view’s delegate to self in viewDidLoad().

extension ViewController: UICollectionViewDelegate {

    override func viewDidLoad() {
        ...
        collectionView.delegate = self
    }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        pushSplitViewController(for: indexPath)
    }
    
    private func pushSplitViewController(for indexPath: IndexPath) {
        guard let colorViewController = storyboard?.instantiateViewController(withIdentifier: "ColorViewController") as? ColorViewController, let splitViewController = splitViewController  else {
            return
        }
        let color: UIColor
        switch (indexPath.row) {
        case 0:
            color = UIColor(colorLiteralRed: 33/255, green: 173/255, blue: 251/255, alpha: 0.6)
        case 1:
            color = UIColor.purple.withAlphaComponent(0.6)
        case 2:
            color = UIColor(colorLiteralRed: 118/255, green: 199/255, blue: 44/255, alpha: 0.6)
        case 3:
            color = UIColor.white.withAlphaComponent(0.6)
        case 4:
            color = UIColor.red.withAlphaComponent(0.6)
        case 5:
            color = UIColor.orange.withAlphaComponent(0.6)
        default:
            color = UIColor.black
        }
        colorViewController.cellColor = color
        splitViewController.showDetailViewController(colorViewController, sender: self)
    }
}

Set the Storyboard ID for your color view controller in interface builder to
match the name you use above. Your tvOS application is behaving pretty well now: navigation is intuitive and the focus engine lets you transition easily back and forth between your view controllers. However, we want some custom focus behavior. When a user selects a cell, they will expect the focus to change to the content they want to browse. Time to make this happen.

Manipulating the Focus

Apple doesn’t let you simply set focus to an element on the screen. There is no myView.grabFocusForcefully() method. Instead they provide the UIFocusEnvironment protocol to let you control how focus moves around. The idea behind this is that if you’re setting focus programatically, you are likely confusing the user because they expect focus to translate from element
to element on the screen, not jump suddenly to an unrelated far-off element.

There are cases, like ours, where we do want to move the focus for the user, and for that we will use UIFocusEnvironment as well as methods defined specifically on collection views to manipulate the focus to do our bidding.

When the user selects a view, focus needs to transition from our collection view, to the table view controller. If we make our collection view unfocusable, focus will have no choice but to jump to the only other focusable element on screen: the table view. There must always be one focused view on screen (it is possible to have no focused views on the screen during video playback, but in cases like these, gesture recognizers and other mechanisms are set up to allow the user to interact with the application). In the delegate extension,
define when it is acceptable for the collection view to have focus and a property to track the state.

var scrollingAround = true
...

extension ViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool {
        return scrollingAround
    }
}

The state changes when the user selects a cell. Add that and a call to the focus
engine to update the focus in collectionView(_:didSelectItemAt:).

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        pushSplitViewController(for: indexPath)
        scrollingAround = false
        setNeedsFocusUpdate()
    }

Build and run. Excellent… we’ve accomplished what we want, with one problem: there is no way to get back to the collection view. However, if we track state more attentively, we can solve this.

The conditions for tableView(_:canFocusRowAt:) and collectionView(_:canFocusItemAt:) are more complex than we first made them. An item or cell should be focusable at all times, except when transitioning between the two views. The didUpdateFocusIn
delegate methods for collection views and table views help to track this state. The code will look similar for collection and table views.

In ColorViewController add hasFocus and browsing propertes and these method changes to track and update state.

var hasFocus = false
var browsing = false

func tableView(_ tableView: UITableView, canFocusRowAt indexPath: IndexPath) -> Bool {
    return browsing || !hasFocus
}

func tableView(_ tableView: UITableView, didUpdateFocusIn context: UITableViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
    browsing = context.nextFocusedIndexPath != nil
    hasFocus = context.nextFocusedIndexPath != nil
}

In ViewController do almost the same, the analog to browsing is scrollingAround which we already have.

var hasFocus = false

func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool {
    return scrollingAround || !hasFocus
}

func collectionView(_ collectionView: UICollectionView, didUpdateFocusIn context: UICollectionViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
    hasFocus = context.nextFocusedIndexPath != nil
    scrollingAround = context.nextFocusedIndexPath != nil
}

There is another intuitive way to exit the color view controller back to the wheel: “menuing out”. This is a press of the menu button. Recognizing a menu button press is done with UITapGestureRecognizer. Add a tap gesture recognizer with an action to move focus away from the color view controller.

override func viewDidLoad() {
    ...
    let menuRecognizer = UITapGestureRecognizer(target: self, action: #selector(menuOut(sender:)))
    menuRecognizer.allowedPressTypes = [NSNumber(integerLiteral:UIPressType.menu.rawValue)]
    self.view.addGestureRecognizer(menuRecognizer)

}

func menuOut(sender: UITapGestureRecognizer) {
    browsing = false
    setNeedsFocusUpdate()
}

If you experiment with the behavior, you might see something you wouldn’t expect. Menuing out switches back to the collection view, but seems to randomly choose which cell to focus on. Collection views and table views have a property designed just for this purpose: remembersLastFocusedIndexPath. Set that to true in viewDidLoad() of ViewController to complete this tutorial.

override func viewDidLoad() {
    ...
    collectionView.remembersLastFocusedIndexPath = true
}

Wrap It Up

If you’ve followed along with both parts, you have an idea of how to create unique navigation controls in a tvOS app using collection views and custom layouts. Along with this comes the requirement of handling focus. You saw by tracking application state, focus can be directed toward the desired views on screen.

Head over to the project’s GitHub to see a few more customization points. The project has been updated to make user selections more obvious and control focus further to prevent confusion between the collection view selection and table view content.

And if you’re having trouble implementing these and other features into your tvOS app, Big Nerd Ranch is happy to help. Get in touch to see how our team can build a tvOS app for you, or impement new features into an existing app.

Not Happy with Your Current App, or Digital Product?

Submit your event

Let's Discuss Your Project

Let's Discuss Your Project