Developing for Apple TV, Part II

In the last post we covered the main differences developing for tvOS, how to set up your project, and some of the details related to managing the focus. In this post, we’ll dive more into focus management using focus guides. UIFocusGuide inherits from UILayoutGuide and enables finer control over the system focus engine.

Using focus guides The system focus engine typically only searches horizontally or vertically for the next view to put in focus when the user swipes horizontally or vertically on the remote. This was inadequate for navigating between tags on a photo. Tapping on a product tag in a photo on our iOS and Android apps provides information on the tagged product. We wanted to have a similar experience on the Apple TV, but the user can’t tap on a tag. Instead, the user needs to navigate the focus from tag to tag. The default focus engine behavior wasn’t able to handle the case of navigating between arbitrarily placed views. For this scenario, tvOS has focus guides. You can place focus guides directly horizontally or vertically from the focused view to tell the focus engine what view you want focused next if the user navigates in a particular direction. This problem is similar to placing pins on a map and navigating the focus between them. So let’s see how we solved this:

First, lets make a simple TV project. Make a single view TV app project in xcode where we’ll randomly place pins on the view. To do this, we first need to make a pin view. Our pin view is going to be a simple subclass of UIImageView and it will show a pin image. Here is our pin view class:


    class PinView: UIImageView { 
      override init(image: UIImage?) {
       super.init(image: image)   
       adjustsImageWhenAncestorFocused = true // 1
       userInteractionEnabled = true // 2
      }
        
      required init?(coder aDecoder: NSCoder) { // 3
       fatalError(“init(coder:) has not been implemented")
      }

      override func canBecomeFocused() -> Bool { // 4
        return true
      }
    }

Explanation:

1. We set a property on UIImageView that makes it adjust the image or one of its ancestors when it’s in focus. The UIImageView will zoom the photo and make it respond to the user slightly rubbing his finger on the remote.

2. We enable user interaction, which is off by default on UIImageView. Without this, the view will not get the focus.

3. The required initializer. Without this, Xcode is extremely unhappy…

4. We override canBecomeFocused to allow this view to become focused. The default is false.

Now in our view controller we will simply add a few random pins to the view on viewDidLoad as such:


    class ViewController: UIViewController {  
     var pins = [UIView]()
     
     override func viewDidLoad() {
       super.viewDidLoad()
       
       // add pins
       for _ in 0 ..< 10 {
        let pin = PinView(image: UIImage(named: "pin"))
       pin.center = CGPoint(x: CGFloat(arc4random_uniform(UInt32(view.bounds.size.width))), y: CGFloat(arc4random_uniform(UInt32(view.bounds.size.height))))
       view.addSubview(pin)
       pins.append(pin)
      } 
     }
    }


If you run the app now you’ll see the pins on the view, but none are in focus. We need to tell the focus engine to start by having one of the pins in focus. We do this by overriding the preferredFocusedView property on the view controller as such:


    override var preferredFocusedView: UIView? {
      return pins.first
    }

If you now run the app, you’ll see the pins and one of them (the first in the array) will be in focus, it will be larger than the other pins and if you rub your finger on the remote you’ll see it react. However, if you try to navigate between the pins you’ll see that the focus doesn’t move nicely between them as the scanning of the focus engine is built for a grid, not arbitrarily placed views. We need to help the focus engine by placing focus guides.

Let’s start by placing four focus guides at the edges of the view where the focus engine will always hit them when scanning. In the view controller add four focus guide variables:

    class ViewController: UIViewController {
        var leftGuide = UIFocusGuide()
        var rightGuide = UIFocusGuide()
        var topGuide = UIFocusGuide()
        var bottomGuide = UIFocusGuide()

And in viewDidLoad() let’s place them in the view as follows:

        // add focus guides
    view.addLayoutGuide(topGuide)
        
    NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("|[guide]|", 
        options: .DirectionLeadingToTrailing, 
        metrics: nil,
        views: ["guide": topGuide]))
    
    NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[guide(1)]", 
        options: .DirectionLeadingToTrailing, 
        metrics: nil, 
        views: ["guide": topGuide]))

    view.addLayoutGuide(bottomGuide)
       
    NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("|[guide]|", 
        options: .DirectionLeadingToTrailing, 
        metrics: nil,
        views: ["guide": bottomGuide]))
        
    NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:[guide(1)]|", 
        options: .DirectionLeadingToTrailing, 
        metrics: nil, 
        views: ["guide": bottomGuide]))

    view.addLayoutGuide(leftGuide)
       
    NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[guide]|", 
        options:.DirectionLeadingToTrailing, 
        metrics: nil,
        views: ["guide": leftGuide]))
        
    NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("|[guide(1)]", 
        options:.DirectionLeadingToTrailing, 
        metrics: nil, 
        views: ["guide": leftGuide]))

    view.addLayoutGuide(rightGuide)
       
    NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[guide]|", 
        options:.DirectionLeadingToTrailing, 
        metrics: nil,
        views: ["guide": rightGuide]))
        
    NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("[guide(1)]|", 
        options: .DirectionLeadingToTrailing, 
        metrics: nil, 
        views: ["guide": rightGuide]))


Now that we have our focus guides, when the view controller gets notified of a focus change, it will set the next view we want focused if the user swipes in each direction and the focus guide is hit by the focus engine scanning, so in didUpdateFocusInContext we add:



      override func didUpdateFocusInContext(context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {
        if let focusedView = UIScreen.mainScreen().focusedView as? PinView {
            if let nextView = findNextViewFromView(focusedView, searchAngle: 0) {
                rightGuide.enabled = true
                rightGuide.preferredFocusedView = nextView
            } else {
                rightGuide.enabled = false
            }

            if let nextView = findNextViewFromView(focusedView, searchAngle: M_PI) {
                leftGuide.enabled = true
                leftGuide.preferredFocusedView = nextView
            } else {
                leftGuide.enabled = false
            }

            if let nextView = findNextViewFromView(focusedView, searchAngle: -M_PI_2) {
                topGuide.enabled = true
                topGuide.preferredFocusedView = nextView
            } else {
                topGuide.enabled = false
            }

            if let nextView = findNextViewFromView(focusedView, searchAngle: M_PI_2) {
                bottomGuide.enabled = true
                bottomGuide.preferredFocusedView = nextView
            } else {
                bottomGuide.enabled = false
            }
        }
    }




What are we doing here? First we test if the focused view is a PinView (in case we have other views in our view). If it is, using a method we’ll provide soon, findNextViewFromView will find the next logical view to place the focus if the focus direction is searchAngle. We repeat this for all focus guides. So what is our findNextViewFromView method? We assume we want to focus the nearest view in the search angle within a tolerance of plus or minus 45 degrees. This is expressed by the following method:



    func findNextViewFromView(currentView: UIView, searchAngle: Double) -> UIView? {
        let currentViewCenter = currentView.center
        var foundView: UIView? = nil
        var distanceToFoundView: CGFloat = 0

        // iterate over all pin views
        for aView in pins {
            if aView == currentView { // skip the current view
                continue
            }
            let location = aView.center
            let difference = CGPoint(x:location.x - currentViewCenter.x, y:location.y - currentViewCenter.y)
            var angle = fmod(Double(atan2(difference.y, difference.x)) - searchAngle, 2 * M_PI)

            // bracket the angle to be between -pi & pi
            if angle > M_PI {
                angle -= 2 * M_PI
            } else if angle < -M_PI {
                angle += 2 * M_PI
            }
            if angle > -M_PI_4 && angle < M_PI_4 {
                // it's in a good angle
                let distance2 = difference.x * difference.x + difference.y * difference.y
                if foundView == nil || distance2 < distanceToFoundView {
                    foundView = aView
                    distanceToFoundView = distance2
                }
            }
        }

        // do alternate search criteria if we didn’t find anything
        if foundView == nil {
            if searchAngle == 0 || searchAngle == M_PI {
                foundView = findViewAlongX(currentView, positive: searchAngle == 0)
            } else {
                foundView = findViewAlongY(currentView, positive: searchAngle > 0)
            }
        }

        return foundView
    }

Which iterates over all the pin views and finds the nearest one. If we don’t find a suitable view within a plus or minus 45 degrees, we do a different kind of search. The findViewAlongX and findViewAlongY will search for the view along the X axis in the positive or negative direction whose Y distance is the smallest, and likewise along the Y axis. Here are the implementations:


 func findViewAlongX(currentView: UIView, positive: Bool) -> UIView? {
        let currentViewCenter = currentView.center
        var foundView: UIView? = nil
        var distanceToFoundView: CGFloat = 99999
        for aView in pins {
            if aView == currentView {
                continue
            }
            let location = aView.center
            let difference = CGPoint(x: location.x - currentViewCenter.x, y: location.y - currentViewCenter.y)
            if ((positive && difference.x > 0) || (!positive && difference.x < 0) && fabs(difference.y) < distanceToFoundView) {
                foundView = aView
                distanceToFoundView = fabs(difference.y)
            }
        }

        return foundView
    }

    func findViewAlongY(currentView: UIView, positive: Bool) -> UIView? {
        let currentViewCenter = currentView.center
        var foundView: UIView? = nil
        var distanceToFoundView: CGFloat = 99999
        for aView in pins {
            if aView == currentView {
                continue
            }
            let location = aView.center
            let difference = CGPoint(x: location.x - currentViewCenter.x, y: location.y - currentViewCenter.y)
            if ((positive && difference.y > 0) || (!positive && difference.y < 0) && fabs(difference.x) < distanceToFoundView) {
                foundView = aView
                distanceToFoundView = fabs(difference.x)
            }
        }

        return foundView
    }


Running the app now, you’ll see it’s easy to navigate the focus between the randomly placed views.

Summary

Using focus guides you can enable complex focus navigation behavior. We’ve seen in this post how we handled the focus navigation between arbitrarily placed views. This is a general problem similar to navigating between pins on a map. You can get the entire code of this blog post on GitHub here.

#app #AppleTV #buildinghouzz #guyshaviv

Recent Posts

See All

Scaling Data Science

Being the first Data Scientist (DS) at a startup is exciting, yet comes with a myriad of challenges from navigating data infrastructure and data engineering staffing to balancing proper modeling again

Copyright : Guru Interior & Decorators

ISO 9001:2015 Certified

MSME Registered Company

  • Instagram
  • LinkedIn
  • whatsapp-png-image-9
  • Facebook
  • Twitter
  • YouTube