A post about tvOS UIFocus enlightenment and a helper Class that I use to help debug my user interface work.
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() } }