最近話題になった以下の記事でディスられた件

tvOS Advent Calendar 2017 9日目 および AbemaTV Advent Calendar 2017 9日目 の記事です。
本日は会社のブログからこんにちは。@toshi0383です。

Apple TV Appのデザインのポイントや注意点など

最近話題になったこちらの記事。内容がとてもわかりやすく、AppleTVに馴染みがない方でも、開発のイメージがつく良記事でした。AbemaTVについても例として触れられていて、

(例としてAbemaTV Appではタップでチャンネルが切り替わってしまい、便利?な反面、誤操作も発生しやすく非常にストレスを感じる)

と、なかなか手厳しいご指摘をいただきました?
書き方にちょっと棘があるのは置いておいて、指摘の内容は的を得ていました。確かにリモコンを置いたり拾ったりする時にうっかり端をタッチしてしまうことがあるんですよね。

対応しましたが何か?

というわけでこの誤操作問題、早速対応しました。すでにストアにリリース済みです。上の記事が書かれたのが11/16で、リリースされたのは11/28です? 今のチームはこういうフットワークの軽さがあっていいなーと思います。

フットワークの軽さというか、「10秒経ったらリモコンを置いたとみなして1度だけ左右端のタッチを無視する」という対応を勝手にサクッと実装して、デザイナーとディレクターに「どう?」と確認してそのままGO!です。

軽いどころの話ではないと思いますが、「ちょっと実装してみたんだどどう?」「いいね!」で世の中にすぐ出していけるというのはとても楽しいしやりがいがありますね。

まあ実際のところtvOSチームはそこまでリリース頻度高くないので、今回たまたまタイミングが良かっただけなんですけどね?
(今回大きな機能追加があり2ヶ月ぶりのリリース?)

上下左右の端をタッチ・タップは必要なのか

上の記事でも、以下のように推奨されていました。

# 上下左右の端をタッチ・タップ・クリックはショートカット的オプションな利用にする
基本的に上下左右の端をタッチ・タップ・クリックするという動作にユーザーは気づけません(ミスでやらない限り基本的にその操作をしないと思われる)。 ですので、基本的に重要な操作はそれに割り当てず、他の方法でもできる操作のショートカットとして使用することをおすすめします。

確かにそうかもと思っていたのですが、実はこの見解には大きな見落としがあったのです。

Nimbusの存在

http://apple.com/tv に行くと、こういうコントローラが紹介されています。気がついている人はどれくらいいるでしょうか?
nimbus

SteelSeries Nimbus Wireless Gaming Controllerという商品名。

AppleTV 第4世代が登場したその日から存在しています。が、まあ使っている人は見たことありませんね。しかし公認なので、アプリもこれを使って操作できる必要があります。

早速会社に頼んでゲーム用に検証用に手配してもらいました。

早速ゲームしy検証。

薄々気づいていましたが、「スワイプ」と「再生ボタン」の概念がありません。上下左右の移動は、方向キーやL,Rキーで操作します。これらはSiri Remoteでいう「上下左右の端をタッチ・タップ」に相当します。つまりもし上下左右の端を完全に反応しないようにしてしまうと、このコントローラで操作できないということになります。

つまり、 上下左右の端をタッチ・タップはショートカットではなく通常の操作として割り当てるべき です。

こういうわけで今回の対応、「10秒経ったらリモコンを置いたとみなして1度だけ左右端のタッチを無視する」の対応に落ち着きました。

実装方法

最後に実装方法を紹介します。

AbemaTV tvOSのテレビ面はUIPageViewControllerをそのまま使っています。
ちょうどこちらの記事で端のタッチを検知する方法はわかっているので、あとはそのイベントを元にUIPageViewControllerDataSourceの処理に手を入れれば良いです。

GestureTVをCarthageでインストールして、

github "toshi0383/GestureTV"

コードは以下のようになります。


import GestureTV
import UIKit

private enum Const {
    static let noInteractionTimeInterval: TimeInterval = 10
}

class PageViewController: UIPageViewController, UIPageViewControllerDataSource {
    private var vcs: [UIViewController]!
    private var lastTouchedTime = Date().timeIntervalSince1970
    private var touchManagerToken: TouchManager.DisposeToken?

    override func viewDidLoad() {
        super.viewDidLoad()
        self.dataSource = self
        let vc1 = UIViewController()
        vc1.view.backgroundColor = .gray
        let vc2 = UIViewController()
        vc2.view.backgroundColor = .green
        vcs = [vc1, vc2]
        self.setViewControllers([vc1], direction: .forward, animated: true, completion: nil)
        
        // TouchManagerにイベント登録
        touchManagerToken = TouchManager.shared.addObserver { [weak self] _ in
            DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 0.5) { [weak self] in
                self?.lastTouchedTime = Date().timeIntervalSince1970
            }
        }
    }

    // 一定時間以上経っていた時の左右端のタッチは無視
    private var shouldIgnoreTouch: Bool {
        if Date().timeIntervalSince1970 - Const.noInteractionTimeInterval > lastTouchedTime {
            let touchState = TouchManager.shared.touchState
            // GestureTV 0.3.0で同時複数コントローラに対応しました.
            // Nimbusの場合、touchUp/touchDownという概念はないため、
            // このコードではif文の中に入らない点に注意.
            if case .touchUp = touchState, touchState.absoluteX > 0.8 {
                return true
            }
        }
        return false
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        if shouldIgnoreTouch {
            return nil
        }
        if let index = vcs.index(of: viewController), index < vcs.count - 1 { return vcs[index + 1] } return nil } func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        if shouldIgnoreTouch {
            return nil
        }
        if let index = vcs.index(of: viewController), 0 < index {
            return vcs[index - 1]
        }
        return nil
    }
}

これで、以前よりもさらに使いやすいテレビ面を実現できたかなと思います。

まとめ

上の記事が出たときチーム内もちょっとモヤッとした雰囲気が漂ったので、短期間で解決策をリリースできてよかったです。なんだかカウンター記事みたいになっちゃいましたが、チームのスピード感や雰囲気を伝えたいという意図もありあえてこういう記事にしてみました。
もちろんタスクを全て期限内に片付けた上でないとこういう動きはできないので、その辺りはエンジニアの力量にかかっていると思いますが、今スプリントは俺結構頑張った!?

最後に、上記のコードは以下のライブラリのサンプルコードに含めてありますので、最新動向についてはこちらをご確認いただければと思います。

https://github.com/toshi0383/GestureTV