tomoyaonishiのブログ

iOSのことを中心に・・・その他もあるよ!

UIVIewControllerカスタムトランジションを理解する 1

前回以下の記事でカスタムアニメーションとカスタムプレゼンテーションについて説明しました。

UIVIewControllerカスタムトランジションを理解する 1 - tomoyaonishiのブログ

前回の記事で説明しきれなかったインタラクティブトランジションについて説明します。

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

UIViewControllerInteractiveTransitioningというものを使います。

UIViewControllerInteractiveTransitioning は非常にシンプルなprotocolで、今回は public func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) を使います。

これに関しては、ソースコードをみたほうがわかりやすいとおもいます。

まずはtransitioningDelegateを以下のようにします。この辺わからない人は前回の記事をみてください。

extension InteractiveTransitionViewController: UIViewControllerTransitioningDelegate {
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return NoAnimatedAnimationController()
    }
    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return pullToDismissTransitionController
    }
}

interactionControllerForDismissalでdismissをインタラクティブにするということになります。presentのときは同じようなメソッドがあるのでそちらを実装します。

では、実際に指に追従するようなViewControllerのdismissの仕方はどのように実装するのでしょうか。

このようになります。

class NoAnimatedAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0 }
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { }
}

class PullToDismissTransitionController: NSObject, UIViewControllerInteractiveTransitioning {
    var isInteractive: Bool = true
    weak var viewController: UIViewController?
    weak var transitionContext: UIViewControllerContextTransitioning?

    init(viewController: UIViewController) {
        self.viewController = viewController
    }

    func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
        self.transitionContext = transitionContext
    }

    @objc func observePanGesture(panGesture: UIPanGestureRecognizer) {
        guard let viewController = viewController else {
            return
        }
        guard let view = panGesture.view else {
            return
        }

        let translation = panGesture.translation(in: view)
        let velocity = panGesture.velocity(in: view)

        switch panGesture.state {
        case .possible:
            break
        case .began:
            viewController.dismiss(animated: true, completion: nil)
        case .changed:
            view.transform.ty = max(translation.y, 0)
        case .ended, .failed, .cancelled:
            if velocity.y > 0 && translation.y > 80 {
                let initialVelocity = abs(velocity.y)
                let distance = view.bounds.height - translation.y

                UIView.animate(withDuration: 0.45,
                               delay: 0,
                               usingSpringWithDamping: 1.0,
                               initialSpringVelocity: initialVelocity / distance,
                               options: .curveEaseInOut,
                               animations: {
                                view.transform.ty = view.bounds.maxY
                },
                               completion: { _ in
                                self.transitionContext?.completeTransition(true)
                })
            } else {
                guard translation.y > 0 else {
                    self.transitionContext?.completeTransition(false)
                    return
                }

                let initialVelocity = velocity.y > 0 ? 0 : abs(velocity.y)
                let distance = translation.y

                UIView.animate(withDuration: 0.45,
                               delay: 0,
                               usingSpringWithDamping: 1.0,
                               initialSpringVelocity: initialVelocity / distance,
                               options: .curveEaseInOut,
                               animations: {
                                view.transform = .identity
                },
                               completion: { _ in
                                self.transitionContext?.completeTransition(false)
                })
            }
        }
    }
}

self.transitionContext?.completeTransition(true) self.transitionContext?.completeTransition(false) viewController.dismiss(animated: true, completion: nil)

この辺がミソでしょうか。トランジションが完了したのか、キャンセルされてもとに戻るのか、dismiss自体をいつ開始させるかを自分で書く感じです。

ViewController側でPullToDismissControllerのobservePanGesture()にUIPanGestureをセットしてあげます。

final class InteractiveTransitionViewController: UIViewController {
    lazy var pullToDismissTransitionController = PullToDismissTransitionController(viewController: self)

    override func viewDidLoad() {
        super.viewDidLoad()
        let pan = UIPanGestureRecognizer(target: pullToDismissTransitionController,
                                         action: #selector(PullToDismissTransitionController.observePanGesture(panGesture:)))
        view.addGestureRecognizer(pan)
    }
}

これで完成です。

カスタムトランジションで登場するクラスやprotocolはどれも名前がわかりにくいですが、理解すればかなり使いまわしがしやすい設計になっているようです。

また、ViewControllerと密接に関わるため、お互いに参照をもたせてしまうようなやり方が一番いいかもしれません。 他にも実際にリッチに作ろうとすると必要なフラグも多くなり、ただでさえややこしいので変に設計を意識せずに惜しみなく変数にもたせてしまうやり方がいいとおもいます。

インタラクティブトランジションと通常のトランジションを切り替えたいときは、interactionControllerForDismissal()でnilを返せばよいです。

カスタムトランジションはカスタムアニメーション、カスタムプレゼンテーション、インタラクティブトランジションの組み合わせで自由に作ることができます。それぞれを単品で理解した上で、取り掛からないとわけがわからなくなってしまうと思うので、1つずつ丁寧にみてみてください。

また、UIPercentDrivenInteractiveTransitionというクラスがありますが、こちらは進捗率に応じて、AnimationControllerで定義したアニメーションを進めるというものです。使い分けするといいでしょう。

ソースコードはこちら。

github.com