Help us understand the problem. What is going on with this article?

UITableViewを含むセミモーダルビューを引っ張って閉じる

More than 1 year has passed since last update.

最近のアプリでちょくちょく見るようになった、セミモーダル(Semi-Modal)を実装する機会がありましたのでまとめました。UITableViewのスクロールでもモーダルを閉じることができます。

完成後の動作は以下のようになります。

IMB_SNnqVH.gif

GitHubにもサンプルコードをおいておきました。
SemiModalVerticalOverCurrentTransitioning

なお、今回実装するにあたって以下サイトを参考にさせていただきました。
・ 引っ張って閉じることができるモーダルを実装する (UINavigationControllerの場合)
・ [iOS] UIPresentationControllerを使用してカスタムダイアログを実装する

動作のポイント

  • スワイプ量に応じてモーダルビューがスクロールする
  • スワイプ完了時、スワイプ量が一定以上に達していればDismissする。そうでなければキャンセルされ、もとに戻る。
  • UITableViewのスクロールでも閉じることができる
  • スワイプ量に応じて背景の透過度もアニメーションする

以上の動作を実現するにはUIKitの以下機能を使うことで実現できます。

  • UIPresentationController
  • UIViewControllerAnimatedTransitioning
  • UIPercentDrivenInteractiveTransition

関連するクラスが多く、はじめは実装するのが大変ですが、一度理解してしまえば様々な遷移処理に応用できるようになると思います。

ファイル構成

  • ViewController
  • SemiModalViewController
  • ModalPresentationController
  • DismissAnimator
  • OverCurrentTransitioningInteractor
  • OverCurrentTransitionable

modalPresentationStyleをカスタムする

通常のモーダルビューの遷移については、modalPresentationStyleを指定することで遷移時の挙動について制御することができます。
私がよく使っているのは .currentOverContentで、遷移元の表示を背景に残したまま、上にモーダルビューを重ねることができます。

今回セミモーダルへの遷移時、背景の透過率をアニメーションして遷移させています。
この処理をUIPresentationControllerにより実現します。

ModalPresentationController
import UIKit

final class ModalPresentationController: UIPresentationController {
    private let overlayView = UIView()

    override func presentationTransitionWillBegin() {
        super.presentationTransitionWillBegin()

        overlayView.frame = containerView!.bounds
        overlayView.backgroundColor = .black
        overlayView.alpha = 0.0
        containerView!.insertSubview(overlayView, at: 0)
        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] _ in
            self.overlayView.alpha = 0.5
        })
    }

    override func dismissalTransitionWillBegin() {
        super.dismissalTransitionWillBegin()

        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] _ in
            self.overlayView.alpha = 0.0
        })
    }

    override func dismissalTransitionDidEnd(_ completed: Bool) {
        super.dismissalTransitionDidEnd(completed)

        if completed {
            overlayView.removeFromSuperview()
        }
    }

    override var frameOfPresentedViewInContainerView: CGRect {
        return containerView!.bounds
    }

    override func containerViewWillLayoutSubviews() {
        super.containerViewWillLayoutSubviews()

        overlayView.frame = containerView!.bounds
        presentedView!.frame = frameOfPresentedViewInContainerView
    }
}

dismissアニメーションをカスタムする

UIViewControllerAnimatedTransitioningプロトコルが提供する機能によりdismissアニメーションを作成します。
引っ張って閉じるインタラクティブな実装を実現するには、UIViewControllerAnimatedTransitioningと、このあと説明する UIPercentDrivenInteractiveTransitionの実装が必要になります。

DismissAnimator
class DismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.4
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let fromVC = transitionContext.viewController(forKey: .from) else { return }

        let containerView = transitionContext.containerView

        containerView.addSubview(fromVC.view)

        let screenBounds = UIScreen.main.bounds

        let bottomLeftCorner = CGPoint(x: 0, y: screenBounds.height)
        let finalFrame = CGRect(origin: bottomLeftCorner, size: screenBounds.size)
        let option: UIView.AnimationOptions = transitionContext.isInteractive ? .curveLinear : .curveEaseIn

        UIView.animate(withDuration: transitionDuration(using: transitionContext),
                       delay: 0,
                       options: [option],
                       animations: {
                           fromVC.view.frame = finalFrame
                       },
                       completion: { _ in
                           transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                       }
        )
    }
}

ポイントはtransitionContextから、遷移元、遷移先のUIViewControllerを引っ張り出し、遷移中の表示を行う特別なcontainerViewaddSubviewすることです。
遷移元fromVC.viewのframeを下方向へアニメーションさせることでdismissアニメーションを実現しています。

【追記ここから】
また、transitionContext.isInteractiveにより、AnimationのOptionを変更しています。
Optionを変更しないと、常に.curveEaseInのイージングが指定された状態となるため、引っ張って閉じる際、パンジェスチャー開始直後は指にModalが遅れてついてくるようになり、UXが損なわれます。
【追記ここまで】

インタラクティブにdismissアニメーションをコントロールする

UIPercentDrivenInteractiveTransitionのサブクラスを実装します。
update(_ percentComplete: CGFloat)メソッドの引数へ画面の何割まで閉じたかを渡すことにより、dismissアニメーションをコントロールすることができます。
また、finish() cancel()メソッドをコールすることでインタラクションの終了、キャンセルをすることができます。

今回実装するサブクラスでは、セミモーダルのdismissアニメーション開始状態をStateとして保持させています。
また、UITableViewのスクロールでもdismissするための処理もあります。

【追記ここから】
インタラクションによるDismiss終了時に意図せず高速に閉じてしまうことがあります。
completionSpeedを適宜設定することで閉じるスピードをコントロールすることができます。
【追記ここまで】

OverCurrentTransitioningInteractor
import UIKit

class OverCurrentTransitioningInteractor: UIPercentDrivenInteractiveTransition {
    /// インタラクションの状態
    ///
    /// - none: 開始していない
    /// - shouldStart: 開始できる(開始していない)
    /// - hasStarted: 開始している
    /// - shouldFinish: 終了できる(終了していない)
    enum State {
        case none
        case shouldStart
        case hasStarted
        case shouldFinish
    }

    var state: State = .none

    var startInteractionTranslationY: CGFloat = 0

    var startHandler: (() -> Void)?

    var resetHandler: (() -> Void)?

    /// インタラクションのキャンセル、終了時のAnimation Durationスピードを変更する
    /// デフォルトのままだと、高速に閉じてしまい、瞬間移動しているように見えるため、ここで調整している。
    override func cancel() {
        completionSpeed = percentComplete
        super.cancel()
    }

    override func finish() {
        completionSpeed = 1.0 - percentComplete
        super.finish()
    }

    func setStartInteractionTranslationY(_ translationY: CGFloat) {
        switch state {
        case .shouldStart:
            /// Interaction開始可能な際にInteraction開始までの間更新し続けることで、開始時のYを保持する
            startInteractionTranslationY = translationY
        case .hasStarted, .shouldFinish, .none:
            break
        }
    }

    func updateStateShouldStartIfNeeded() {
        switch state {
        case .none:
            /// .none -> shouldStartへ更新
            state = .shouldStart
            startHandler?()
        case .shouldStart, .hasStarted, .shouldFinish:
            break
        }
    }

    func reset() {
        state = .none
        startInteractionTranslationY = 0
        resetHandler?()
    }
}

モーダルビューを閉じる際に、PanGesture.translation.yを取得し、画面全体の何割までスクロールしたかを元にdismissをアニメーションさせる処理を、UIViewControllerのProtocolExtensionとして定義します。
ここで、UIPercentDrivenInteractiveTransitionのサブクラスで定義したStateを元にdismissアニメーションをコントールします。

【追記ここから】
また、インタラクションの終了条件に、スクロール速度velocityを加えると、より自然な動きになります。
【追記ここまで】

OverCurrentTransitionable
import UIKit

protocol OverCurrentTransitionable where Self: UIViewController {
    var percentThreshold: CGFloat { get }
    var interactor: OverCurrentTransitioningInteractor { get }
}

extension OverCurrentTransitionable {
    var shouldFinishVerocityY: CGFloat {
        return 1200
    }
}

extension OverCurrentTransitionable {
    func handleTransitionGesture(_ sender: UIPanGestureRecognizer) {

        /// TableViewからPanGestureを取得する場合、
        /// dismiss開始をsender.state.beganで判断できないため、Interactor.stateで判定している
        switch interactor.state {
        case .shouldStart:
            interactor.state = .hasStarted
            dismiss(animated: true, completion: nil)
        case .hasStarted, .shouldFinish:
            break
        case .none:
            return
        }

        /// セミモーダルが画面の何割移動したかを計算
        let translation = sender.translation(in: view)
        let verticalMovement = (translation.y - interactor.startInteractionTranslationY) / view.bounds.height
        let downwardMovement = fmaxf(Float(verticalMovement), 0.0)
        let downwardMovementPercent = fminf(downwardMovement, 1.0)
        let progress = CGFloat(downwardMovementPercent)

        /// PanGesture.stateごとに、インタラクションの更新、終了、キャンセルを制御
        switch sender.state {
        case .changed:
            /// スクロール量がしきい値を超えたか? もしくは スクロール速度がしきい値を超えたか?
            if progress > percentThreshold || sender.velocity(in: view).y > shouldFinishVerocityY {
                interactor.state =  .shouldFinish
            } else {
                interactor.state =  .hasStarted
            }
            interactor.update(progress)
        case .cancelled:
            interactor.cancel()
            interactor.reset()
        case .ended:
            switch interactor.state {
            case .shouldFinish:
                interactor.finish()
            case .hasStarted, .none, .shouldStart:
                interactor.cancel()
            }
            interactor.reset()
        default:
            break
        }
    }
}

セミモーダルビューを実装する

ここまで実装したクラスを使い、遷移後のセミモーダルビューを実装します。

SemiModalViewController
import UIKit

final class SemiModalViewController: UIViewController, OverCurrentTransitionable {
    var percentThreshold: CGFloat = 0.3
    var interactor = OverCurrentTransitioningInteractor()

    private var tableViewContentOffsetY: CGFloat = 0.0

    @IBOutlet private weak var headerView: UIView!
    @IBOutlet private weak var tableView: UITableView!
    @IBOutlet private weak var backgroundView: UIView!

    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
        transitioningDelegate = self
        modalPresentationStyle = .custom
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        transitioningDelegate = self
        modalPresentationStyle = .custom
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        interactor.startHandler = { [weak self] in
            self?.tableView.bounces = false
        }
        interactor.resetHandler = { [weak self] in
            self?.tableView.bounces = true
        }

        setupViews()
    }

    private func setupViews() {
        headerView.layer.cornerRadius = 8.0
        headerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
        let headerGesture = UIPanGestureRecognizer(target: self, action: #selector(headerDidScroll(_:)))
        headerView.addGestureRecognizer(headerGesture)

        let gesture = UITapGestureRecognizer(target: self, action: #selector(backgroundDidTap))
        backgroundView.addGestureRecognizer(gesture)

        let tableViewGesture = UIPanGestureRecognizer(target: self, action: #selector(tableViewDidScroll(_:)))
        tableViewGesture.delegate = self
        tableView.addGestureRecognizer(tableViewGesture)
        tableView.delegate = self
        tableView.dataSource = self
    }

    static func make() -> SemiModalViewController {
        let sb = UIStoryboard(name: "SemiModalViewController", bundle: nil)
        let vc = sb.instantiateInitialViewController() as! SemiModalViewController
        return vc
    }

    @objc private func backgroundDidTap() {
        dismiss(animated: true, completion: nil)
    }

    @objc private func headerDidScroll(_ sender: UIPanGestureRecognizer) {
        /// ヘッダービューをスワイプした場合の処理
        /// コールされたと同時にインタラクション開始する。
        interactor.updateStateShouldStartIfNeeded()
        handleTransitionGesture(sender)
    }

    @objc private func tableViewDidScroll(_ sender: UIPanGestureRecognizer) {
        /// テーブルビューをスワイプした場合の処理
        /// テーブルビューのスクロールがトップ位置にまでスクロールされた場合にインタラクションを開始
        if tableViewContentOffsetY <= 0 {
            interactor.updateStateShouldStartIfNeeded()
        }
        /// インタラクション開始位置と、テーブルビュースクロール開始位置が異なるため、インタラクション開始時のY位置を取得している
        interactor.setStartInteractionTranslationY(sender.translation(in: view).y)
        handleTransitionGesture(sender)
    }
}

extension SemiModalViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 30
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "Cell")
        cell.textLabel?.text = String(indexPath.row)
        return cell
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        tableViewContentOffsetY = scrollView.contentOffset.y
    }
}

extension SemiModalViewController: UIGestureRecognizerDelegate {
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
}

/// 以下デリゲートメソッドで、セミモーダルの閉じる処理に関する設定している
extension SemiModalViewController: UIViewControllerTransitioningDelegate {
    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
        /// セミモーダル遷移時の背景透過アニメーションを行うPresentationControllerを返却
        return ModalPresentationController(presentedViewController: presented, presenting: presenting)
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        /// dismiss時のアニメーションを定義したクラスを返却
        return DismissAnimator()
    }

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        /// インタラクション開始している場合にはinteractorを返却する
        /// 開始していない場合はnilを返却することでインタラクション無しのdissmissとなる
        switch interactor.state {
        case .hasStarted, .shouldFinish:
            return interactor
        case .none, .shouldStart:
            return nil
        }
    }
}

SemiModalViewControllerの初期化時に、modalPresentationStyle = .customを設定し、transitioningDelegate = selfとして、UIViewControllerTransitioningDelegateのデリゲートメソッドを実装しています。

headerViewと、tableViewそれぞれにUIPanGestureRecognizerを追加しています。
UITableView.scrollViewDidScroll(_ scrollView: UIScrollView)からスクロール量を拾うことは可能ですが、UITableViewのスクロール位置がTopとなった際に、tableView.bounces = trueとしているため、Topより上へのスクロールが拾えません。そのための措置です。

また、UITableViewへPanGestureを追加すると、そのままではTableView側のPanGestureが奪われてしまいスクロールできなくなってしまいます。
それを回避するため、UIGestureRecognizerDelegate gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Boolを追加し、return trueとすることで、複数のGestureを許容しています。
実装する機能によってこのメソッド内で、引数をもとに有効、無効を切り替えることができます。

UITableViewのスクロールの挙動でbouncesの状態を管理するため、interactor.startHandler interactor.resetHandler でインタラクション開始、終了時にbouncesの状態を更新しています。
これにより、セミモーダルを閉じるインタラクションが開始していない場合は、bouncesが有効となり、スクロール時の慣性でTopまで達した際にバウンスする自然な挙動となります。

セミモーダルを開く

ここまで実装すればあとは遷移処理を実装するだけです。
セミモーダルに関する処理は遷移先にすべて実装しているため、SemiModalViewControllerを初期化してpresentするだけで遷移できます。

ViewController
import UIKit

class ViewController: UIViewController {

    @IBAction func buttonDidTap(_ sender: Any) {
        let vc = SemiModalViewController.make()
        present(vc, animated: true, completion: nil)
    }
}

補足

DismissAnimatorの実装で、遷移先UIViewController.viewcontainerViewaddSubviewすると、遷移完了後に遷移後の画面が消えてしまいました。
おそらく、Dismiss完了後、遷移先モーダルビューのインスタンスが破棄されたのと合わせて一緒に破棄されてしまっているようです。
DismissAnimatorの実態を遷移元で保持していれば問題ないかもしれません(検証していません)
なお、今回はセミモーダルを閉じる処理を実装する上で、遷移元fromVC.viewをアニメーションすることが目的であるため、遷移先UIViewControllerは参照していません。

iincho
フリーランスでiPhoneアプリ開発をしています。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away