iOS minne

minne iOS アプリで作品画面をひっぱって閉じられるようにしました

iOS minne

minne の iOS アプリを開発しているこじこじです。ハンバーガーとケバブが大好きです。
先日、作品をサクサク閲覧できるようにするために ひっぱって閉じる をリリースしたので、この機能開発についてご紹介していきたいと思います。

ひっぱって閉じるとは

名前からイメージできるかと思いますが、画面を下にひっぱることで1つ前の画面に戻れるようにする機能のことを、 minne では ひっぱって閉じる と呼んでいます。

iPhone には、 Android のスマートフォンのように下部に戻るボタンが存在しないので、アプリ内で画面を戻るためには「戻るボタンのタップ」や「エッジスワイプ」などの操作を行う必要があります。
そんな中、Plus 系 の 5.5 インチディスプレイの端末や、 iPhone X といった、片手では収まらないような大きい画面サイズの端末が登場してきました。
これらの端末で戻る操作を行うには、両手を使わなければならなかったりして、不便と思われている方は多くいらっしゃるのではないでしょうか。

ひっぱって閉じる は、この不便さを少なくすることを目的としています。
そうすることで多くの作品を快適にサクサクと見られるようになり、お気に入りの作品を見つけやすくなるはずです。

実装

ひっぱって閉じるは大きく2つの要素から構成されています。
1つはカスタムトランジション、もう1つはインタラクティブトランジションです。

全体の流れは、 Apple の View Controller Programming Guide for iOS (Objective-C) を参考にしていただければと思います。

カスタムトランジション

カスタムトランジションとは、アニメーションをカスタマイズした画面遷移のことです。
UIKit ではこのための API が提供されており、これを使用することで、左右に画面が移動するデフォルトのアニメーション以外のアニメーションで画面遷移を行うことができます。

具体的な実装については私の個人ブログに書いてありますので、よろしければご覧ください。

このカスタムトランジションですが、エッジスワイプに適用する際には、デフォルトのエッジスワイプを無効にした後、新たに自前でエッジスワイプを実装しなければならないため、ある程度実装量が増えてしまうということに注意が必要です。

minne でも、作品画面における画面遷移に一貫性を持たせるために、ひっぱって閉じる以外の方法でもカスタムトランジションに変更したかったので、エッジスワイプによる画面遷移を自前で実装しています。

インタラクティブトランジション

インタラクティブトランジションとは、ユーザの操作と対話的に進捗が変化していく画面遷移のことです。
デフォルトの画面遷移も、エッジスワイプの時にはインタラクティブトランジションで、スワイプに合わせて対話的に画面遷移の進捗が変化します。

minne でも、ひっぱって閉じるやエッジスワイプを自前で実装する際には、より自然に操作できるよう、これらの2つともインタラクティブトランジションの実装をしています。

両者は、スワイプの方向が異なる点以外には基本的に違いはないので、以下ではエッジスワイプの場合のコードのみ示しておきます。

// 画面遷移のメソッドを実行するための Delegate
protocol ZoomPercentDrivenInteractiveTransitionDelegate: class {
    func zoomPercentDrivenInteractiveTransitionScreenEdgePanBegan()
}

// インタラクティブトランジションオブジェクト
class ZoomPercentDrivenInteractiveTransition: UIPercentDrivenInteractiveTransition {
    weak var delegate: ZoomPercentDrivenInteractiveTransitionDelegate?

    // UINavigationController の Delegate メソッドで
    // インタラクティブかどうかを判断する必要があるのでフラグを持つ
    var isInteractive = false
    
    // このメソッドをビューに追加したエッジパンジェスチャのハンドラとして登録しておく
    func handle(screenEdgePanGestureRecognizer: UIScreenEdgePanGestureRecognizer) {
        guard let view = screenEdgePanGestureRecognizer.view else {
            return
        }

        switch screenEdgePanGestureRecognizer.state {
        case .began:
            // ジェスチャが開始したタイミングで一度だけこのスコープに入る
            // delegate で VC の push メソッドを実行して画面遷移を開始する
            delegate?.zoomPercentDrivenInteractiveTransitionScreenEdgePanBegan()
        case .changed:
            // 画面サイズとスワイプの移動量から進捗を計算する
            let progress = screenEdgePanGestureRecognizer.translation(in: view).x / view.bounds.width

            // update に進捗を渡すだけでアニメーションが進捗に合わせて変化する
            update(progress)
        case .cancelled, .ended:
            let progress = screenEdgePanGestureRecognizer.translation(in: view).x / view.bounds.width
            let velocity = screenEdgePanGestureRecognizer.velocity(in: view).x
            
            // 画面から指が離れたタイミングでのスワイプの量と速度を見て
            // 画面遷移を完了させるか中断させるかを判断する
            if velocity < 0.0 || progress < 0.1 {
                // cancel を呼ぶと画面遷移が中断されて元の画面が表示される
                cancel()
            } else {
                // finish を呼ぶと画面遷移が最後まで行われる
                finish()
            }
        case .possible, .failed:
            break
        }
    }
}

上記のインタラクティブトランジションオブジェクトを画面遷移時に使用することで、通常の画面遷移をインタラクティブにできます。

具体的には、 UINavigationControllerDelegate の以下のメソッドでインタラクティブトランジションオブジェクトのインスタンスを返してあげます。

optional func navigationController(_ navigationController: UINavigationController, 
          interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning?

minne には UINavigationController のサブクラスが存在するので、以下のように、そのクラスにインスタンスを持たせて、インタラクティブトランジションをする必要があるときにだけ返してあげるようにしています。

class MinneNavigationController: UINavigationController {
    let zoomPercentDrivenInteractiveTransition = ZoomPercentDrivenInteractiveTransition()

    override func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        // 画面遷移時に呼ばれるメソッド
        // zoomPercentDrivenInteractiveTransition を返すとインタラクティブになり nil を返すと通常の画面遷移になる
        return zoomPercentDrivenInteractiveTransition.isInteractive ? zoomPercentDrivenInteractiveTransition : nil
    }
}

リリースしてみて

実装してみて改めて実感したのは、画面遷移に手を加えるということは簡単には見過ごすことのできないデメリットがあるということです。
例えば、カスタムトランジションやインタラクティブトランジョンのための API が UIKit で用意されているとはいえ、アニメーション処理や画面遷移の進捗管理は自前で実装しなければならず、どうしてもある程度煩雑なコードを書くことになってしまいました。
加えて、画面遷移というアプリの中枢機関の処理を修正することになるので、影響範囲がとても大きいです。
さらには、iOSのバージョンアップ時の対応が必要になる可能性もあります。

ですが、ひっぱって閉じるの実装は、 minne においては十分に見合う効果が得られたと思います。
ひっぱって閉じるの効果を測定するために、主に、1セッションあたりに作品画面を閲覧した回数を追っていきました。 リリース前後でこの数値を比較してみたところ、まだリリースして1週間程度なので今後変動する可能性はありますが、およそ 13 % 上昇しており、大幅に改善されていることが確認できました。

今後もインタラクションの調整は行っていきたいと思いますが、こういったメリットとデメリットを比べて慎重に行っていきたいと思います。

最後に

弊社CTLが常々口にしているのですが、モバイルアプリの特徴の1つは、画面間のつながりを表現しやすいところです。
この表現を豊かにすることで、ユーザの求める情報を、よりストレスのない形で提供できると思います。

今後も、この点を意識してより良いプロダクトへと成長させていきます!