Creating Focus Effects in tvOS
UPDATE: tvOS 11 now supports non-roundrect buttons! You don’t need to do this any more to get circular buttons — yay! However: if you’re still interested in building your own Focus Effect by using UIInterpolatingMotionEffect
, read on!
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.
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.)
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:
Source: Apple’s tvOS HIG
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)
// If you press multiple buttons at the same time,
// that shouldn't trigger a pressDown() animation.
guard presses.count == 1 else {
return
}
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 UIInterpolatingMotionEffect
s 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 CustomFocusableViewType
s 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 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!