iOS
UIKit
Swift

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

最近のアプリでちょくちょく見るようになった、セミモーダル(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)

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


ポイントはtransitionContextから、遷移元、遷移先のUIViewControllerを引っ張り出し、遷移中の表示を行う特別なcontainerViewaddSubviewすることです。

遷移元fromVC.viewのframeを下方向へアニメーションさせることでdismissアニメーションを実現しています。


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

UIPercentDrivenInteractiveTransitionのサブクラスを実装します。

update(_ percentComplete: CGFloat)メソッドの引数へ画面の何割まで閉じたかを渡すことにより、dismissアニメーションをコントロールすることができます。

また、finish() cancel()メソッドをコールすることでインタラクションの終了、キャンセルをすることができます。

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

また、UITableViewのスクロールでもdismissするための処理もあります。


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)?

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アニメーションをコントールします。


OverCurrentTransitionable

import UIKit

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

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:
interactor.state = progress > percentThreshold ? .shouldFinish : .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は参照していません。