tvOS UIFocusGuide demystified

A post about tvOS UIFocus enlightenment and a helper Class that I use to help debug my user interface work.

UIFocus

Above you’ll notice four buttons (a screenshot from my actual Apple TV). You’ll also notice a total of eight purple boxes with dashed outlines. Each is labeled in all capitals. Those are instances of my helper Class (“FocusGuideRepresentation”). You see, UIFocusGuides do not have any visual interface. So when you are deploying them, you’re essentially working in the dark. This helper Class shows you right where the guides are to help you visually lay everything out. Of course, when you get into dynamic situations where buttons are shuffling around, this can help you even more.

The focus management for tvOS works incredibly well when your buttons line up vertically or horizontally. It just works, you don’t need to do anything for that functionality. When they aren’t aligned, you can get into situations where buttons aren’t available through normal navigation. Above, without any UIFocusGuides, a user could move from button One to Two. And back up. That would be it. Buttons Three and Four would be hung out to dry without the user able to navigate to them. That’s why UIFocusGuides exist.

You can think of them as invisible buttons that pass focus to an actual button – based on rules that you provide.

I decided that in addition to being to move up and down with the Siri remote to access the buttons, left and right should also work at the top. A user swiping right from One to get to Three should work. That means 8 guides, as you can see in the diagram. The dashed rule lines show how each guide passes focus. That is a lot of guides, but in the end, the navigation ends up being buttery and simple to use. An application should be a joy to use.

Below is the code for the helper Class, followed by the full code for what you see in the image. Try it out on an Apple TV and see what’s going on and experience how nice it feels getting around.

import UIKit

class FocusGuideRepresentation: UIView {

    init(frameSize: CGRect, label: String)
    {
        super.init(frame: frameSize)
        self.backgroundColor = UIColor.blue.withAlphaComponent(0.1)
        let myLabel = UILabel(frame: CGRect(x: 0, y: 0, width: self.frame.width, height: self.frame.height))
        myLabel.font = UIFont.systemFont(ofSize: 20)
        myLabel.textColor = UIColor.white.withAlphaComponent(0.5)
        myLabel.textAlignment = .center
        myLabel.text = label.uppercased()
        self.addSubview(myLabel)
        
        // Add a dashed rule around myself.
        
        let border = CAShapeLayer()
        border.strokeColor = UIColor.white.withAlphaComponent(0.4).cgColor
        border.fillColor = nil
        border.lineWidth = 1
        border.lineDashPattern = [4, 4]
        border.path = UIBezierPath(rect: self.bounds).cgPath
        border.frame = self.bounds
        self.layer.addSublayer(border)
    }
    
    required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}

And, now, the main code for the ViewController. Note: the 4 UIButtons are in the Storyboard and hooked up (as you see the @IBOutlets).

import UIKit

class ViewController: UIViewController {

    @IBOutlet var one:   UIButton!
    @IBOutlet var two:   UIButton!
    @IBOutlet var three: UIButton!
    @IBOutlet var four:  UIButton!

    var fg1: FocusGuideRepresentation!
    var fg2: FocusGuideRepresentation!
    var fg3: FocusGuideRepresentation!
    var fg4: FocusGuideRepresentation!
    var fg5: FocusGuideRepresentation!
    var fg6: FocusGuideRepresentation!
    var fg7: FocusGuideRepresentation!
    var fg8: FocusGuideRepresentation!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUpFocusGuides()
        
        // Pop these back up above the guide representations.
        
        self.view.addSubview(one)
        self.view.addSubview(two)
        self.view.addSubview(three)
        self.view.addSubview(four)
    }

    func setUpFocusGuides()
    {
        let firstFocusGuide = UIFocusGuide()
        view.addLayoutGuide(firstFocusGuide)
        firstFocusGuide.leftAnchor.constraint(equalTo:   one.leftAnchor).isActive =    true
        firstFocusGuide.topAnchor.constraint(equalTo:    one.bottomAnchor).isActive =  true
        firstFocusGuide.heightAnchor.constraint(equalTo: one.heightAnchor).isActive =  true
        firstFocusGuide.widthAnchor.constraint(equalTo:  three.widthAnchor).isActive = true
        firstFocusGuide.preferredFocusEnvironments = [three]
        
        let secondFocusGuide = UIFocusGuide()
        view.addLayoutGuide(secondFocusGuide)
        secondFocusGuide.rightAnchor.constraint(equalTo:  three.rightAnchor).isActive =  true
        secondFocusGuide.bottomAnchor.constraint(equalTo: three.topAnchor).isActive =    true
        secondFocusGuide.heightAnchor.constraint(equalTo: three.heightAnchor).isActive = true
        secondFocusGuide.widthAnchor.constraint(equalTo:  three.widthAnchor).isActive =  true
        secondFocusGuide.preferredFocusEnvironments = [one]
        
        let thirdFocusGuide = UIFocusGuide()
        view.addLayoutGuide(thirdFocusGuide)
        thirdFocusGuide.leftAnchor.constraint(equalTo:   two.leftAnchor).isActive =   true
        thirdFocusGuide.bottomAnchor.constraint(equalTo: two.topAnchor).isActive =    true
        thirdFocusGuide.heightAnchor.constraint(equalTo: two.heightAnchor).isActive = true
        thirdFocusGuide.widthAnchor.constraint(equalTo:  four.widthAnchor).isActive = true
        thirdFocusGuide.preferredFocusEnvironments = [four]

        let fourthFocusGuide = UIFocusGuide()
        view.addLayoutGuide(fourthFocusGuide)
        fourthFocusGuide.leftAnchor.constraint(equalTo:   four.leftAnchor).isActive =   true
        fourthFocusGuide.topAnchor.constraint(equalTo:    four.bottomAnchor).isActive = true
        //fourthFocusGuide.bottomAnchor.constraint(equalTo: two.bottomAnchor).isActive =  true
        fourthFocusGuide.heightAnchor.constraint(equalTo: four.heightAnchor).isActive = true
        fourthFocusGuide.widthAnchor.constraint(equalTo:  four.widthAnchor).isActive =  true
        fourthFocusGuide.preferredFocusEnvironments = [two]
        
        let fifthFocusGuide = UIFocusGuide()
        view.addLayoutGuide(fifthFocusGuide)
        fifthFocusGuide.leftAnchor.constraint(equalTo:   three.leftAnchor).isActive =   true
        fifthFocusGuide.bottomAnchor.constraint(equalTo: one.bottomAnchor).isActive =  true
        fifthFocusGuide.heightAnchor.constraint(equalTo: three.heightAnchor).isActive = true
        fifthFocusGuide.widthAnchor.constraint(equalTo:  three.widthAnchor).isActive =  true
        fifthFocusGuide.preferredFocusEnvironments = [three]
        
        let sixthFocusGuide = UIFocusGuide()
        view.addLayoutGuide(sixthFocusGuide)
        sixthFocusGuide.leftAnchor.constraint(equalTo:   one.leftAnchor).isActive =   true
        sixthFocusGuide.bottomAnchor.constraint(equalTo: three.bottomAnchor).isActive =  true
        sixthFocusGuide.heightAnchor.constraint(equalTo: three.heightAnchor).isActive = true
        sixthFocusGuide.widthAnchor.constraint(equalTo:  three.widthAnchor).isActive =  true
        sixthFocusGuide.preferredFocusEnvironments = [one]
        
        let seventhFocusGuide = UIFocusGuide()
        view.addLayoutGuide(seventhFocusGuide)
        seventhFocusGuide.leftAnchor.constraint(equalTo:   four.leftAnchor).isActive =   true
        seventhFocusGuide.bottomAnchor.constraint(equalTo: two.bottomAnchor).isActive =  true
        seventhFocusGuide.heightAnchor.constraint(equalTo: four.heightAnchor).isActive = true
        seventhFocusGuide.widthAnchor.constraint(equalTo:  four.widthAnchor).isActive =  true
        seventhFocusGuide.preferredFocusEnvironments = [four]
        
        let eighthFocusGuide = UIFocusGuide()
        view.addLayoutGuide(eighthFocusGuide)
        eighthFocusGuide.leftAnchor.constraint(equalTo:   two.leftAnchor).isActive =   true
        eighthFocusGuide.bottomAnchor.constraint(equalTo: four.bottomAnchor).isActive =  true
        eighthFocusGuide.heightAnchor.constraint(equalTo: four.heightAnchor).isActive = true
        eighthFocusGuide.widthAnchor.constraint(equalTo:  four.widthAnchor).isActive =  true
        eighthFocusGuide.preferredFocusEnvironments = [two]
        
        // To aid in debug placement.
        
        fg1 = FocusGuideRepresentation(frameSize: firstFocusGuide.layoutFrame, label:  "first")
        fg2 = FocusGuideRepresentation(frameSize: secondFocusGuide.layoutFrame, label: "second")
        fg3 = FocusGuideRepresentation(frameSize: thirdFocusGuide.layoutFrame, label:  "third")
        fg4 = FocusGuideRepresentation(frameSize: fourthFocusGuide.layoutFrame, label: "fourth")
        fg5 = FocusGuideRepresentation(frameSize: fifthFocusGuide.layoutFrame, label: "fifth")
        fg6 = FocusGuideRepresentation(frameSize: sixthFocusGuide.layoutFrame, label: "sixth")
        fg7 = FocusGuideRepresentation(frameSize: seventhFocusGuide.layoutFrame, label: "seventh")
        fg8 = FocusGuideRepresentation(frameSize: eighthFocusGuide.layoutFrame, label: "eighth")
        
        self.view.addSubview(fg1)
        self.view.addSubview(fg2)
        self.view.addSubview(fg3)
        self.view.addSubview(fg4)
        self.view.addSubview(fg5)
        self.view.addSubview(fg6)
        self.view.addSubview(fg7)
        self.view.addSubview(fg8)
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}

 

Leave a Reply

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

Time limit is exhausted. Please reload CAPTCHA.

This site uses Akismet to reduce spam. Learn how your comment data is processed.