最近のアプリでちょくちょく見るようになった、セミモーダル(Semi-Modal)を実装する機会がありましたのでまとめました。UITableView
のスクロールでもモーダルを閉じることができます。
完成後の動作は以下のようになります。
GitHubにもサンプルコードをおいておきました。
SemiModalVerticalOverCurrentTransitioning
なお、今回実装するにあたって以下サイトを参考にさせていただきました。
・ 引っ張って閉じることができるモーダルを実装する (UINavigationControllerの場合)
・ [iOS] UIPresentationControllerを使用してカスタムダイアログを実装する
##動作のポイント
- スワイプ量に応じてモーダルビューがスクロールする
- スワイプ完了時、スワイプ量が一定以上に達していればDismissする。そうでなければキャンセルされ、もとに戻る。
- UITableViewのスクロールでも閉じることができる
- スワイプ量に応じて背景の透過度もアニメーションする
以上の動作を実現するにはUIKitの以下機能を使うことで実現できます。
- UIPresentationController
- UIViewControllerAnimatedTransitioning
- UIPercentDrivenInteractiveTransition
関連するクラスが多く、はじめは実装するのが大変ですが、一度理解してしまえば様々な遷移処理に応用できるようになると思います。
##ファイル構成
- ViewController
- SemiModalViewController
- ModalPresentationController
- DismissAnimator
- OverCurrentTransitioningInteractor
- OverCurrentTransitionable
##modalPresentationStyleをカスタムする
通常のモーダルビューの遷移については、modalPresentationStyle
を指定することで遷移時の挙動について制御することができます。
私がよく使っているのは .currentOverContent
で、遷移元の表示を背景に残したまま、上にモーダルビューを重ねることができます。
今回セミモーダルへの遷移時、背景の透過率をアニメーションして遷移させています。
この処理をUIPresentationController
により実現します。
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
の実装が必要になります。
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
を引っ張り出し、遷移中の表示を行う特別なcontainerView
へaddSubview
することです。
遷移元の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を適宜設定することで閉じるスピードをコントロールすることができます。
【追記ここまで】
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
を加えると、より自然な動きになります。
【追記ここまで】
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
}
}
}
##セミモーダルビューを実装する
ここまで実装したクラスを使い、遷移後のセミモーダルビューを実装します。
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
するだけで遷移できます。
import UIKit
class ViewController: UIViewController {
@IBAction func buttonDidTap(_ sender: Any) {
let vc = SemiModalViewController.make()
present(vc, animated: true, completion: nil)
}
}
##補足
DismissAnimator
の実装で、遷移先のUIViewController.view
をcontainerView
へaddSubview
すると、遷移完了後に遷移後の画面が消えてしまいました。
おそらく、Dismiss完了後、遷移先モーダルビューのインスタンスが破棄されたのと合わせて一緒に破棄されてしまっているようです。
DismissAnimatorの実態を遷移元で保持していれば問題ないかもしれません(検証していません)
なお、今回はセミモーダルを閉じる処理を実装する上で、遷移元fromVC.view
をアニメーションすることが目的であるため、遷移先UIViewController
は参照していません。