LoginSignup
17
7

More than 5 years have passed since last update.

UIKitCatalogのサンプルコードからtvOSのフォーカスを学ぶ

Last updated at Posted at 2016-03-13

はじめに

Appleの公開しているtvOSのUIKitの扱い方が分かるサンプルコードをリーディングして、正しいtvOSアプリのフォーカスとユーザインタフェースの在り方を学ぼうと思います。iOSと変わらない部分が多いので必要な部分だと思った箇所(フォーカス関連)中心に見ていきます。

iOSアプリ開発者にとってtvOSアプリで開発で最も困るのがフォーカスの扱いですね。フォーカスって何?と思った方は公式ドキュメントかairbnbのこの記事を一度流し読みすると良いかと思います。

実行環境とサンプルのバージョン

  • tvOS: Version 9.1
  • Swift: Version 2.1.1
  • Xcode: Version 7.2
  • UIKit Catalog (tvOS): Version 1.3, 2016-02-04

フォーカスの変更と更新

ビルドしてみると以下のような画面が出ると思います。「Controls」「ViewControllers」「TextEntry」項目を表示するベースとなるクラスがMenuSplitViewControllerMenuTableViewControllerになります。まずはここが何をしているか見てみましょう。

※ 何も操作できないじゃんと思った方はシミュレータのメニューで Hardware > Show Apple TV Remote を選択するとリモコンが出てきます。

MenuSplitViewController

ここではスプリットビューのプライマリー(MenuTableViewController)側の項目が選択された時にフォーカスをセカンダリー(該当するUIViewController)側に渡すかどうか判定しています。

フォーカス先の変更

preferDetailViewControllerOnNextFocusUpdateはメニュー項目が選択された時にtrueが入ってきます。preferredFocusedViewはフォーカスエンジンが必要に応じて次のフォーカスをどこに移すべきか保持しているので、ここを上書きすることで通常とは異なるフォーカス移動を可能にします。(絶対では無い)

上記の処理を行わせる為にsetNeedsFocusUpdate()でフォーカスエンジンにリクエストを投げています。しかしフォーカスエンジンは次の更新サイクルまで考え事をしているのでupdateFocusIfNeeded()で直ぐに行うよう命令しています。レイアウトの更新処理に似ていますね。

MenuSplitViewController.swift
class MenuSplitViewController: UISplitViewController {

    // 次のフォーカスをセカンダリー側に渡すか判定するフラグ
    private var preferDetailViewControllerOnNextFocusUpdate = false

    // フォーカス先を変更する為にオーバーライドする
    override var preferredFocusedView: UIView? {
        let preferredFocusedView: UIView?

        // メニューの項目を選択した時だけセカンダリー側にフォーカスを渡す
        if preferDetailViewControllerOnNextFocusUpdate {
            preferredFocusedView = viewControllers.last?.preferredFocusedView
            preferDetailViewControllerOnNextFocusUpdate = false
        }
        else {
            preferredFocusedView = super.preferredFocusedView
        }

        return preferredFocusedView
    }

    // 上記のフォーカス先の変更を強制的に行わせる
    func updateFocusToDetailViewController() {
        preferDetailViewControllerOnNextFocusUpdate = true

        setNeedsFocusUpdate()
        updateFocusIfNeeded()
    }
}

MenuTableViewController

こいつは「Controls」「ViewControllers」「TextEntry」項目のプライマリー側ViewControllerが継承する親になるクラスです。ここでは実際にテーブルビューの項目が選択、フォーカスされた時の処理がそれぞれ書かれています。

オーバースキャン

まず気になるのがviewDidLoad()で行っているマージンです。これはオーバースキャンというテレビの映像受信機が歪みやノイズを隠す為にトリミングをする際の対策として行っているようです。layoutMarginsで指定した分だけフォーカスはそこに当たらなくなります。

フォーカス先の変更

実際の表示切り替えはストーリーボード上のSegue接続で行っています。shouldPerformSegueWithIdentifier:ではSegueのIdentifierが正しいか見てdidSelectRowAtIndexPath:で選択された項目ごとのViewControllerへ先ほどのupdateFocusToDetailViewController()を使ってフォーカス先を変更しています。

フォーカス更新時の処理

didUpdateFocusInContext:はtvOS専用のTableViewControllerのデリゲートメソッドです。これはテーブルビュー上でフォーカスが更新された時に呼び出されます。テーブルビューからナビゲーションバーに変更される時にもよばれるので最初にcontext.nextFocusedViewを調べています。

そして次が少し面倒なことをしているのですが、フォーカスが更新される度にセカンダリー側のビューがチカチカと切り替わるのはユーザ的に不愉快なので、表示までのディレイ処理をNSBlockOperation()を使って非同期にしています。(これはフォーカスエンジンの仕事としてやってくれないんだ…)

output.gif

MenuTableViewController.swift
class MenuTableViewController: UITableViewController {

    // 継承先でオーバライドする項目ごとのSegueIdentifier
    var segueIdentifierMap: [[String]] {
        return [[]]
    }

    private var lastPerformedSegueIdentifier: String?
    private let delayedSeguesOperationQueue = NSOperationQueue()
    private static let performSegueDelay: NSTimeInterval = 0.1

    override func viewDidLoad() {
        super.viewDidLoad()

        // テーブルビュー以外からフォーカスが戻る時に以前選択していた場所に戻す
        tableView.remembersLastFocusedIndexPath = true

        // オーバースキャン用に左右にマージンを取る
        tableView.layoutMargins.left = 90
        tableView.layoutMargins.right = 20
    }

    override func shouldPerformSegueWithIdentifier(identifier: String, sender: AnyObject?) -> Bool {

        // オーバーライドした項目ごとのSegueIdentifierと一致した場合のみ遷移させる
        guard segueIdentifierMap.contains({ $0.contains(identifier) }) else { return true }

        // 最後のSegueIdentifierと同じ場合は何もしない
        return identifier != lastPerformedSegueIdentifier
    }

    override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        guard let menuSplitViewController = splitViewController as? MenuSplitViewController else { return }

        // プライマリー側へフォーカスの変更を行わせる
        menuSplitViewController.updateFocusToDetailViewController()
    }

    // テーブルビュー上でフォーカスが更新された時に呼び出される
    override func tableView(tableView: UITableView, didUpdateFocusInContext context: UITableViewFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {

        // フォーカス先がテーブルビューの子ビューの場合のみ下記処理を行う
        guard let nextFocusedView = context.nextFocusedView where nextFocusedView.isDescendantOfView(tableView) else { return }
        guard let indexPath = context.nextFocusedIndexPath else { return }

        // 前回のフォーカス移動による下記表示処理は中止する
        delayedSeguesOperationQueue.cancelAllOperations()

        // フォーカスが当たったことによるプライマリー側の表示処理は非同期で行う
        let performSegueOperation = NSBlockOperation()
        let segueIdentifier = segueIdentifierMap[indexPath.section][indexPath.row]
        performSegueOperation.addExecutionBlock { [weak self, unowned performSegueOperation] in

            // 0.1秒のディレイを入れる
            NSThread.sleepForTimeInterval(MenuTableViewController.performSegueDelay)

            // この非同期処理が行なわれていて最後にフォーカス先が異なっている場合のみメインスレッドで表示処理を行う
            guard !performSegueOperation.cancelled && segueIdentifier != self?.lastPerformedSegueIdentifier else { return }
            NSOperationQueue.mainQueue().addOperationWithBlock {
                self?.performSegueWithIdentifier(segueIdentifier, sender: nextFocusedView)
                self?.lastPerformedSegueIdentifier = segueIdentifier

                // フォーカスが当たっている項目とプライマリー側の表示を合わせる
                self?.tableView.selectRowAtIndexPath(indexPath, animated: true, scrollPosition: .None)
            }
        }

        delayedSeguesOperationQueue.addOperation(performSegueOperation)
    }
}

フォーカスガイドとコレクションビュー

ナビゲーションバーの「Focus」を選択すると「ShowFocusGuidesExample」と「ShowEmbeddedCollectionViewsExample」のボタンが表示されます。それぞれFocusGuidesViewControllerCollectionViewContainerViewControllerがモーダル表示されるようになっています。

FocusGuidesViewController

ビューが無い場所(TopRightButtonの下)にフォーカスエンジンが更新処理を行った場合に他のフォーカス可能なビュー(BottomLeftButton)にすり替える方法を示しています。

ビューが無い場所のフォーカス

公式のドキュメントにもあったように、フォーカスエンジンはフォーカス先が可視領域のあるビューの大きさに少しでも引っかから無い限り移動できません。上記画像のようにビューが無い場所へのフォーカスを変更する場合はUILayoutGuideとそれを継承したUIFocusGuideを使用します。

フォーカス可能であっても、他のビューの奥に完全に隠れているものは無視します。一部でも見えているものだけが対象になります。このテクニックにより、現在フォーカスされているビューを起点として、動きの方向にあるフォーカス可能な領域を見つけます。検索する範囲は、現在フォーカスされているビューの大きさに応じて決まります。

UILayoutGuideはiOS9から導入されたレイアウト情報を持つクラスで、AutoLayoutを以前よりもコードで書きやすくしてくれます。tvOSではそれをフォーカス先の選定に使用できます。やり方は簡単でビューが無い場所に必要なレイアウトをアンカーで指定するだけです。ただしUIFocusGuideで指定したものはフォーカスエンジンが自動でフォーカス先を決められ無いので、必要なパターン分preferredFocusedViewを示してあげなければなりません。

FocusGuidesViewController.swift
class FocusGuidesViewController: UIViewController {

    @IBOutlet var topRightButton: UIButton!
    @IBOutlet var bottomLeftButton: UIButton!
    private var focusGuide: UIFocusGuide!

    override func viewDidLoad() {
        super.viewDidLoad()

        focusGuide = UIFocusGuide()
        view.addLayoutGuide(focusGuide)

        // ビューの無い場所にボタンひとつ分のフォーカスガイドを作成
        focusGuide.leftAnchor.constraintEqualToAnchor(topRightButton.leftAnchor).active = true
        focusGuide.topAnchor.constraintEqualToAnchor(bottomLeftButton.topAnchor).active = true
        focusGuide.widthAnchor.constraintEqualToAnchor(topRightButton.widthAnchor).active = true
        focusGuide.heightAnchor.constraintEqualToAnchor(bottomLeftButton.heightAnchor).active = true
    }

    override func didUpdateFocusInContext(context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {
        super.didUpdateFocusInContext(context, withAnimationCoordinator: coordinator)
        guard let nextFocusedView = context.nextFocusedView else { return }

        // それぞれのビューでフォーカスガイドに来た時のフォーカス先を示す
        switch nextFocusedView {
            case topRightButton:
                focusGuide.preferredFocusedView = bottomLeftButton
            case bottomLeftButton:
                focusGuide.preferredFocusedView = topRightButton
            default:
                focusGuide.preferredFocusedView = nil
        }
    }
}

CollectionViewContainerViewController

コレクションビューの中にコレクションビューが入れ子になり、横スクロールが可能なコレクションビューを実装しています。これを実装する上で今までのようにフォーカス先を細かく指定する必要はなく、フォーカスエンジンがいい感じに行ってくれることが分かります。

縦方向フォーカス用コレクションビュー

まずコレクションビューを中に持つコレクションビューを作成します。これは縦方向にフォーカスを移動させるためのもので、セル自体は持っていません。ただし、それぞれの中のコレクションビューで横方向にフォーカスが移動できるようにcanFocusItemAtIndexPath:の扱いだけ気を付けます。

もしcanFocusItemAtIndexPath:の返り値がtrueだった場合は下の画像のように入れ物の役割のCollectionViewContainerCellpreferredFocusedViewになってしまい中のセルまでフォーカスが当たらなくなってしまいます。

collectionView:canFocusItemAtIndexPath:メソッドはUICollectionViewDelegateクラス、tableView:canFocusRowAtIndexPath:メソッドはUITableViewDelegateクラスを使って、あるセルがフォーカス可能か否かを指定します。これには、独自に実装したUIViewのcanBecomeFocusedメソッドをオーバーライドするのと同様の働きがあります。

つまりcanFocusItemAtIndexPath:でただの入れ物であることを明示的に示しておくことでフォーカスはその中のフォーカス可能なセルを探しに行ってくれます。

CollectionViewContainerViewController.swift
class CollectionViewContainerViewController: UICollectionViewController {
    private static let minimumEdgePadding = CGFloat(90.0)

    // サンプルアイテムから指定したグループごとに分けて配列にする
    private let dataItemsByGroup: [[DataItem]] = {
        return DataItem.Group.allGroups.map { group in
            return DataItem.sampleItems.filter { $0.group == group }
        }
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        guard let collectionView = collectionView, layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return }

        // フォーカスが当たった時の拡大表示が画面上端まで表示されてしまうのでパディングする
        collectionView.contentInset.top = CollectionViewContainerViewController.minimumEdgePadding - layout.sectionInset.top
        collectionView.contentInset.bottom = CollectionViewContainerViewController.minimumEdgePadding - layout.sectionInset.bottom
    }

    override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {

        // 縦方向にグループの数だけセクションを設ける
        return dataItemsByGroup.count
    }

    override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 1
    }

    override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
        return collectionView.dequeueReusableCellWithReuseIdentifier(CollectionViewContainerCell.reuseIdentifier, forIndexPath: indexPath)
    }

    override func collectionView(collectionView: UICollectionView, willDisplayCell cell: UICollectionViewCell, forItemAtIndexPath indexPath: NSIndexPath) {
        guard let cell = cell as? CollectionViewContainerCell else { fatalError("Expected to display a `CollectionViewContainerCell`.") }

        let sectionDataItems = dataItemsByGroup[indexPath.section]
        cell.configureWithDataItems(sectionDataItems)
    }

    override func collectionView(collectionView: UICollectionView, canFocusItemAtIndexPath indexPath: NSIndexPath) -> Bool {

        // このセルがフォーカス可能だと中のセルにフォーカスが行かなくなってしまう
        return false
    }
}

CollectionViewContainerCell

一方は実際のアイテムを表示させるコレクションビューとセルを定義しています。もちろんスクロール方向をストーリーボード上でHorizontalに指定しています。注意して欲しいのはこのクラスは上記のコレクションビューのカスタムセルとして定義して、その中にコレクションビューを持たせているところだけです。

特にフォーカスの指定などもなく普通にUICollectionViewDataSourceUICollectionViewDelegateのメソッドに表示するアイテムを指定しているだけなのでコードの説明は割愛します。

おしまい

少しでもtvOSの曲者であるフォーカスについてイメージが付けば幸いです。フォーカスの深みにハマればハマるほど、改めてタッチスクリーンの有り難さを感じるのではないでしょうか。まだ一般的に普及しているようには思えませんが、私たちが良いアプリを生み出していくことで自然とユーザも増えてくるともいます。一緒に頑張りましょう!

参考文献

17
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
7