your3i’s blog

iOSエンジニア。頑張る⚉

iOS Custom Presentation & Transition (4) 〜Interactive Transition〜

はじめに

いよいよラストになった!!ついつい長引いちゃった。
一応頭の方で説明した方がいいかな。
この記事の内容は以下の記事と繋いている、そして今回はPart4でラストだ。

iOS Custom Presentation & Transition (1) 〜コード一行もない編〜 - your3i’s blog
iOS Custom Presentation & Transition (2) 〜UIPresentationControllerでカスタムモーダルを作る〜 - your3i’s blog
iOS Custom Presentation & Transition (3) 〜Custom Transition〜 - your3i’s blog

前回までは

前回までは、dismissするときのアニメーションをカスタマイズしたところまでだった。そのカスタマイズは今回のinteractive transitionのために必要で、ときにdismiss transitionの見た目は変えてない。

f:id:your3i:20181015220910g:plain

今回の目標は

今回の目標は、dismissするとき、detailsViewController(緑のview)をdrag gestureで閉じれるようにする。

UIPercentDrivenInteractiveTransition

Yeah! これを使って実現する。
UIPercentDrivenInteractiveTransitionプロトコルの役割はtransitionなんパーセント進んだのかの報告やinteractive transitionを完成するかどうかのコントロールだ。

これをadoptするクラスは論理上なんでもよくて、UIViewControllerTransitioningDelegateで返してあげれば問題なし。要するに、DetailsViewController自分でadoptしてもできる。でも今回はinteractorになるCustomDismissInteractorというクラスを作った。

CustomDismissInteractorクラスの作成

とりあえずまずCustomDismissInteractorクラスを作る。

final class CustomDismissInteractor: UIPercentDrivenInteractiveTransition {
// まだ空っぽ
}

UIViewControllerTransitioningDelegateで返す。DetailsViewControllerの中:

class DetailsViewController: UIViewController, UIViewControllerTransitioningDelegate {

    private var interactor: CustomDismissInteractor!

...

    override func viewDidLoad() {
        super.viewDidLoad()
        // ここでinteractorを作って保存しておく
        interactor = CustomDismissInteractor()
    }

...

    // MARK: - UIViewControllerTransitioningDelegate

    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
        return CustomPresentationController(presentedViewController: presented, presenting: presenting)
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomDismissAnimator()
    }

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        // viewDidLoadで作ったinteractorを返す
        return interactor
    }
}

注意:interactorはなんでviewDidLoadで作らなきゃいけないだろう?interactionControllerForDismissalが呼ばれるたびに作ると、作られたinteractorが誰でもretainしなくて、メソッド完了時点ですぐクリアされる。transitionの進捗も、わかる人いなくなったということだ。だから、detailsViewControllerがinteractorをもつ必要がある。

gesture recognizerをセットアップする前の準備

今回はやることをCustomDismissInteractorに寄せたいので、presentedControllerとどのviewにgestureを追加するのか、をCustomDismissInteractorに渡す。

CustomDismissInteractorに

    private var interactiveView: UIView!

    private var presented: UIViewController!

    static func instance(_ presented: UIViewController, panOnView view: UIView) -> CustomDismissInteractor {
        let interactor = CustomDismissInteractor()
        interactor.interactiveView = view
        interactor.presented = presented
        return interactor
    }

そしたら、DetailsViewControllerでのinteractorのinit方法もこっちに変える:

    override func viewDidLoad() {
        super.viewDidLoad()
        interactor = CustomDismissInteractor.instance(self, panOnView: view)
    }

UIPanGestureRecognizerをセットアップ

drag dismissはUIPanGestureRecognizerで実現する。CustomDismissInteractorにgestureを追加するviewを渡したので、その中でgestureのセットアップをやる。

    static func instance(_ presented: UIViewController, panOnView view: UIView) -> CustomDismissInteractor {
        let interactor = CustomDismissInteractor()
        interactor.interactiveView = view
        interactor.presented = presented
        // setupのメソッドを呼び出す
        interactor.setupPanGesture()
        return interactor
    }

    private func setupPanGesture() {
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
        panGesture.maximumNumberOfTouches = 1
        interactiveView.addGestureRecognizer(panGesture)
    }

    @objc func handlePan(_ sender: UIPanGestureRecognizer) {
        // 次のステップで
    }

こうして、detailsViewControllerのviewにpan gesture recognizerを追加した。

UIPanGestureRecognizerのハンドリング

    @objc func handlePan(_ sender: UIPanGestureRecognizer) {
        // 0.4パーセント以上完成したらinteractive transition portionを終了
        let threshold: CGFloat = 0.4

        switch sender.state {
        case .began:
            // gestureのstart pointのを設定
            sender.setTranslation(.zero, in: interactiveView)
            // dismiss transitionをスタート(呼ばないとdismiss始まらない)
            presented.dismiss(animated: true, completion: nil)
        case .changed:
            // 進捗pointをとる
            let translation = sender.translation(in: interactiveView)

            guard translation.y >= 0 else {
                // 上にドラッグできなようにしたいから、上にpan gestureが移動したらとにかく.zeroとして認識
                sender.setTranslation(.zero, in: interactiveView)
                return
            }

            // パーセンテージを計算
            let percentage = abs(translation.y / interactiveView.bounds.height)
            update(percentage)
        case .ended:
            // pan gestureが終了下から、ここはtransitionを完成するかどうかの判断
            if percentComplete >= threshold {
                finish()
                // 完成するなら、pan gesture recognizerをremove
                interactiveView.removeGestureRecognizer(sender)
            } else {
                cancel()
            }
        case .cancelled, .failed:
            // pan gesture が正しく完了してない場合、transitionをキャンセル
            cancel()
        default:
            break
        }
    }

挙動をみる:
f:id:your3i:20181022004142g:plain

drag dismiss できた!!!
でも通常のボタンタップしてdismissするのがこけてる!!
なぜだ!!!!

interactive dismiss と normal dismiss の区別

前のステップで、通常のdismissが動かなくなった原因は:
通常のdismissするときも、animator側(前回の記事を参照)が、これはただのtransitionなのか、それともinteractive transitionなのか、わからないので、UIViewControllerTransitioningDelegateのinteractionControllerForDismissalをとにかく読んでるらしい。今はinteractorを返してるから、interactive transitionだとみられたらしく、アニメーションがはじまらない。

色々書いてて、要するに、ボタンタップからのdismissなのかそれともgesture からはじまったdismiss なのかの判断は、UIViewControllerTransitioningDelegateのinteractionControllerForDismissalの返してる値がnilかどうかによるのだ。

DetailsViewControllerで、interactorがin progessのときだけ、interactorを返すように。

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        guard interactor.interactionInProgress else {
            return nil
        }
        return interactor
    }

interactorのinteractionInProgressプロパティがまだないから、追加:

final class CustomDismissInteractor: UIPercentDrivenInteractiveTransition {

...

private(set) var interactionInProgress = false

...

    @objc func handlePan(_ sender: UIPanGestureRecognizer) {
        let threshold: CGFloat = 0.4

        switch sender.state {
        case .began:
            // interactive transition portion start
            interactionInProgress = true
            sender.setTranslation(.zero, in: interactiveView)
            presented.dismiss(animated: true, completion: nil)
        case .changed:
            let translation = sender.translation(in: interactiveView)
            guard translation.y >= 0 else {
                sender.setTranslation(.zero, in: interactiveView)
                return
            }

            let percentage = abs(translation.y / interactiveView.bounds.height)
            update(percentage)
        case .ended:
            if percentComplete >= threshold {
                finish()
                interactiveView.removeGestureRecognizer(sender)
            } else {
                cancel()
            }
            // interactive transition portion end
            interactionInProgress = false
        case .cancelled, .failed:
            cancel()
            // interactive transition portion end
            interactionInProgress = false
        default:
            // interactive transition portion end
            interactionInProgress = false
        }
    }
}

完成!

f:id:your3i:20181022010248g:plain