Taking Control of the tvOS Focus Engine

Introduction

On iOS, users normally interact with your apps via the device’s touch screen. On tvOS, however, user interaction is handled by moving the current focus between views on the screen.

Luckily, the tvOS implementations of the UIKit APIs handle the changing of focus between views automatically. While this built-in system works very well, for specific view layouts and/or purposes, it may be necessary to sometimes manually control the focus engine.

In this tutorial, we take an in-depth look at the tvOS focus engine. You learn how it works and how to control it however you want to.

Prerequisites

This tutorial requires that you are running Xcode 7.3 or higher with the latest tvOS 9.2 SDK. If you want to follow along, you also need to download the starter project from GitHub.

1. Focus Engine Overview

The purpose of the focus engine of tvOS is to help developers concentrate on their own app’s unique content rather than reimplementing basic navigation behaviors. This means that, while many users will use Apple TV’s Siri Remote, the focus engine automatically supports all current and future Apple TV input devices.

This means that, as a developer, you don’t have to worry about how a user is interacting with your app. Another important goal of the focus engine is to create a consistent user experience between applications. Because of this, there is no API that allows an application to move the focus.

Focus Movement

When the user interacts with the remote of the Apple TV by swiping on the glass Touch surface in a particular direction, the focus engine looks for a possible focusable view in that direction and, if found, moves the focus to that view. If no focusable view is found, the focus remains where it currently is.

In addition to moving the focus in a particular direction, the focus engine also handles several other, more advanced behaviors, such as:

  • moving the focus past particular views if, for example, the user swipes fast on the Touch surface of the Apple TV remote
  • running animations at speeds based on the velocity of the focus change
  • playing navigation sounds when the focus changes
  • animating scroll view offsets automatically when the focus needs to move to a currently off-screen view

When determining where the focus should move to in an app, the focus engine takes an internal picture of your app’s current interface and highlights all of the visible elements that are focusable. This means that any hidden views, including views with an alpha value of 0, cannot be focused. This also means that, for any view that is hidden by another view, only the visible part is considered by the focus engine.

If the focus engine finds a view it can move the focus to, it notifies the objects conforming to the UIFocusEnvironment protocol that are involved with the change. The UIKit classes that conform to the UIFocusEnvironment protocol are UIWindow, UIViewControllerUIView, and UIPresentationController. The focus engine calls the shouldUpdateFocusInContext(_:) method of all the focus environment objects that contain either the currently focused view or the view the focus is moving to. If any of these method calls returns false, the focus is not changed.

Initial Focus

The UIFocusEnvironment protocol represents an object that is known as a focus environment. The protocol defines a preferredFocusView property that specifies where the focus should move to if the current environment becomes focussed itself.

For example, a UIViewController object’s default preferredFocusView is its root view. As each UIView object can also specify its own preferred focus view, a preferred focus chain can be created. The tvOS focus engine follows this chain until a particular object returns either self or nil from its preferredFocusView property. By using these properties, you can redirect focus throughout the user interface and also specify which view  should be focussed first when a view controller appears on-screen.

It is important to note that, if you don’t change any of the preferredFocusView properties of your views and view controllers, the focus by default engine focuses the view closest to the top left corner of the screen.

Focus Update

A focus update occurs when one of three events take place:

  • the user causes a focus movement
  • the app explicitly requests a focus update
  • the system triggers and automatic update

Whenever an update takes place, the following events follow:

  • The current UIScreen object’s focusedView property is changed to the view that the focus is moving to.
  • The focus engine calls the didUpdateFocusInContext(_:withAnimationCoordinator:) of every focus environment object involved in the focus update. These are the same set of objects which the focus engine checks by calling each object’s shouldUpdateFocusInContext(_:) method before updating the focus. It is at this point that you can add custom animations to run in conjunction with the focus-related animations the system provides.
  • All of the coordinated animations, both system and custom animations, are run simultaneously.
  • If the view the focus is moving to is currently off-screen and in a scroll view, the system scrolls the view on-screen so that the view becomes visible to the user.

To manually update the focus in the user interface, you can invoke the setNeedsFocusUpdate() method of any focus environment object. This resets the focus and moves it back to the environment’s preferredFocusView.

The system can also trigger an automatic focus update in several situations, including when a focussed view is removed from the view hierarchy, a table or collection view reloads its data, or when a new view controller is presented or dismissed.

While the tvOS focus engine is quite complex and has a lot of moving parts, the UIKit APIs provided to you make it very easy to utilize this system and make it work how you want it to.

2. Controlling the Focus Engine

Focus Guides

To extend the focus engine, we are going to implement a wrap-around behavior. Our current app has a grid of six buttons as shown in the below screenshot.

Project Setup

What we are going to do is allow the user to move the focus towards the right, from buttons 3 and 6, and make the focus wrap back around to buttons 1 and 4 respectively. As the focus engine ignores any invisible views, this can not be done by inserting an invisible UIView (including a view with a width and height of 0) and changing its preferredFocusedView property.

Instead, we can accomplish this using the UIFocusGuide class. This class is a subclass of UILayoutGuide and represents a rectangular focusable region on the screen while being completely invisible and not interacting with the view hierarchy. On top of all the UILayoutGuide properties and methods, the UIFocusGuide class adds the following properties:

  • preferredFocusedView: This property works as I described earlier. You can think of this as the view that you want the focus guide to redirect to.
  • enabled: This property lets you enable or disable the focus guide.

In your project, open ViewController.swift and implement the viewDidAppear(_:) method of the ViewController class as shown below:

override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)
    
    let rightButtonIds = [3, 6]
    for buttonId in rightButtonIds {
        if let button = buttonWithTag(buttonId) {
            let focusGuide = UIFocusGuide()
            view.addLayoutGuide(focusGuide)
            focusGuide.widthAnchor.constraintEqualToAnchor(button.widthAnchor).active = true
            focusGuide.heightAnchor.constraintEqualToAnchor(button.heightAnchor).active = true
            focusGuide.leadingAnchor.constraintEqualToAnchor(button.trailingAnchor, constant: 60.0).active = true
            focusGuide.centerYAnchor.constraintEqualToAnchor(button.centerYAnchor).active = true
            focusGuide.preferredFocusedView = buttonWithTag(buttonId-2)
        }
    }
    
    let leftButtonIds = [1, 4]
    for buttonId in leftButtonIds {
        if let button = buttonWithTag(buttonId) {
            let focusGuide = UIFocusGuide()
            view.addLayoutGuide(focusGuide)
            focusGuide.widthAnchor.constraintEqualToAnchor(button.widthAnchor).active = true
            focusGuide.heightAnchor.constraintEqualToAnchor(button.heightAnchor).active = true
            focusGuide.trailingAnchor.constraintEqualToAnchor(button.leadingAnchor, constant: -60.0).active = true
            focusGuide.centerYAnchor.constraintEqualToAnchor(button.centerYAnchor).active = true
            focusGuide.preferredFocusedView = buttonWithTag(buttonId+2)
        }
    }
}

In viewDidAppear(_:), we create focus guides to the right of buttons 3 and 6, and to the left of buttons 1 and 4. As these focus guides represent a focusable region in the user interface, they must have a set height and width. With this code, we make the regions the same size as the other buttons so that the momentum-based logic of the focus engine feels  consistent with the visible buttons.

Coordinated Animations

To illustrate how coordinated animations work, we update the alpha property of the buttons when the focus changes. In ViewController.swift, implement the didUpdateFocusInContext(_:withAnimationCoordinator:) method in the ViewController class:

override func didUpdateFocusInContext(context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {
    super.didUpdateFocusInContext(context, withAnimationCoordinator: coordinator)
    
    if let focusedButton = context.previouslyFocusedView as? UIButton where buttons.contains(focusedButton) {
        coordinator.addCoordinatedAnimations({ 
            focusedButton.alpha = 0.5
        }, completion: { 
            // Run completed animation
        })
    }
}

The context parameter of didUpdateFocusInContext(_:withAnimationCoordinator:) is a UIFocusUpdateContext object that has the following properties:

  • previouslyFocusedView: references the view the focus is moving from
  • nextFocusedView: references the view the focus is moving to
  • focusHeading: a UIFocusHeading enumeration value representing the direction the focus is moving in

With the implementation of didUpdateFocusInContext(_:withAnimationCoordinator:), we add a coordinated animation to change the alpha value of the previously focused button to 0.5 and that of the currently focused button to 1.0.

Run the app in the simulator and move the focus between the buttons in the user interface. You can see that the currently focused button has an alpha of 1.0 while the previously focused button has an alpha of 0.5.

Transparent Buttons

The first closure of the addCoordinatedAnimations(_:completion:) method works similarly to a regular UIView animation closure. The difference is that it inherits its duration  and timing function from the focus engine.

If you want to run an animation with a custom duration, you can add any UIView animation within this closure with the OverrideInheritedDuration animation option. The following code is an example of how to implement a custom animation that runs in half the time of the focus animations:

// Running custom timed animation
let duration = UIView.inheritedAnimationDuration()
UIView.animateWithDuration(duration/2.0, delay: 0.0, options: .OverrideInheritedDuration, animations: { 
    // Animations
}, completion: { (completed: Bool) in
    // Completion block
})

By using the UIFocusGuide class and by utilizing custom animations, you can extend the standard behavior of the tvOS focus engine to suit your needs.

Limiting the Focus Engine

As I mentioned earlier, when deciding whether or not the focus should be moved from one view to another, the focus engine calls the shouldUpdateFocusInContext(_:) method on every focus environment involved. If any of these method calls returns false, the focus is not changed.

In our app, we are going to override this method in the ViewController class so that the focus cannot be moved down if the currently focused button is 2 or 3. To do so, implement shouldUpdateFocusInContext(_:) in the ViewController class as shown below:

override func shouldUpdateFocusInContext(context: UIFocusUpdateContext) -> Bool {
    let focusedButton = context.previouslyFocusedView as? UIButton
    
    if focusedButton == buttonWithTag(2) || focusedButton == buttonWithTag(3) {
        if context.focusHeading == .Down {
            return false
        }
    }
    
    return super.shouldUpdateFocusInContext(context)
}

In shouldUpdateFocusInContext(_:), we first check whether the previously focused view is button 2 or 3. We then inspect the focus heading. If the heading is equal to Down, we return false so that the current focus does not change.

Run your app one last time. You cannot move the focus down from buttons 2 and 3 to buttons 5 and 6.

Conclusion

You should now be comfortable controlling and working with the focus engine of tvOS. You now know how the focus engine works and how you can manipulate it to fit whatever needs you have for your own Apple TV apps.

As always, be sure to leave your comments and feedback in the comments below.

Download Taking Control of the tvOS Focus Engine

Leave a Reply

Your email address will not be published. Required fields are marked *