Creating Custom Focus Effects in tvOS

In my current side-project, I’m making a tvOS app. One of the screens features a handful of circular buttons. Unfortunately, when the tvOS Focus Engine renders these buttons, they get really awful shadows and focus states, that look a bit like this:

The system-standard UIButton looks awful with circular images.

The system-standard UIButton looks awful with circular images.

I had the good fortune of attending Apple’s tvOS Tech Talk in Seattle this winter - which meant I got to ask a few folks in the Q&A lab! They told me that, unfortunately, tvOS doesn’t allow non-roundrect focus effects: the system-standard focused state are pretty “simple” and aren’t clever enough (yet) to alter their shape depending on the alpha-channel of the image in the UIButton.

We needed to roll our own custom focus effect - but fortunately, it turned out to be pretty straightforward!

Here’s what the finished product looks like:

Ah, much better! (Apologies for the GIF compression; it's rasterizing the shadows.)

Ah, much better! (Apologies for the GIF compression; it's rasterizing the shadows.)

You can find the sample code for this project on GitHub - but read on to learn what’s under the hood!

What’s in a Focus Effect?

There’s a ton going on in the system-standard Focus Effect:

  • a scale transform that makes the button appear larger,
  • a shadow that makes the control appear to lift off the screen,
  • a white glare that reflects a “light source” across the surface, probably a masked blurry white circle,
  • a parallax tilt and shift that rotates the button in 3D, to provide continuous gestural feedback as you nudge your finger slightly on the trackpad,
  • and if the view contains a layered image, there’s a neat 3D parallax effect there, too.

Put that all together, and you get this effect:

These effects are important - they help you know what’s in focus, and they attempt to make the remote’s indirect manipulation of onscreen content feel a little more like direct (touch) manipulation.

Creating the Focus Effect

From the above list, we know we’re going to need some transforms, a shadow, a parallax tilt, a parallax shift, a parallax “white glare”, and a parallax layered image. (Note: for my project’s design, I don’t need the glare or layered image - but I’ll have a footnote below outlining what I might try if I were building those effects.)

Really, there are two subgroups here: - a “focused style” (“what does this view look like when it’s focused?”) - and a “parallax style” (“how does this view tilt and shift around when it’s focused?”)

FocusedStyle

Let’s start by creating a FocusedStyle:

public struct FocusedStyle { 
    let transform: CGAffineTransform 
    let shadowColor: CGColor 
    let shadowOffset: CGSize 
    let shadowRadius: CGFloat 
}

CustomFocusableViewType

We can then create a protocol that allows any view or control to render with this style:

public protocol CustomFocusableViewType { 
    var view: UIView { get }         
    var focusedStyle: FocusedStyle { get } 
}

public extension CustomFocusableViewType {
    func displayAsFocused(focused: Bool) {
        view.layer.shadowOpacity = focused ? 1 : 0
        view.transform = focused ? focusedStyle.transform : CGAffineTransformIdentity
    }
}

CustomFocusableButton

Now let’s create a CustomFocusableButton that conforms to our CustomFocusableViewType protocol. There’s a bit of code here to make the “select” animation work (when you click down on a button) - but outside of that, there’s not much here:

/// An implementation of CustomParallaxView,
/// implements a pressDown state when Select is clicked. 
/// Particularly useful for non-roundrect button shapes. 
public class CustomFocusableButton: UIButton { 
public let focusedStyle: FocusedStyle

public init(focusedStyle: FocusedStyle) {
        self.focusedStyle = focusedStyle

        super.init(frame: CGRectZero)

        view.layer.shadowColor = focusedStyle.shadowColor
        view.layer.shadowOffset = focusedStyle.shadowOffset
        view.layer.shadowRadius = focusedStyle.shadowRadius
    }

    required public init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: Animating selection - buttons should shrink when clicked.

    public override func pressesBegan(presses: Set<UIPress>, withEvent event: UIPressesEvent?) {
        super.pressesBegan(presses, withEvent: event)
        guard presses.count == 1 else { return } // If you press multiple buttons at the same time, that shouldn't trigger a pressDown() animation.

        for press in presses where press.type == .Select {
            pressDown()
        }
    }

    public override func pressesEnded(presses: Set<UIPress>, withEvent event: UIPressesEvent?) {
        super.pressesEnded(presses, withEvent: event)

        for press in presses where press.type == .Select {
            pressUp()
        }
    }

    public override func pressesCancelled(presses: Set<UIPress>, withEvent event: UIPressesEvent?) {
        super.pressesCancelled(presses, withEvent: event)

        for press in presses where press.type == .Select {
            pressUp()
        }
    }

    private func pressDown() {
        UIView.animateWithDuration(0.1,
            delay: 0.0, 
            usingSpringWithDamping: 0.9,
            initialSpringVelocity: 0.0,
            options: .BeginFromCurrentState,
            animations: { () -> Void in
                self.displayAsFocused(false)
            }, completion: nil)
    }

    private func pressUp() {
        UIView.animateWithDuration(0.2,
            delay: 0,
            usingSpringWithDamping: 0.9,
            initialSpringVelocity: 0,
            options: .BeginFromCurrentState,
            animations: { () -> Void in
                self.displayAsFocused(true)
            }, completion: nil)
    }
}

extension CustomFocusableButton: CustomFocusableViewType {
    public var view: UIView { return self }
}

ParallaxStyle

Next, let’s make a ParallaxStyle, which wraps our FocusedStyle along with the UIInterpolatingMotionEffects that compose into a parallax effect when you slide your thumb around.

/// Represents the tilting & shifting parallax effect when you nudge your thumb slightly on a focused UIView
public struct ParallaxStyle {
    /// The focused appearance for a view
    let focusStyle: FocusedStyle

    /// The max amount by which center.x will shift.
    /// Use a negative number for a reverse effect.
    let shiftHorizontal: Double

    /// The max amount by which center.y will shift.
    /// Use a negative number for a reverse effect.
    let shiftVertical: Double

    /// The max amount by which the view will rotate side-to-side, in radians.
    /// Use a negative number for a reverse effect.
    let tiltHorizontal: Double

    /// The max amount by which the view will rotate up-and-down, in radians.
    /// Use a negative number for a reverse effect.
    let tiltVertical: Double

    var motionEffectGroup: UIMotionEffectGroup {
        func toRadians(degrees: Double) -> Double {
            return degrees * M_PI_2 / 180
        }

        let shiftX = UIInterpolatingMotionEffect(keyPath: "center.x", type: .TiltAlongHorizontalAxis)
        shiftX.minimumRelativeValue = -shiftHorizontal
        shiftX.maximumRelativeValue = shiftHorizontal

        let shiftY = UIInterpolatingMotionEffect(keyPath: "center.y", type: .TiltAlongVerticalAxis)
        shiftY.minimumRelativeValue = -shiftVertical
        shiftY.maximumRelativeValue = shiftVertical

        let rotateX = UIInterpolatingMotionEffect(keyPath: "layer.transform.rotation.y", type: .TiltAlongHorizontalAxis)
        rotateX.minimumRelativeValue = toRadians(-tiltHorizontal)
        rotateX.maximumRelativeValue = toRadians(tiltHorizontal)

        let rotateY = UIInterpolatingMotionEffect(keyPath: "layer.transform.rotation.x", type: .TiltAlongVerticalAxis)
        rotateY.minimumRelativeValue = toRadians(-tiltVertical)
        rotateY.maximumRelativeValue = toRadians(tiltVertical)

        let motionGroup = UIMotionEffectGroup()
        motionGroup.motionEffects = [shiftX, shiftY, rotateX, rotateY]

        return motionGroup
    }
}

CustomFocusEffectCoordinator

So far, we’ve got FocusedStyle, ParallaxStyle, and a custom UIButton that implements CustomFocusableViewType and animates its UIControlState.Selected properly. Now we need a way to link up our UIMotionEffectGroup and FocusedStyle to the didUpdateFocusInContext(_:withAnimationCoordinator:) function in our UIView.

Enter the CustomFocusEffectCoordinator:

/// Manages the intersection of UIMotionEffects
/// and the tvOS Focus Engine, to provide a nice
/// parallax/focus effect on custom controls. 
public class CustomFocusEffectCoordinator { 
    private let views: Set<UIView>
    private let motionEffectGroup: UIMotionEffectGroup</uiview>

    public init(views: [UIView], parallaxStyle: ParallaxStyle) {
        self.views = Set(views)
        self.motionEffectGroup = parallaxStyle.motionEffectGroup
    }

    /// Call this function within your `didUpdateFocusInContext` method to create a parallax effect!
    public func updateFromContext(context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {
        coordinator.addCoordinatedAnimations({

            if let previousView = context.previouslyFocusedView as? CustomFocusableViewType {
                if self.views.contains(previousView.view) {
                    previousView.displayAsFocused(false)
                    previousView.view.removeMotionEffect(self.motionEffectGroup)
                }
            }

            if let nextView = context.nextFocusedView as? CustomFocusableViewType {
                if self.views.contains(nextView.view) {
                    nextView.displayAsFocused(true)
                    nextView.view.addMotionEffect(self.motionEffectGroup)
                }
            }

            }, completion: nil)
    }

    /// When you're ready to tear down the effect, call this function.
    public func removeMotionEffectsFromAllViews() {
        views.forEach { $0.removeMotionEffect(motionEffectGroup) }
    }
}

This class has the following responsibilities: - Links a set of CustomFocusableViewTypes to a ParallaxStyle, - Provides an easy way to update from a UIFocusUpdateContext and UIFocusAnimationCoordinator in the parent view. - Provides a way to tear down the effect.

Implementing in our UIView / UIViewController

Now for the fun & easy part: hooking it all together in the UIViewController or UIView!

class MyView: UIView {
    private var viewData: MyViewData

        private let customButtonOne = CustomFocusableButton(...)
        private let customButtonTwo = CustomFocusableButton(...)

        init(viewData: MyViewData) {
            self.viewData = viewData

            let focusedStyle = FocusedStyle(
            transform: CGAffineTransformMakeScale(1.1, 1.1),
            shadowColor: UIColor.blackColor().colorWithAlphaComponent(0.3).CGColor,
            shadowOffset: CGSize(width: 0, height: 16),
            shadowRadius: 25)

        let parallaxStyle = ParallaxStyle(
            shiftHorizontal: 4,
            shiftVertical: 4,
            tiltHorizontal: 10,
            tiltVertical: 10,
            focusStyle: focusedStyle)

            self.focusEffectCoordinator = CustomFocusEffectCoordinator(
                views: [customButtonOne, customButtonTwo],
                parallaxStyle: parallaxStyle)
        }

            public override func didUpdateFocusInContext(context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {
        focusEffectCoordinator.updateFromContext(context, withAnimationCoordinator: coordinator)
    }
}

Ta-da! Now you have a nice Focus behavior on your custom views - and since it’s all composed by little protocols and style-structs, you can easily tailor the focus and parallax behavior to suit your needs.

One Last Thing

In my current project, we didn’t have a need for the white gloss or layered image aspects of the focused state. Using the above code, you could probably extend ParallaxStyle and CustomFocusableViewType to implement these behaviors.

But, a word of caution: if you’re going to implement these bits, spend extra time polishing ‘em, because it’s no good when custom UI attempts to mimic the native platform but gets stuck in the Uncanny Valley. If you don’t fully dial-in the gloss and layered image effects, you may find that your UI feels a bit out-of-place, like a non-native app running on iOS.

Enjoy!