ヤフー株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。LINEヤフー Tech Blog

テクノロジー

2017年こそtvOSアプリ開発を始めたいエンジニア向け、UI実装ノウハウ集

みなさんこんにちは。Yahoo! JAPAN Tech Advent Calendar 2016の最終日を担当させていただきます林(@kazuhiro4949)です。
ヤフーでは普段iOSアプリの開発に携わっているのですが、今年の9月にYahoo!ショッピングのtvOSアプリも開発させていただきました。Apple TV(第4世代)をお持ちの方もしよければ触ってみてください。今回はそのtvOSアプリについて書きます。

tvOSアプリを開発する場合、そのやり方には大きく分けて2種類あります。それぞれ“Client-Server App”と“Traditional Apps”と呼ばれています

“Client-Server App”はウェブアプリとして開発するタイプで、TVML・TVJSというApple独自の技術を使います。一方、“Traditional Apps”として実装すると、「ほぼ」iOSアプリと同じように実装できます。FoundationやUIKitといったフレームワークは一部を除いてそのまま使うことができ、アプリの基本構造はiOSと変わりません。もともとiOSアプリエンジニアをやっていた私は、“Traditional Apps”を選びました。

しかし、いざ実装してみると「こんな感じのUIってどう作ればいいんだろう…」と思うところがいくつか出てきました。
そこで、UIの実装でちょっと一手間必要なポイントを書き残しておこうと思います。今後開発を検討されている方たちへのノウハウとしてお役に立てれば幸いです。

また、文中のコードはSwift2.3で実装しています。

検索のUIを作る

作りはじめていきなりつまずいたのが、よくある検索窓を持つUIでした。iOSアプリの場合、iOS8以降ではUISearchControllerを使って作ると思います。tvOSにもそのクラスは存在しているため、tvOSアプリで見慣れた検索画面は同じようにすぐ作れるものだと思っていました。

しかしいざ実装し始めてみると、「検索窓を置く場所」に迷うことがわかりました。典型的なものとしては、検索窓を埋め込んだViewをUITabBarControllerのいち要素として表示させます。
https://developer.apple.com/tvos/human-interface-guidelines/interface-elements/#text-and-search

そんな時に使えるのがUISearchContainerViewControllerです。こいつはtvOS用に使うことができます。UINavigationControllerやUITabBarControllerの子として検索窓を配置するために使用します。

    // UISearchControllerを作る
    let searchController = UISearchController(searchResultsController: /** 検索結果を表示させるViewController */)
    // 検索窓への入力イベントを受け取る
    searchController.searchResultsUpdater = self

    // UISearchContainerViewControllerへUISearchControllerを入れる
    let searchContainerController = UISearchContainerViewController(searchController: searchController) 

    // ContainerタイプのViewControllerへUISearchContainerViewControllerをわたす
    let nvc = UINavigationController(rootViewController: searchContainerController)

こうすると、標準アプリのような検索結果画面を表示させることができました。

2016-09-07 13 02 14

上記内容はApple Developerのフォーラムを参考にしました。

UISearchController inside tab item view controller

画像+ラベルのセルを持つCollectionViewに対して、フォーカスがあたった時の動きをつける

App Storeアプリをはじめ、大半のtvOSアプリはシンプルなUIが採用されています。多くのものがUICollectionViewの上へ、画像+ラベルのセルを表示させるだけという構成になっています。
https://developer.apple.com/tvos/human-interface-guidelines/visual-design/

単純なUIに見えて意外と一手間必要だったのが、UILabelのフォーカス時アニメーションです。UIImageViewの場合、adjustsImageWhenAncestorFocusedというプロパティー値を変更するだけでアニメーションがつけられます。一方でUILabelはそのような設定がなく、そのままではフォーカスされた画像に重なってしまいます。

2016-09-07 13 51 13

そこで、これらに対しては自分でフォーカスアニメーションを作る必要があります。

処理を記述する場所は、UIView内でフォーカス時に呼ばれるdidUpdateFocusInContext(context:, withAnimationCoordinator coordinator:)というメソッドです。

その中でUILabelの位置や色を調整していきます。

class ItemCell: UICollectionViewCell {
    // ItemCellは商品画像と商品価格を表示させる
    @IBOutlet weak var itemImageView: ItemImageView!
    @IBOutlet weak var itemPriceLabel: UILabel!
    @IBOutlet weak var priceLabelTopConstraint: NSLayoutConstraint!

    override func prepareForReuse() {
        super.prepareForReuse()
        itemImageView.image = nil
        itemPriceLabel.text = nil
    }

    // Itemエンティティから商品価格と商品画像を取り出す 
    func configure(item: Item?) {
        itemPriceLabel.text = item?.price.map { "\($0)円" } ?? "取得できませんでした"
        itemPriceLabel.textColor = UIColor.darkGrayColor()
        itemImageView.image = item?.image
    }

    // tvOS用のメソッド。リモコンをスワイプして、このセルにフォーカスが当たる・フォーカスが外れるタイミングで呼ばれる。
    // そのときに自分自身のフォーカス状態に応じたアニメーションをUIFocusAnimationCoordinatorの中で記述する。
    override func didUpdateFocusInContext(context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {

        priceLabelTopConstraint.constant = focused ? 45 : 15
        coordinator.addCoordinatedAnimations({ [weak self] in
            self?.layoutIfNeeded()

            // 次にフォーカスされるセルが自分自身だった場合
            if context.nextFocusedView == self {
                self?.itemPriceLabel.textColor = UIColor.whiteColor()
            }

            // 直前にフォーカスされていたセルが自分自身だった場合
            if context.previouslyFocusedView == self {
                self?.itemPriceLabel.textColor = UIColor.darkGrayColor()
            }
            }, completion: nil)
    }
}

またTop Shelfように各セクションへタイトルを付けている場合、それはそれでフォーカスされた画像と重ならないようにする必要があります。セクションのタイトルの場合厄介なのは、タイトルの位置を固定しながら横にCollectionViewをスクロールさせ、フォーカスされているCellと重なっているか判定する必要があります。必要があればそれも同様に対応しましょう。

こうすることで、「らしい」UIを実現できました。

任意のViewへフォーカス時のアニメーションをつける

続いて、任意のViewがフォーカスされた時のふわっと浮き出る画面の作り方です。

iTunes Movieアプリの説明文のように、任意のViewに対してフォーカスがあてられるようにしたいと思ったのですが、残念ながらUIViewに対してはフォーカスアニメーションが存在していません。そのため、自前で実装しなければなりません。

もし単純にフォーカスがあたった時にViewを浮かせたいのであれば、CGAffineTransformでViewをScaleさせつつ影を設定することでできます。ただしtvOSアプリが持つフォーカスの概念では、それに加えてリモコンのタッチパネルをグリグリした時Viewが一緒に合わせて動くという特徴を持っています。
https://developer.apple.com/tvos/human-interface-guidelines/user-interaction/

この動作はどのように実現するのか調べてみると、iOSでは7から利用可能なUIMotionEffectによる視差効果がそのままグリグリさせたときの効果として使えました。

以下へ、Siri Remote上で指を動かした時にViewへ上下の視差効果を与えるコードを書きます。

まずUIMotionEffectGroupを生成します。作成したグループはフォーカスイベントの時につけたり外したります。

class View: UIView {
  let motionEffectGroup: UIMotionEffectGroup = {
        // UIMotionEffectグリグリしたときのエフェクトを付けていく
        let xTilt = UIInterpolatingMotionEffect(keyPath: "center.y", type: .TiltAlongVerticalAxis)

        // 値はいい感じのものを適当に
        xTilt.maximumRelativeValue = 7
        xTilt.minimumRelativeValue = -7

        let tiltAngle = CGFloat(2 * M_PI / 180)

        var minX = CATransform3DIdentity
        minX.m34 = 1.0 / 500
        minX = CATransform3DRotate(minX, -tiltAngle, 1, 0, 0)

        var maxX = CATransform3DIdentity
        maxX.m34 = minX.m34
        maxX = CATransform3DRotate(maxX, tiltAngle, 1, 0, 0)

        let verticalTiltEffect = UIInterpolatingMotionEffect(keyPath: "layer.transform", type: .TiltAlongVerticalAxis)
        verticalTiltEffect.minimumRelativeValue = NSValue(CATransform3D: maxX)
        verticalTiltEffect.maximumRelativeValue = NSValue(CATransform3D: minX)

        let motionEffectGroup = UIMotionEffectGroup()
        motionEffectGroup.motionEffects = [xTilt, verticalTiltEffect]
        return motionEffectGroup
  }()
}

フォーカスがあてられた時に奥行きをつけるために、layerへ影を設定します。

class View: UIView {
  override func awakeFromNib() {
    super.awakeFromNib()

    // フォーカスがあてられた時のために影をつける
    layer.masksToBounds = false
    layer.shadowColor = UIColor.blackColor().CGColor
    layer.shadowRadius = 30
    layer.shadowOffset = CGSize(width: 0, height: 20)
    layer.shadowOpacity = 0.3
  }
}

Viewに対してフォーカスをあてられるようにするには、専用のメソッドをオーバーライドします。

class View: UIView {
    // ... 省略
    override func canBecomeFocused() -> Bool {
        return true
    }
}

続いてアニメーションを実装します。
フォーカスがあたったり外れたりすると、対象となるViewで以下のメソッドが呼ばれます。

class View: UIView {
    // ... 省略
    // Viewのフォーカスが当たる・外れる時に呼ばれる
    override func didUpdateFocusInContext(context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {
        super.didUpdateFocusInContext(context, withAnimationCoordinator: coordinator)
        if context.nextFocusedView == self {
            coordinator.addCoordinatedAnimations(
                { [weak self] in
                    guard let strongSelf = self else { return }
                    strongSelf.transform = CGAffineTransformMakeScale(1.05, 1.05)
                    strongSelf.backgroundColor = UIColor.whiteColor().colorWithAlphaComponent(0.5)
                    strongSelf.addMotionEffect(strongSelf.motionEffectGroup)
                },
                completion: { }
            )
        }
        if context.previouslyFocusedView == self {
            coordinator.addCoordinatedAnimations(
                { [weak self] in
                    guard let strongSelf = self else { return }
                    strongSelf.transform = CGAffineTransformMakeScale(1.0, 1.0)
                    strongSelf.backgroundColor = .clearColor()
                    strongSelf.removeMotionEffect(strongSelf.motionEffectGroup)
                }, completion: { }
            )
        }
    }
}

そして、Siri Remoteのタッチパネルをクリックした時にViewが奥へ押されるアニメーションを追加しましょう。Viewに対するプレス判定は専用のメソッドがあるので、それをoverrideします。

class View: UIView {
    override func pressesBegan(presses: Set<UIPress>, withEvent event: UIPressesEvent?) {
        UIView.animateWithDuration(0.2, animations: { [weak self] in
            self?.transform = CGAffineTransformMakeScale(1.0, 1.0)
        }) { (_) in }
    }

    override func pressesEnded(presses: Set<UIPress>, withEvent event: UIPressesEvent?) {
        UIView.animateWithDuration(0.2, animations: { [weak self] in
            self?.transform = CGAffineTransformMakeScale(1.05, 1.05)
        }) { (_) in }
    }
}

以上の実装でフォーカスを当てると浮き上がり、指の動きに合わせた視差効果も持つViewを作成できました。

ちなみに標準で上記の視差効果を持っているUIパーツはUIImageViewとUIButtonです。それ以外の場合ではUIMotionEffectを利用した視差効果をつけましょう。

NavigationBarの領域にあるコンテンツを隠す

tvOSではデフォルトではUINavigationBarが透明になっています。そのためtranslucentを設定している場合、スクロールするとNavigationBarの領域にも表示されてしまいます。

2016-09-16 19 15 29

この状態を避けるためにNavigationBarへViewを置いたり色を設定したりなどが考えられるかと思います。色々他のアプリを調べていると、多くのものではNavigationBar領域は背景が表示されつつコンテンツは何も表示されないという形になっていました。そのUIは、UIViewControllerへmaskViewを設定することでできます。

まずはマスクする領域に相当するViewを作ります。CAGradientLayerでグラデーションをかけて、徐々にViewが消える感じを出します。

探してみると、Appleが提供しているサンプル集であるUIKit Catalogにそういった目的のクラスが作られており、これをベースに作っていくのがいいでしょう。

では、UIViewControllerへCAGradientLayerを適用しましょう。この機能はUINavigationControllerに保持されているViewController共通で持っていると嬉しいでしょう。そんな時はProtocol Extensionですね。

protocol CollectionViewControllerMaskProtocol {
    var maskView: GradientMaskView { get set }
    func updateMask()
}

extension CollectionViewControllerMaskProtocol where Self: UICollectionViewController {
    func updateMask() {
        guard let collectionView = collectionView,
            layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout
            else {
                return
        }

        maskView.maskPosition.end = topLayoutGuide.length * 0.8

        let maximumMaskStart = maskView.maskPosition.end + (topLayoutGuide.length * 0.5)
        let verticalScrollPosition = max(0, collectionView.contentOffset.y + collectionView.contentInset.top)
        maskView.maskPosition.start = min(maximumMaskStart, maskView.maskPosition.end + verticalScrollPosition)

        var rect = CGRect(origin: CGPoint(x: 0, y: collectionView.contentOffset.y), size: collectionView.bounds.size)
        rect = CGRectInset(rect, -layout.minimumInteritemSpacing, 0)

        maskView.frame = rect
    }
}

対象のViewControllerを、先程作ったProtocol Extensionで拡張します。ここではviewWillLayoutSubviewsのイベントでレイアウトを調整しています。

final class CollectionViewController: UICollectionViewController, CollectionViewControllerMaskProtocol {
    lazy var maskView: GradientMaskView = {
        return GradientMaskView(frame: CGRect(origin: CGPoint.zero, size: self.collectionView!.bounds.size))
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView?.maskView = maskView
    }

    override func viewWillLayoutSubviews() {
        super.viewDidLayoutSubviews()
        updateMask()
    }
}

これで、以下のようにNavigationBarの領域へは何も表示されないようになりました。

2016-09-16 19 14 11

Menuボタンをクリックしてスクロールを一番上まで戻す

Apple TVのSiri RemoteにはMenuボタンがあります。

何もしなかった場合、以下のように動作をします。

  1. UITabBarControllerが持っているViewControllerではTabBarにフォーカスされる
  2. UINavigationControllerが持っているViewControllerはNavigation Stackの一つ前に戻る

iOSアプリでステータスバーを押した時と同じようにUICollectionViewの一番上へ戻したい場合は、Menuボタンが押されたときの操作をカスタマイズする必要があります。

例えばここでは、

  1. 画面上の任意のセルでMenuボタンを押すと先頭のセルに戻る
  2. 先頭セルでもう一度Menuボタンを押すとタブバーへフォーカスが当たる

ようにしましょう。

Menuボタンが押されたときの処理をフックするには、UITapGestureRecognizerを使います。ただしSiri Remoteのタッチパネルへ触れる操作にも反応してしまうため、「Menuボタンが押された」ということのみへ反応させます。

class ViewController: UICollectionViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // ジェスチャーを作成する
        let gesture = UITapGestureRecognizer(target: self, action: #selector(ViewController.menuButtonPressed(_:)))
        // メニューボタンのみに反応させる
        gesture.allowedPressTypes = [NSNumber(integer: UIPressType.Menu.rawValue)]
        view.addGestureRecognizer(gesture)
    }

    func menuButtonPressed(sender: UITapGesutureRecognizer) {
      // メニューボタンが押されたときの処理を実装する
    }
}

上記のコードの通り、リモコンを押した時の操作はiOSアプリと同じように制御できます。

続いて、画面上の先頭要素へフォーカスを当てる処理を書いていきます。そのためにはフォーカスされる要素を変更するための命令を送りましょう。

func menuHandle(gesture: UITapGestureRecognizer) {
     // Viewの中でフォーカスされる要素をアップデートする
     collectionView?.setNeedsFocusUpdate()
}

UICollectionViewであれば、これでindexPathForPreferredFocusedView(in:)というメソッドが呼ばれます。一番先頭へ戻したい場合はNSIndexPath(forItem: 0, inSection: 0)を返してやれば大丈夫です。

override func indexPathForPreferredFocusedViewInCollectionView(collectionView: UICollectionView) -> NSIndexPath? {
    return NSIndexPath(forItem: 0, inSection: 0)
}

Menuボタンを押すとiOSアプリでステータスバーをタップした時のような挙動をするようになりました。

最後に

以上自分が気がついた範囲で書いてみました。tvOSアプリ開発はiOSアプリエンジニアであれば想像以上にすんなりと取り組めるようになっています。Focus Engineなどの一部の独特な概念と、ここで書いたような細かいUIのTipsをおさえるだけですぐに入っていくことができます。この記事が皆様のお役に立って、App Storeが盛り上がるのを期待しています。

Yahoo! JAPAN Tech Advent Calendar 2016を最後まで読んでいただきありがとうございました。
それではみなさん良いお年をお過ごしください。

参考資料

  • UIKit Catalog (tvOS)
    • 現状最も参考になる資料はこのサンプルコードだと思います。確認した所、現時点(2016/12/22)で最新のXcode8, tvOS10にも対応されていました。
  • App Programming Guide for tvOS
    • tvOSの技術に関する概要的な話はこちらの情報が参考になります。

こちらの記事のご感想を聞かせください。

  • 学びがある
  • わかりやすい
  • 新しい視点

ご感想ありがとうございました

このページの先頭へ