A common way to dismiss a modal is to swipe down - How do we allows the user to drag the modal down, if it's far enough, the modal's dismissed, otherwise it animates back to the original position?
For example, we can find this used on the Twitter app's photo views, or Snapchat's "discover" mode.
Similar threads point out that we can use a UISwipeGestureRecognizer and [self dismissViewControllerAnimated...] to dismiss a modal VC when a user swipes down. But this only handles a single swipe, not letting the user drag the modal around.
I just created a tutorial for interactively dragging down a modal to dismiss it.
http://www.thorntech.com/2016/02/ios-tutorial-close-modal-dragging/
I found this topic to be confusing at first, so the tutorial builds this out step-by-step.
If you just want to run the code yourself, this is the repo:
https://github.com/ThornTechPublic/InteractiveModal
This is the approach I used:
View Controller
You override the dismiss animation with a custom one. If the user is dragging the modal, the interactor
kicks in.
import UIKit class ViewController: UIViewController { let interactor = Interactor() override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { if let destinationViewController = segue.destinationViewController as? ModalViewController { destinationViewController.transitioningDelegate = self destinationViewController.interactor = interactor } } } extension ViewController: UIViewControllerTransitioningDelegate { func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { return DismissAnimator() } func interactionControllerForDismissal(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return interactor.hasStarted ? interactor : nil } }
Dismiss Animator
You create a custom animator. This is a custom animation that you package inside a UIViewControllerAnimatedTransitioning
protocol.
import UIKit class DismissAnimator : NSObject { } extension DismissAnimator : UIViewControllerAnimatedTransitioning { func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval { return 0.6 } func animateTransition(transitionContext: UIViewControllerContextTransitioning) { guard let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey), let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey), let containerView = transitionContext.containerView() else { return } containerView.insertSubview(toVC.view, belowSubview: fromVC.view) let screenBounds = UIScreen.mainScreen().bounds let bottomLeftCorner = CGPoint(x: 0, y: screenBounds.height) let finalFrame = CGRect(origin: bottomLeftCorner, size: screenBounds.size) UIView.animateWithDuration( transitionDuration(transitionContext), animations: { fromVC.view.frame = finalFrame }, completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled()) } ) } }
Interactor
You subclass UIPercentDrivenInteractiveTransition
so that it can act as your state machine. Since the interactor object is accessed by both VCs, use it to keep track of the panning progress.
import UIKit class Interactor: UIPercentDrivenInteractiveTransition { var hasStarted = false var shouldFinish = false }
Modal View Controller
This maps the pan gesture state to interactor method calls. The translationInView()
y
value determines whether the user crossed a threshold. When the pan gesture is .Ended
, the interactor either finishes or cancels.
import UIKit class ModalViewController: UIViewController { var interactor:Interactor? = nil @IBAction func close(sender: UIButton) { dismissViewControllerAnimated(true, completion: nil) } @IBAction func handleGesture(sender: UIPanGestureRecognizer) { let percentThreshold:CGFloat = 0.3 // convert y-position to downward pull progress (percentage) let translation = sender.translationInView(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) guard let interactor = interactor else { return } switch sender.state { case .Began: interactor.hasStarted = true dismissViewControllerAnimated(true, completion: nil) case .Changed: interactor.shouldFinish = progress > percentThreshold interactor.updateInteractiveTransition(progress) case .Cancelled: interactor.hasStarted = false interactor.cancelInteractiveTransition() case .Ended: interactor.hasStarted = false interactor.shouldFinish ? interactor.finishInteractiveTransition() : interactor.cancelInteractiveTransition() default: break } } }
I'll share how I did it in Swift 3 :
Result
Implementation
class MainViewController: UIViewController { @IBAction func click() { performSegue(withIdentifier: "showModalOne", sender: nil) } }
class ModalOneViewController: ViewControllerPannable { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .yellow } @IBAction func click() { performSegue(withIdentifier: "showModalTwo", sender: nil) } }
class ModalTwoViewController: ViewControllerPannable { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .green } }
Where the Modals View Controllers inherit from a class
that I've built (ViewControllerPannable
) to make them draggable and dismissible when reach certain velocity.
ViewControllerPannable class
class ViewControllerPannable: UIViewController { var panGestureRecognizer: UIPanGestureRecognizer? var originalPosition: CGPoint? var currentPositionTouched: CGPoint? override func viewDidLoad() { super.viewDidLoad() panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureAction(_:))) view.addGestureRecognizer(panGestureRecognizer!) } func panGestureAction(_ panGesture: UIPanGestureRecognizer) { let translation = panGesture.translation(in: view) if panGesture.state == .began { originalPosition = view.center currentPositionTouched = panGesture.location(in: view) } else if panGesture.state == .changed { view.frame.origin = CGPoint( x: translation.x, y: translation.y ) } else if panGesture.state == .ended { let velocity = panGesture.velocity(in: view) if velocity.y >= 1500 { UIView.animate(withDuration: 0.2 , animations: { self.view.frame.origin = CGPoint( x: self.view.frame.origin.x, y: self.view.frame.size.height ) }, completion: { (isCompleted) in if isCompleted { self.dismiss(animated: false, completion: nil) } }) } else { UIView.animate(withDuration: 0.2, animations: { self.view.center = self.originalPosition! }) } } } }
created a demo for interactively dragging down to dismiss view controller like snapchat's discover mode. Check this github for sample project.
Swift 4.x, Using Pangesture
Simple way
Vertical
class ViewConrtoller: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(onDrage(_:)))) } @objc func onDrage(_ sender:UIPanGestureRecognizer) { let percentThreshold:CGFloat = 0.3 let translation = sender.translation(in: view) let newX = ensureRange(value: view.frame.minX + translation.x, minimum: 0, maximum: view.frame.maxX) let progress = progressAlongAxis(newX, view.bounds.width) view.frame.origin.x = newX //Move view to new position if sender.state == .ended { let velocity = sender.velocity(in: view) if velocity.x >= 300 || progress > percentThreshold { self.dismiss(animated: true) //Perform dismiss } else { UIView.animate(withDuration: 0.2, animations: { self.view.frame.origin.x = 0 // Revert animation }) } } sender.setTranslation(.zero, in: view) } }
Helper function
func progressAlongAxis(_ pointOnAxis: CGFloat, _ axisLength: CGFloat) -> CGFloat { let movementOnAxis = pointOnAxis / axisLength let positiveMovementOnAxis = fmaxf(Float(movementOnAxis), 0.0) let positiveMovementOnAxisPercent = fminf(positiveMovementOnAxis, 1.0) return CGFloat(positiveMovementOnAxisPercent) } func ensureRange<T>(value: T, minimum: T, maximum: T) -> T where T : Comparable { return min(max(value, minimum), maximum) }
Hard way
Refer this -> https://github.com/satishVekariya/DraggableViewController
Here is a one-file solution based on @wilson's answer (thanks 👍 ) with the following improvements:
List of Improvements from previous solution
- Limit panning so that the view only goes down:
- Avoid horizontal translation by only updating the
y
coordinate ofview.frame.origin
- Avoid panning out of the screen when swiping up with
let y = max(0, translation.y)
- Avoid horizontal translation by only updating the
- Also dismiss the view controller based on where the finger is released (defaults to the bottom half of the screen) and not just based on the velocity of the swipe
- Show view controller as modal to ensure the previous viewcontroller appears behind and avoid a black background (should answer your question @nguyễn-anh-việt)
- Remove unneeded
currentPositionTouched
andoriginalPosition
- Expose the following parameters:
minimumVelocityToHide
: what speed is enough to hide (defaults to 1500)minimumScreenRatioToHide
: how low is enough to hide (defaults to 0.5)animationDuration
: how fast do we hide/show (defaults to 0.2s)
Solution
Swift 3 & Swift 4 :
// // PannableViewController.swift // import UIKit class PannableViewController: UIViewController { public var minimumVelocityToHide: CGFloat = 1500 public var minimumScreenRatioToHide: CGFloat = 0.5 public var animationDuration: TimeInterval = 0.2 override func viewDidLoad() { super.viewDidLoad() // Listen for pan gesture let panGesture = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))) self.view.addGestureRecognizer(panGesture) } @objc func onPan(_ panGesture: UIPanGestureRecognizer) { func slideViewVerticallyTo(_ y: CGFloat) { self.view.frame.origin = CGPoint(x: 0, y: y) } switch panGesture.state { case .began, .changed: // If pan started or is ongoing then // slide the view to follow the finger let translation = panGesture.translation(in: view) let y = max(0, translation.y) self.slideViewVerticallyTo(y) case .ended: // If pan ended, decide it we should close or reset the view // based on the final position and the speed of the gesture let translation = panGesture.translation(in: view) let velocity = panGesture.velocity(in: view) let closing = (translation.y > self.view.frame.size.height * minimumScreenRatioToHide) || (velocity.y > minimumVelocityToHide) if closing { UIView.animate(withDuration: animationDuration, animations: { // If closing, animate to the bottom of the view self.slideViewVerticallyTo(self.view.frame.size.height) }, completion: { (isCompleted) in if isCompleted { // Dismiss the view when it dissapeared self.dismiss(animated: false, completion: nil) } }) } else { // If not closing, reset the view to the top UIView.animate(withDuration: animationDuration, animations: { self.slideViewVerticallyTo(0) }) } default: // If gesture state is undefined, reset the view to the top UIView.animate(withDuration: animationDuration, animations: { self.slideViewVerticallyTo(0) }) } } override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nil, bundle: nil) self.modalPresentationStyle = .overFullScreen; self.modalTransitionStyle = .coverVertical; } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) self.modalPresentationStyle = .overFullScreen; self.modalTransitionStyle = .coverVertical; } }
Massively updates the repo for Swift 4.
For Swift 3, I have created the following to present a UIViewController
from right to left and dismiss it by pan gesture. I have uploaded this as a GitHub repository.
DismissOnPanGesture.swift
file:
// Created by David Seek on 11/21/16. // Copyright © 2016 David Seek. All rights reserved. import UIKit class DismissAnimator : NSObject { } extension DismissAnimator : UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.6 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { let screenBounds = UIScreen.main.bounds let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) var x:CGFloat = toVC!.view.bounds.origin.x - screenBounds.width let y:CGFloat = toVC!.view.bounds.origin.y let width:CGFloat = toVC!.view.bounds.width let height:CGFloat = toVC!.view.bounds.height var frame:CGRect = CGRect(x: x, y: y, width: width, height: height) toVC?.view.alpha = 0.2 toVC?.view.frame = frame let containerView = transitionContext.containerView containerView.insertSubview(toVC!.view, belowSubview: fromVC!.view) let bottomLeftCorner = CGPoint(x: screenBounds.width, y: 0) let finalFrame = CGRect(origin: bottomLeftCorner, size: screenBounds.size) UIView.animate( withDuration: transitionDuration(using: transitionContext), animations: { fromVC!.view.frame = finalFrame toVC?.view.alpha = 1 x = toVC!.view.bounds.origin.x frame = CGRect(x: x, y: y, width: width, height: height) toVC?.view.frame = frame }, completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } ) } } class Interactor: UIPercentDrivenInteractiveTransition { var hasStarted = false var shouldFinish = false } let transition: CATransition = CATransition() func presentVCRightToLeft(_ fromVC: UIViewController, _ toVC: UIViewController) { transition.duration = 0.5 transition.type = kCATransitionPush transition.subtype = kCATransitionFromRight fromVC.view.window!.layer.add(transition, forKey: kCATransition) fromVC.present(toVC, animated: false, completion: nil) } func dismissVCLeftToRight(_ vc: UIViewController) { transition.duration = 0.5 transition.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) transition.type = kCATransitionPush transition.subtype = kCATransitionFromLeft vc.view.window!.layer.add(transition, forKey: nil) vc.dismiss(animated: false, completion: nil) } func instantiatePanGestureRecognizer(_ vc: UIViewController, _ selector: Selector) { var edgeRecognizer: UIScreenEdgePanGestureRecognizer! edgeRecognizer = UIScreenEdgePanGestureRecognizer(target: vc, action: selector) edgeRecognizer.edges = .left vc.view.addGestureRecognizer(edgeRecognizer) } func dismissVCOnPanGesture(_ vc: UIViewController, _ sender: UIScreenEdgePanGestureRecognizer, _ interactor: Interactor) { let percentThreshold:CGFloat = 0.3 let translation = sender.translation(in: vc.view) let fingerMovement = translation.x / vc.view.bounds.width let rightMovement = fmaxf(Float(fingerMovement), 0.0) let rightMovementPercent = fminf(rightMovement, 1.0) let progress = CGFloat(rightMovementPercent) switch sender.state { case .began: interactor.hasStarted = true vc.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 } }
Easy usage:
import UIKit class VC1: UIViewController, UIViewControllerTransitioningDelegate { let interactor = Interactor() @IBAction func present(_ sender: Any) { let vc = self.storyboard?.instantiateViewController(withIdentifier: "VC2") as! VC2 vc.transitioningDelegate = self vc.interactor = interactor presentVCRightToLeft(self, vc) } func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { return DismissAnimator() } func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return interactor.hasStarted ? interactor : nil } } class VC2: UIViewController { var interactor:Interactor? = nil override func viewDidLoad() { super.viewDidLoad() instantiatePanGestureRecognizer(self, #selector(gesture)) } @IBAction func dismiss(_ sender: Any) { dismissVCLeftToRight(self) } func gesture(_ sender: UIScreenEdgePanGestureRecognizer) { dismissVCOnPanGesture(self, sender, interactor!) } }
What you're describing is an interactive custom transition animation. You are customizing both the animation and the driving gesture of a transition, i.e. the dismissal (or not) of a presented view controller. The easiest way to implement it is by combining a UIPanGestureRecognizer with a UIPercentDrivenInteractiveTransition.
My book explains how to do this, and I have posted examples (from the book). This particular example is a different situation - the transition is sideways, not down, and it is for a tab bar controller, not a presented controller - but the basic idea is exactly the same:
If you download that project and run it, you will see that what is happening is exactly what you are describing, except that it is sideways: if the drag is more than half, we transition, but if not, we cancel and snap back into place.
I've created an easy to use extension.
Just inherent Your UIViewController with InteractiveViewController and you are done InteractiveViewController
call method showInteractive() from your controller to show as Interactive.
Only vertical dismiss
func panGestureAction(_ panGesture: UIPanGestureRecognizer) { let translation = panGesture.translation(in: view) if panGesture.state == .began { originalPosition = view.center currentPositionTouched = panGesture.location(in: view) } else if panGesture.state == .changed { view.frame.origin = CGPoint( x: view.frame.origin.x, y: view.frame.origin.y + translation.y ) panGesture.setTranslation(CGPoint.zero, in: self.view) } else if panGesture.state == .ended { let velocity = panGesture.velocity(in: view) if velocity.y >= 150 { UIView.animate(withDuration: 0.2 , animations: { self.view.frame.origin = CGPoint( x: self.view.frame.origin.x, y: self.view.frame.size.height ) }, completion: { (isCompleted) in if isCompleted { self.dismiss(animated: false, completion: nil) } }) } else { UIView.animate(withDuration: 0.2, animations: { self.view.center = self.originalPosition! }) } }
I figured out super simple way to do this. Just put the following code into your view controller:
Swift 4
override func viewDidLoad() { super.viewDidLoad() let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognizerHandler(_:))) view.addGestureRecognizer(gestureRecognizer) } @IBAction func panGestureRecognizerHandler(_ sender: UIPanGestureRecognizer) { let touchPoint = sender.location(in: view?.window) var initialTouchPoint = CGPoint.zero switch sender.state { case .began: initialTouchPoint = touchPoint case .changed: if touchPoint.y > initialTouchPoint.y { view.frame.origin.y = touchPoint.y - initialTouchPoint.y } case .ended, .cancelled: if touchPoint.y - initialTouchPoint.y > 200 { dismiss(animated: true, completion: nil) } else { UIView.animate(withDuration: 0.2, animations: { self.view.frame = CGRect(x: 0, y: 0, width: self.view.frame.size.width, height: self.view.frame.size.height) }) } case .failed, .possible: break } }
In Objective C : Here's the code
inviewDidLoad
UISwipeGestureRecognizer *swipeRecognizer = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(swipeDown:)]; swipeRecognizer.direction = UISwipeGestureRecognizerDirectionDown; [self.view addGestureRecognizer:swipeRecognizer]; //Swipe Down Method - (void)swipeDown:(UIGestureRecognizer *)sender{ [self dismissViewControllerAnimated:YES completion:nil]; }
Here is an extension I made based on @Wilson answer :
// MARK: IMPORT STATEMENTS import UIKit // MARK: EXTENSION extension UIViewController { // MARK: IS SWIPABLE - FUNCTION func isSwipable() { let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) self.view.addGestureRecognizer(panGestureRecognizer) } // MARK: HANDLE PAN GESTURE - FUNCTION @objc func handlePanGesture(_ panGesture: UIPanGestureRecognizer) { let translation = panGesture.translation(in: view) let minX = view.frame.width * 0.135 var originalPosition = CGPoint.zero if panGesture.state == .began { originalPosition = view.center } else if panGesture.state == .changed { view.frame.origin = CGPoint(x: translation.x, y: 0.0) if panGesture.location(in: view).x > minX { view.frame.origin = originalPosition } if view.frame.origin.x <= 0.0 { view.frame.origin.x = 0.0 } } else if panGesture.state == .ended { if view.frame.origin.x >= view.frame.width * 0.5 { UIView.animate(withDuration: 0.2 , animations: { self.view.frame.origin = CGPoint( x: self.view.frame.size.width, y: self.view.frame.origin.y ) }, completion: { (isCompleted) in if isCompleted { self.dismiss(animated: false, completion: nil) } }) } else { UIView.animate(withDuration: 0.2, animations: { self.view.frame.origin = originalPosition }) } } } }
USAGE
Inside your view controller you want to be swipable :
override func viewDidLoad() { super.viewDidLoad() self.isSwipable() }
and it will be dismissible by swiping from the extreme left side of the view controller, as a navigation controller.
This my simple class for Drag ViewController from axis. Just herited your class from DraggableViewController.
MyCustomClass: DraggableViewController
Work only for presented ViewController.
// MARK: - DraggableViewController public class DraggableViewController: UIViewController { public let percentThresholdDismiss: CGFloat = 0.3 public var velocityDismiss: CGFloat = 300 public var axis: NSLayoutConstraint.Axis = .horizontal public var backgroundDismissColor: UIColor = .black { didSet { navigationController?.view.backgroundColor = backgroundDismissColor } } // MARK: LifeCycle override func viewDidLoad() { super.viewDidLoad() view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(onDrag(_:)))) } // MARK: Private methods @objc fileprivate func onDrag(_ sender: UIPanGestureRecognizer) { let translation = sender.translation(in: view) // Movement indication index let movementOnAxis: CGFloat // Move view to new position switch axis { case .vertical: let newY = min(max(view.frame.minY + translation.y, 0), view.frame.maxY) movementOnAxis = newY / view.bounds.height view.frame.origin.y = newY case .horizontal: let newX = min(max(view.frame.minX + translation.x, 0), view.frame.maxX) movementOnAxis = newX / view.bounds.width view.frame.origin.x = newX } let positiveMovementOnAxis = fmaxf(Float(movementOnAxis), 0.0) let positiveMovementOnAxisPercent = fminf(positiveMovementOnAxis, 1.0) let progress = CGFloat(positiveMovementOnAxisPercent) navigationController?.view.backgroundColor = UIColor.black.withAlphaComponent(1 - progress) switch sender.state { case .ended where sender.velocity(in: view).y >= velocityDismiss || progress > percentThresholdDismiss: // After animate, user made the conditions to leave UIView.animate(withDuration: 0.2, animations: { switch self.axis { case .vertical: self.view.frame.origin.y = self.view.bounds.height case .horizontal: self.view.frame.origin.x = self.view.bounds.width } self.navigationController?.view.backgroundColor = UIColor.black.withAlphaComponent(0) }, completion: { finish in self.dismiss(animated: true) //Perform dismiss }) case .ended: // Revert animation UIView.animate(withDuration: 0.2, animations: { switch self.axis { case .vertical: self.view.frame.origin.y = 0 case .horizontal: self.view.frame.origin.x = 0 } }) default: break } sender.setTranslation(.zero, in: view) } }
You can use a UIPanGestureRecognizer to detect the user's drag and move the modal view with it. If the ending position is far enough down, the view can be dismissed, or otherwise animated back to its original position.
Check out this answer for more information on how to implement something like this.
来源:https://stackoverflow.com/questions/29290313/in-ios-how-to-drag-down-to-dismiss-a-modal