Posted at

引っ張って閉じることができるモーダルを実装する (UINavigationControllerの場合)

More than 1 year has passed since last update.

UIKitでは、ViewController→ViewControllerの遷移をカスタムできます。

上の画像のようにボタンを押すと下から出てきて 引っ張ると閉じることができるモーダルを実装してみます。

https://www.thorntech.com/2016/02/ios-tutorial-close-modal-dragging/

当初こちらのチュートリアルを参考に進めていたのですが、ある問題が発生しました。

それはUINavigationControllerにはPan Gesture Recognizerをアタッチすることができないことです。

そこで、UINavigationControllerの子のViewControllerでPan Gestureをハンドリングし、UINavigationControllerに伝播する方法で目的を達成できましたのでコード片を残しておきたいと思います。


ファイル構成


  • ViewController

  • ModalNavigationController

  • ModalViewController

  • DismissAnimator

  • Interactor


コード


ViewController.swift

import UIKit

class ViewController: UIViewController, UIViewControllerTransitioningDelegate {
let interactor = Interactor()

@IBAction func handleButton(_ sender: UIButton) {
let sb = UIStoryboard(name: "ModalViewController", bundle: nil)
let nc = sb.instantiateInitialViewController() as! ModalNavigationController
nc.interactor = interactor
nc.transitioningDelegate = self
present(nc, animated: true, completion: nil)
}

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

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



ModalNavigationController.swift

import UIKit

class ModalNavigationController: UINavigationController {
var interactor: Interactor!

func handleGesture(_ sender: UIPanGestureRecognizer) {
let percentThreshold: CGFloat = 0.3

let translation = sender.translation(in: view)
let verticalMovement = translation.y / view.bounds.height
let downwardMovement = fmaxf(Float(verticalMovement), 0.0)
let downwardMovementPercent = fminf(downwardMovement, 1.0)
let progress = CGFloat(downwardMovementPercent)

switch sender.state {
case .began:
interactor.hasStarted = true
dismiss(animated: true, completion: nil)
case .changed:
interactor.shouldFinish = progress > percentThreshold
interactor.update(progress)
case .cancelled:
interactor.hasStarted = false
interactor.cancel()
case .ended:
interactor.hasStarted = false
interactor.shouldFinish
? interactor.finish()
: interactor.cancel()
default:
break
}
}
}



ModalViewController.swift

import UIKit

class ModalViewController: UIViewController {
@IBAction func handleDismissButton(_ sender: UIBarButtonItem) {
dismiss(animated: true, completion: nil)
}

@IBAction func handleGesture(_ sender: UIPanGestureRecognizer) {
weak var nc = navigationController as? ModalNavigationController
nc?.handleGesture(sender)
}
}



DismissAnimator.swift

import UIKit

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

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

let containerView = transitionContext.containerView

containerView.insertSubview(toVC.view, belowSubview: 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)
}
)
}
}



Interactor.swift

import UIKit

class Interactor: UIPercentDrivenInteractiveTransition {
var hasStarted = false
var shouldFinish = false
}



GitHub

https://github.com/keisei1092/ModalNavigationControllerPractice