In iOS, how to drag down to dismiss a modal? - ios

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? {
DismissAnimator()
}
func interactionControllerForDismissal(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
interactor.hasStarted ? interactor : .none
}
}
Dismiss Animator
You create a custom animator. This is a custom animation that you package inside a UIViewControllerAnimatedTransitioning protocol.
import UIKit
class DismissAnimator : NSObject {
let transitionDuration = 0.6
}
extension DismissAnimator : UIViewControllerAnimatedTransitioning {
func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
transitionDuration
}
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
guard
let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey),
let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey),
let containerView = transitionContext.containerView()
else {
return
}
if transitionContext.transitionWasCancelled {
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) {
dismiss(animated: true)
}
#IBAction 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)
guard interactor = interactor else { return }
switch sender.state {
case .began:
interactor.hasStarted = true
dismiss(animated: true)
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
}
}
}

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!)
}
#objc 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!
})
}
}
}
}

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 of view.frame.origin
Avoid panning out of the screen when swiping up with let y = max(0, translation.y)
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 and originalPosition
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(_:)))
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)
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
dismiss(animated: false, completion: nil)
}
})
} else {
// If not closing, reset the view to the top
UIView.animate(withDuration: animationDuration, animations: {
slideViewVerticallyTo(0)
})
}
default:
// If gesture state is undefined, reset the view to the top
UIView.animate(withDuration: animationDuration, animations: {
slideViewVerticallyTo(0)
})
}
}
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen;
modalTransitionStyle = .coverVertical;
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
modalPresentationStyle = .overFullScreen;
modalTransitionStyle = .coverVertical;
}
}

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
}
}

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
Horizontal
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

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!)
}
}

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'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.

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:
https://github.com/mattneub/Programming-iOS-Book-Examples/blob/master/bk2ch06p300customAnimation3/ch19p620customAnimation1/Animator.swift
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.

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];
}

For those who really wanna dive a little deeper into Custom UIViewController Transition, I recommend this great tutorial from raywenderlich.com.
The original final sample project contains bug. So I fixed it and upload it to Github repo. The proj is in Swift 5, so you can easily run and play it.
Here is a preview:
And it's interactive too!
Happy hacking!

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

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.

For Swift 4 + Swift 5, using UIPanGestureRecognizer. Based on #SPatel 's answer above.
Add these two helper functions:
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)
}
To dismiss by dragging down:
class SwipeDownViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// dismiss dragging vertically:
view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(onDragY(_:))))
}
#objc func onDragY(_ sender: UIPanGestureRecognizer) {
let percentThreshold: CGFloat = 0.3
let translation = sender.translation(in: view)
let newY = ensureRange(value: view.frame.minY + translation.y, minimum: 0, maximum: view.frame.maxY)
let progress = progressAlongAxis(newY, view.bounds.height)
view.frame.origin.y = newY // Move view to new position
if sender.state == .ended {
let velocity = sender.velocity(in: view)
if velocity.y >= 300 || progress > percentThreshold {
dismiss(animated: true) // Perform dismiss
} else {
UIView.animate(withDuration: 0.2, animations: {
self.view.frame.origin.y = 0 // Revert animation
})
}
}
sender.setTranslation(.zero, in: view)
}
}
To dismiss by dragging right:
class SwipeRightViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// dismiss dragging horizontally:
view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(onDragX(_:))))
}
#objc func onDragX(_ 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 {
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)
}
}

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.

Related

Drag to dismiss a UIPresentationController

I have made a UIPresentationController that fits any view controller and shows up on half of the screen using this tutorial. Now I would love to add drag to dismiss to this. I'm trying to have the drag feel natural and responsive like the drag experience for "Top Stories" on the Apple iOS 13 stocks app. I thought the iOS 13 modal drag to dismiss would get carried over but it doesn't to this controller but it doesn't.
Every bit of code and tutorial I found had a bad dragging experience. Does anyone know how to do this? I've been trying / searching for the past week. Thank you in advance
Here's my code for the presentation controller
class SlideUpPresentationController: UIPresentationController {
// MARK: - Variables
private var dimmingView: UIView!
//MARK: - View functions
override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
setupDimmingView()
}
override func containerViewWillLayoutSubviews() {
presentedView?.frame = frameOfPresentedViewInContainerView
}
override var frameOfPresentedViewInContainerView: CGRect {
guard let container = containerView else { return super.frameOfPresentedViewInContainerView }
let width = container.bounds.size.width
let height : CGFloat = 300.0
return CGRect(x: 0, y: container.bounds.size.height - height, width: width, height: height)
}
override func presentationTransitionWillBegin() {
guard let dimmingView = dimmingView else { return }
containerView?.insertSubview(dimmingView, at: 0)
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "V:|[dimmingView]|",
options: [],
metrics: nil,
views: ["dimmingView": dimmingView]))
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "H:|[dimmingView]|",
options: [],
metrics: nil,
views: ["dimmingView": dimmingView]))
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 1.0
return
}
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 1.0
})
}
override func dismissalTransitionWillBegin() {
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 0.0
return
}
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 0.0
})
}
func setupDimmingView() {
dimmingView = UIView()
dimmingView.translatesAutoresizingMaskIntoConstraints = false
dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
dimmingView.alpha = 0.0
let recognizer = UITapGestureRecognizer(target: self,
action: #selector(handleTap(recognizer:)))
dimmingView.addGestureRecognizer(recognizer)
}
#objc func handleTap(recognizer: UITapGestureRecognizer) {
presentingViewController.dismiss(animated: true)
}
}
As your description about the dragging experience you wanted is not that clear, hope I didn't get you wrong.
I'm trying to have the drag feel natural and responsive like the drag experience for "Top Stories" on the Apple iOS 13 stocks app.
What I get is, you want to be able to drag the presented view, dismiss it if it reach certain point, else go back to its original position (and of coz you can bring the view to any position you wanted).
To achieve this, we can add a UIPanGesture to the presentedViewController, then
move the presentedView according to the gesture
dismiss / move back the presentedView
class SlideUpPresentationController: UIPresentationController {
// MARK: - Variables
private var dimmingView: UIView!
private var originalX: CGFloat = 0
//MARK: - View functions
override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
setupDimmingView()
}
override func containerViewWillLayoutSubviews() {
presentedView?.frame = frameOfPresentedViewInContainerView
}
override var frameOfPresentedViewInContainerView: CGRect {
guard let container = containerView else { return super.frameOfPresentedViewInContainerView }
let width = container.bounds.size.width
let height : CGFloat = 300.0
return CGRect(x: 0, y: container.bounds.size.height - height, width: width, height: height)
}
override func presentationTransitionWillBegin() {
guard let dimmingView = dimmingView else { return }
containerView?.insertSubview(dimmingView, at: 0)
// add PanGestureRecognizer for dragging the presented view controller
let viewPan = UIPanGestureRecognizer(target: self, action: #selector(viewPanned(_:)))
containerView?.addGestureRecognizer(viewPan)
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "V:|[dimmingView]|", options: [], metrics: nil, views: ["dimmingView": dimmingView]))
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "H:|[dimmingView]|", options: [], metrics: nil, views: ["dimmingView": dimmingView]))
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 1.0
return
}
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 1.0
})
}
#objc private func viewPanned(_ sender: UIPanGestureRecognizer) {
// how far the pan gesture translated
let translate = sender.translation(in: self.presentedView)
switch sender.state {
case .began:
originalX = presentedViewController.view.frame.origin.x
case .changed:
// move the presentedView according to pan gesture
// prevent it from moving too far to the right
if originalX + translate.x < 0 {
presentedViewController.view.frame.origin.x = originalX + translate.x
}
case .ended:
let presentedViewWidth = presentedViewController.view.frame.width
let newX = presentedViewController.view.frame.origin.x
// if the presentedView move more than 0.75 of the presentedView's width, dimiss it, else bring it back to original position
if presentedViewWidth * 0.75 + newX > 0 {
setBackToOriginalPosition()
} else {
moveAndDismissPresentedView()
}
default:
break
}
}
private func setBackToOriginalPosition() {
// ensure no pending layout change in presentedView
presentedViewController.view.layoutIfNeeded()
UIView.animate(withDuration: 0.25, delay: 0.0, options: .curveEaseIn, animations: {
self.presentedViewController.view.frame.origin.x = self.originalX
self.presentedViewController.view.layoutIfNeeded()
}, completion: nil)
}
private func moveAndDismissPresentedView() {
// ensure no pending layout change in presentedView
presentedViewController.view.layoutIfNeeded()
UIView.animate(withDuration: 0.25, delay: 0.0, options: .curveEaseIn, animations: {
self.presentedViewController.view.frame.origin.x = -self.presentedViewController.view.frame.width
self.presentedViewController.view.layoutIfNeeded()
}, completion: { _ in
// dimiss when the view is completely move outside the screen
self.presentingViewController.dismiss(animated: true, completion: nil)
})
}
override func dismissalTransitionWillBegin() {
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 0.0
return
}
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 0.0
})
}
func setupDimmingView() {
dimmingView = UIView()
dimmingView.translatesAutoresizingMaskIntoConstraints = false
dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
dimmingView.alpha = 0.0
let recognizer = UITapGestureRecognizer(target: self,
action: #selector(handleTap(recognizer:)))
dimmingView.addGestureRecognizer(recognizer)
}
#objc func handleTap(recognizer: UITapGestureRecognizer) {
presentingViewController.dismiss(animated: true)
}
}
The above code is just an example based on the code you provide, but I hope that explain what's happening under the hood of what you called a drag experience. Hope this helps ;)
Here is the example result:
via GIPHY

Drag-to-dismiss interactive transition out of sync with pan gesture

I am trying to implement a custom drag-to-dismiss interactive transition, using UIViewControllerAnimatedTransitioning and UIPercentDrivenInteractiveTransition.
Here is the animator code for dismissing, it just moves the view down completely out from the frame:
final class SheetDismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
0.5
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from) else { return }
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
fromVC.view.frame.origin.y = transitionContext.containerView.frame.height
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
And here is the code for the modal view controller I am trying to dismiss, which has the pan gesture recognizer attached to it:
final class ModalViewController: UIViewController {
private let customTransitionDelegate = SheetTransitioningDelegate()
private var interactionController: UIPercentDrivenInteractiveTransition?
init() {
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .custom
transitioningDelegate = customTransitionDelegate
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .red
view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:))))
}
#objc func handleGesture(_ gesture: UIPanGestureRecognizer) {
let translate = gesture.translation(in: view)
let percent = translate.y / view.bounds.height
switch gesture.state {
case .began:
interactionController = UIPercentDrivenInteractiveTransition()
customTransitionDelegate.interactionController = interactionController
dismiss(animated: true)
case .changed:
interactionController?.update(percent)
case .cancelled:
interactionController?.cancel()
case .ended:
let velocity = gesture.velocity(in: gesture.view)
if percent > 0.5 || velocity.y > 700 {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
default:
break
}
}
}
The relevant part I believe is the handleGesture(_ gesture: UIPanGestureRecognizer) function, which calculates the percentage for advancing the animation. Pasted the whole code for clarity.
I would expect the animation to follow the pan gesture linearly, but this is what happens instead:
I solved the issue by switching to UIViewPropertyAnimator:
final class SheetDismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
0.5
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let propertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext),
timingParameters: UISpringTimingParameters(dampingRatio: 1))
propertyAnimator.addAnimations {
switch self.type {
case .presented:
guard let toVC = transitionContext.viewController(forKey: .to) else { break }
toVC.view.frame = transitionContext.finalFrame(for: toVC)
case .dismissed:
transitionContext.view(forKey: .from)?.frame.origin.y = transitionContext.containerView.frame.maxY
}
}
propertyAnimator.addCompletion { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
propertyAnimator.startAnimation()
}
}
I also added the following into my gesture handling code, which is a basic attempt at transfering the gesture's velocity to the animation speed.
case .ended:
if percent > 0.45 || velocity.y > 700 {
interactor.timingCurve = UICubicTimingParameters(animationCurve: .linear)
interactor.completionSpeed = max(1, (view.frame.height * (1 / interactor.duration) / velocity.y))
interactor.finish()
} else {
interactor.cancel()
}
isInteractive = false

Swift: Instantiate a view controller for custom transition in the current navigation stack

Introduction
I'm creating an app that has, in its rootViewController, a UITableView and a UIPanGestureRecognizer attached to a small UIView acting as a "handle" which enables a custom view controller transition for a UIViewController called "SlideOutViewController" to be panned from the right.
Issue
I have noticed two issues with my approach. But the actual custom transition works as expected.
When the SlideOutViewController is created it is not attached to the navigation stack I believe, therefore it has no associated navigationBar. And if I use the navigationController to push it on the stack, I loose the interactive transition.
Side note: I have not found a way to connect the handle to the SlideOutViewController that is interactively dragged out. So the translation of the handle is not consistent with the SlideOutViewControllers position.
Question
How can I add the SlideOutViewController to the navigation stack? So that the SlideOutViewController transitions with a navigationBar when I trigger the UIPanGestureRecognizer?
My code
In the rootViewController.
class RootViewController: UIViewController {
...
let slideControllerHandle = UIView()
var interactionController : UIPercentDrivenInteractiveTransition?
override func viewDidLoad() {
super.viewDidLoad()
... // Setting up the table view etc...
setupPanGForSlideOutController()
}
private func setupPanGForSlideOutController() {
slideControllerHandle.translatesAutoresizingMaskIntoConstraints = false
slideControllerHandle.layer.borderColor = UIColor.black.cgColor
slideControllerHandle.layer.borderWidth = 1
slideControllerHandle.layer.cornerRadius = 30
view.addSubview(slideControllerHandle)
slideControllerHandle.frame = CGRect(x: view.frame.width - 12.5, y: view.frame.height / 2, width: 25, height: 60)
let panGestureForCalendar = UIPanGestureRecognizer(target: self, action: #selector(handlePanGestureForSlideOutViewController(_:)))
slideControllerHandle.addGestureRecognizer(panGestureForCalendar)
}
#objc private func handlePanGestureForSlideOutViewController(_ gesture: UIPanGestureRecognizer) {
let xPosition = gesture.location(in: view).x
let percent = 1 - (xPosition / view.frame.size.width)
switch gesture.state {
case .began:
guard let slideOutController = storyboard?.instantiateViewController(withIdentifier: "CNSlideOutViewControllerID") as? SlideOutViewController else { fatalError("Sigh...") }
interactionController = UIPercentDrivenInteractiveTransition()
slideOutController.customTransitionDelegate.interactionController = interactionController
self.present(slideOutController, animated: true)
case .changed:
slideControllerHandle.center = CGPoint(x: xPosition, y: slideControllerHandle.center.y)
interactionController?.update(percent)
case .ended, .cancelled:
let velocity = gesture.velocity(in: view)
interactionController?.completionSpeed = 0.999
if percent > 0.5 || velocity.x < 10 {
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, options: .curveEaseInOut, animations: {
self.slideControllerHandle.center = CGPoint(x: self.view.frame.width, y: self.slideControllerHandle.center.y)
})
interactionController?.finish()
} else {
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, options: .curveEaseInOut, animations: {
self.slideControllerHandle.center = CGPoint(x: -25, y: self.slideControllerHandle.center.y)
})
interactionController?.cancel()
}
interactionController = nil
default:
break
}
}
The SlideOutViewController
class SlideOutViewController: UIViewController {
var interactionController : UIPercentDrivenInteractiveTransition?
let customTransitionDelegate = TransitionDelegate()
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
modalPresentationStyle = .custom
transitioningDelegate = customTransitionDelegate
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .red
navigationItem.title = "Slide Controller"
let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addNewData(_:)))
navigationItem.setRightBarButton(addButton, animated: true)
}
}
The custom transition code. Based on Rob's descriptive answer on this SO question
TransitionDelegate
class TransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
weak var interactionController : UIPercentDrivenInteractiveTransition?
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CNRightDragAnimationController(transitionType: .presenting)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CNRightDragAnimationController(transitionType: .dismissing)
}
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return PresentationController(presentedViewController: presented, presenting: presenting)
}
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController
}
}
DragAnimatedTransitioning
class CNRightDragAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
enum TransitionType {
case presenting
case dismissing
}
let transitionType: TransitionType
init(transitionType: TransitionType) {
self.transitionType = transitionType
super.init()
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let inView = transitionContext.containerView
let toView = transitionContext.view(forKey: .to)!
let fromView = transitionContext.view(forKey: .from)!
var frame = inView.bounds
switch transitionType {
case .presenting:
frame.origin.x = frame.size.width
toView.frame = frame
inView.addSubview(toView)
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
toView.frame = inView.bounds
}, completion: { finished in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
case .dismissing:
toView.frame = frame
inView.insertSubview(toView, belowSubview: fromView)
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
frame.origin.x = frame.size.width
fromView.frame = frame
}, completion: { finished in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.5
}
}
PresentationController
class PresentationController: UIPresentationController {
override var shouldRemovePresentersView: Bool { return true }
}
Thanks for reading my question.
The animation code you’ve taken this from is for custom “present” (e.g. modal) transitions. But if you want a custom navigation as you push/pop when using a navigation controller, you specify a delegate for your UINavigationController and then return the appropriate transitioning delegate in navigationController(_:animationControllerFor:from:to:). And also implement navigationController(_:interactionControllerFor:) and return your interaction controller there.
E.g. I'd do something like:
class FirstViewController: UIViewController {
let navigationDelegate = CustomNavigationDelegate()
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.delegate = navigationDelegate
navigationDelegate.addPushInteractionController(to: view)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationDelegate.pushDestination = { [weak self] in
self?.storyboard?.instantiateViewController(withIdentifier: "Second")
}
}
}
Where:
class CustomNavigationDelegate: NSObject, UINavigationControllerDelegate {
var interactionController: UIPercentDrivenInteractiveTransition?
var current: UIViewController?
var pushDestination: (() -> UIViewController?)?
func navigationController(_ navigationController: UINavigationController,
animationControllerFor operation: UINavigationController.Operation,
from fromVC: UIViewController,
to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CustomNavigationAnimator(transitionType: operation)
}
func navigationController(_ navigationController: UINavigationController,
interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController
}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
current = viewController
}
}
// MARK: - Push
extension CustomNavigationDelegate {
func addPushInteractionController(to view: UIView) {
let swipe = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handlePushGesture(_:)))
swipe.edges = [.right]
view.addGestureRecognizer(swipe)
}
#objc private func handlePushGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
guard let pushDestination = pushDestination else { return }
let position = gesture.translation(in: gesture.view)
let percentComplete = min(-position.x / gesture.view!.bounds.width, 1.0)
switch gesture.state {
case .began:
interactionController = UIPercentDrivenInteractiveTransition()
guard let controller = pushDestination() else { fatalError("No push destination") }
current?.navigationController?.pushViewController(controller, animated: true)
case .changed:
interactionController?.update(percentComplete)
case .ended, .cancelled:
let speed = gesture.velocity(in: gesture.view)
if speed.x < 0 || (speed.x == 0 && percentComplete > 0.5) {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
default:
break
}
}
}
// MARK: - Pop
extension CustomNavigationDelegate {
func addPopInteractionController(to view: UIView) {
let swipe = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handlePopGesture(_:)))
swipe.edges = [.left]
view.addGestureRecognizer(swipe)
}
#objc private func handlePopGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
let position = gesture.translation(in: gesture.view)
let percentComplete = min(position.x / gesture.view!.bounds.width, 1)
switch gesture.state {
case .began:
interactionController = UIPercentDrivenInteractiveTransition()
current?.navigationController?.popViewController(animated: true)
case .changed:
interactionController?.update(percentComplete)
case .ended, .cancelled:
let speed = gesture.velocity(in: gesture.view)
if speed.x > 0 || (speed.x == 0 && percentComplete > 0.5) {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
default:
break
}
}
}
And
class CustomNavigationAnimator: NSObject, UIViewControllerAnimatedTransitioning {
let transitionType: UINavigationController.Operation
init(transitionType: UINavigationController.Operation) {
self.transitionType = transitionType
super.init()
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let inView = transitionContext.containerView
let toView = transitionContext.view(forKey: .to)!
let fromView = transitionContext.view(forKey: .from)!
var frame = inView.bounds
switch transitionType {
case .push:
frame.origin.x = frame.size.width
toView.frame = frame
inView.addSubview(toView)
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
toView.frame = inView.bounds
}, completion: { finished in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
case .pop:
toView.frame = frame
inView.insertSubview(toView, belowSubview: fromView)
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
frame.origin.x = frame.size.width
fromView.frame = frame
}, completion: { finished in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
case .none:
break
}
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.5
}
}
Then, if the second view controller wanted to have the custom interactive pop plus the ability to swipe to the third view controller:
class SecondViewController: UIViewController {
var navigationDelegate: CustomNavigationDelegate { return navigationController!.delegate as! CustomNavigationDelegate }
override func viewDidLoad() {
super.viewDidLoad()
navigationDelegate.addPushInteractionController(to: view)
navigationDelegate.addPopInteractionController(to: view)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationDelegate.pushDestination = { [weak self] in
self?.storyboard?.instantiateViewController(withIdentifier: "Third")
}
}
}
But if the last view controller can't push to anything, but only pop:
class ThirdViewController: UIViewController {
var navigationDelegate: CustomNavigationDelegate { return navigationController!.delegate as! CustomNavigationDelegate }
override func viewDidLoad() {
super.viewDidLoad()
navigationDelegate.addPopInteractionController(to: view)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationDelegate.pushDestination = nil
}
}

Broken layout when dismissing UIViewController

We have to implement UIViewController that supports all interface orientations and may be dismissed by swipe-down gesture.
But presenting UIViewController supports only portrait orientation.
extension TransitioningDelegate: UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.3
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let toView = transitionContext.view(forKey: .to), let fromView = transitionContext.view(forKey: .from) else {
return
}
let containerView = transitionContext.containerView
let containerFrame = containerView.frame
let targetPoint = CGPoint(x: containerFrame.minX, y: containerFrame.maxY).applying(fromView.transform)
toView.frame = containerView.bounds
containerView.insertSubview(toView, belowSubview: fromView)
UIView.animate(withDuration: self.transitionDuration(using: transitionContext),
animations: {
fromView.frame.origin = targetPoint
},
completion: { (finished) in
transitionContext.completeTransition(finished && !transitionContext.transitionWasCancelled)
})
}
}
#objc func handlePan(_ sender: UIPanGestureRecognizer) {
guard let mainView = sender.view else { return }
let translation = max(0, sender.translation(in: mainView).y)
let percent = translation/mainView.bounds.height
switch sender.state {
case .began:
self.hasStarted = true
self.presentedViewController?.dismiss(animated: true, completion: {
print("COMPLETION")
})
case .changed:
self.interactor.update(percent)
case .cancelled, .failed:
self.hasStarted = false
self.interactor.cancel()
case .ended:
self.hasStarted = false
if percent > 0.3 {
self.interactor.finish()
} else {
self.interactor.cancel()
}
default:
break
}
}
When pan gesture happens, layout of presented UIViewController becomes invalid.
Presented UIViewController changes its orientation,
and pan gesture not being handled anymore.
COMPLETED PROJECT
Try this code in viewWillappear
super.viewWillAppear(true)
self.view.frame = UIScreen.main.bounds
self.view.layoutIfNeeded()

Display 2 view controllers at the same time with animation

I'm following this awesome video to create a custom transition for my project, because I'm developing for the iPad, so instead of presenting destination view controller full screen, I want to have it occupy half of the screen like this:
My code of the custom transition class is:
class CircularTransition: NSObject {
var circle = UIView()
var startingPoint = CGPoint.zero {
didSet {
circle.center = startingPoint
}
}
var circleColor = UIColor.white
var duration = 0.4
enum circularTransitionMode: Int {
case present, dismiss
}
var transitionMode = circularTransitionMode.present
}
extension CircularTransition: UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
if transitionMode == .present {
if let presentedView = transitionContext.view(forKey: UITransitionContextViewKey.to) {
var viewCenter = presentedView.center
var viewSize = presentedView.frame.size
if UIDevice.current.userInterfaceIdiom == .pad {
viewCenter = CGPoint(x: viewCenter.x, y: viewSize.height)
viewSize = CGSize(width: viewSize.width, height: viewSize.height)
}
circle = UIView()
circle.frame = frameForCircle(withViewCenter: viewCenter, size: viewSize, startPoint: startingPoint)
circle.layer.cornerRadius = circle.frame.size.width / 2
circle.center = startingPoint
circle.backgroundColor = circleColor
circle.transform = CGAffineTransform(scaleX: 0.001, y: 0.001)
containerView.addSubview(circle)
presentedView.center = startingPoint
presentedView.transform = CGAffineTransform(scaleX: 0.001, y: 0.001)
presentedView.alpha = 0
containerView.addSubview(presentedView)
UIView.animate(withDuration: duration, animations: {
self.circle.transform = CGAffineTransform.identity
presentedView.transform = CGAffineTransform.identity
presentedView.alpha = 1
presentedView.center = viewCenter
}, completion: {(sucess: Bool) in transitionContext.completeTransition(sucess)})
}
} else {
if let returningView = transitionContext.view(forKey: UITransitionContextViewKey.from) {
let viewCenter = returningView.center
let viewSize = returningView.frame.size
circle.frame = frameForCircle(withViewCenter: viewCenter, size: viewSize, startPoint: startingPoint)
circle.layer.cornerRadius = circle.frame.size.width / 2
circle.center = startingPoint
UIView.animate(withDuration: duration + 0.1, animations: {
self.circle.transform = CGAffineTransform(scaleX: 0.001, y: 0.001)
returningView.transform = CGAffineTransform(scaleX: 0.001, y: 0.001)
returningView.center = self.startingPoint
returningView.alpha = 0
}, completion: {(success: Bool) in
returningView.center = viewCenter
returningView.removeFromSuperview()
self.circle.removeFromSuperview()
transitionContext.completeTransition(success)
})
}
}
}
func frameForCircle(withViewCenter viewCenter: CGPoint, size viewSize: CGSize, startPoint: CGPoint) -> CGRect {
let xLength = fmax(startingPoint.x, viewSize.width - startingPoint.x)
let yLength = fmax(startingPoint.y, viewSize.height - startingPoint.y)
let offsetVector = sqrt(xLength * xLength + yLength * yLength) * 2
let size = CGSize(width: offsetVector, height: offsetVector)
return CGRect(origin: CGPoint.zero, size: size)
}
}
And the part of code in my view controller:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
let secondVC = segue.destination as! ResultViewController
secondVC.transitioningDelegate = self
secondVC.modalPresentationStyle = .custom
}
// MARK: - Animation
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transtion.transitionMode = .dismiss
transtion.startingPoint = calculateButton.center
transtion.circleColor = calculateButton.backgroundColor!
return transtion
}
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transtion.transitionMode = .present
transtion.startingPoint = calculateButton.center
transtion.circleColor = calculateButton.backgroundColor!
return transtion
}
But the controller shows up full screen.
You may try the two different Container View for half of top and bottom.
then give animation on it...
So I have finished creating my answer, It takes a different approach than the other answers so bear with me.
Instead of adding a container view what I figured would be the best way was to create a UIViewController subclass (which I called CircleDisplayViewController). Then all your VCs that need to have this functionality could inherit from it (rather than from UIViewController).
This way all your logic for presenting and dismissing ResultViewController is handled in one place and can be used anywhere in your app.
The way your VCs can use it is like so:
class AnyViewController: CircleDisplayViewController {
/* Only inherit from CircleDisplayViewController,
otherwise you inherit from UIViewController twice */
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
#IBAction func showCircle(_ sender: UIButton) {
openCircle(withCenter: sender.center, radius: nil, resultDataSource: calculator!.iterateWPItems())
//I'll get to this stuff in just a minute
//Edit: from talking to Bright Future in chat I saw that resultViewController needs to be setup with calculator!.iterateWPItems()
}
}
Where showCircle will present your ResultViewController using the transitioning delegate with the circle center at the sending UIButtons center.
The CircleDisplayViewController subclass is this:
class CircleDisplayViewController: UIViewController, UIViewControllerTransitioningDelegate, ResultDelegate {
private enum CircleState {
case collapsed, visible
}
private var circleState: CircleState = .collapsed
private var resultViewController: ResultViewController!
private lazy var transition = CircularTransition()
func openCircle(withCenter center: CGPoint, radius: CGFloat?, resultDataSource: ([Items], Int, String)) {
let circleCollapsed = (circleState == .collapsed)
DispatchQueue.main.async { () -> Void in
if circleCollapsed {
self.addCircle(withCenter: center, radius: radius, resultDataSource: resultDataSource)
}
}
}
private func addCircle(withCenter circleCenter: CGPoint, radius: CGFloat?, resultDataSource: ([Items], Int, String])) {
var circleRadius: CGFloat!
if radius == nil {
circleRadius = view.frame.size.height/2.0
} else {
circleRadius = radius
}
//instantiate resultViewController here, and setup delegate etc.
resultViewController = UIStoryboard.resultViewController()
resultViewController.transitioningDelegate = self
resultViewController.delegate = self
resultViewController.modalPresentationStyle = .custom
//setup any values for resultViewController here
resultViewController.dataSource = resultDataSource
//then set the frame of resultViewController (while also setting endFrame)
let resultOrigin = CGPoint(x: 0.0, y: circleCenter.y - circleRadius)
let resultSize = CGSize(width: view.frame.size.width, height: (view.frame.size.height - circleCenter.y) + circleRadius)
resultViewController.view.frame = CGRect(origin: resultOrigin, size: resultSize)
resultViewController.endframe = CGRect(origin: resultOrigin, size: resultSize)
transition.circle = UIView()
transition.startingPoint = circleCenter
transition.radius = circleRadius
transition.circle.frame = circleFrame(radius: transition.radius, center: transition.startingPoint)
present(resultViewController, animated: true, completion: nil)
}
func collapseCircle() { //THIS IS THE RESULT DELEGATE FUNCTIONS
dismiss(animated: true) {
self.resultViewController = nil
}
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.transitionMode = .dismiss
transition.circleColor = UIColor.red
return transition
}
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.transitionMode = .present
transition.circleColor = UIColor.red
return transition
}
func circleFrame(radius: CGFloat, center: CGPoint) -> CGRect {
let circleOrigin = CGPoint(x: center.x - radius, y: center.y - radius)
let circleSize = CGSize(width: radius*2, height: radius*2)
return CGRect(origin: circleOrigin, size: circleSize)
}
}
public extension UIStoryboard {
class func mainStoryboard() -> UIStoryboard { return UIStoryboard(name: "Main", bundle: Bundle.main) }
}
private extension UIStoryboard {
class func resultViewController() -> ResultViewController {
return mainStoryboard().instantiateViewController(withIdentifier: "/* Your ID for ResultViewController */") as! ResultViewController
}
}
The only function that is called by the VCs that inherit from DisplayCircleViewController is openCircle, openCircle has a circleCenter argument (which should be your button center I'm guessing), an optional radius argument (if this is nil then a default value of half the view height is taken, and then whatever else you need to setup ResultViewController.
In the addCircle func there is some important stuff:
you setup ResultViewController however you have to before presenting (like you would in prepare for segue),
then setup the frame for it (I tried to make it the area of the circle that is visible but it is quite rough here, might be worth playing around with),
then this is where I reset the transition circle (rather than in the transition class), so that I could set the circle starting point, radius and frame here.
then just a normal present.
If you haven't set an identifier for ResultViewController you need to for this (see the UIStoryboard extensions)
I also changed the TransitioningDelegate functions so you don't set the circle center, this is because to keep it generic I put that responsibility to the ViewController that inherits from this one. (see top bit of code)
Finally I changed the CircularTransition class
I added a variable:
var radius: CGFloat = 0.0 //set in the addCircle function above
and changed animateTransition:
(removed the commented out lines):
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
if transitionMode == .present {
if let presentedView = transitionContext.view(forKey: UITransitionContextViewKey.to) {
...
// circle = UIView()
// circle.frame = frameForCircle(withViewCenter: viewCenter, size: viewSize, startPoint: startingPoint)
circle.layer.cornerRadius = radius
...
}
} else {
if let returningView = transitionContext.view(forKey: UITransitionContextViewKey.from) {
...
// circle.frame = frameForCircle(withViewCenter: viewCenter, size: viewSize, startPoint: startingPoint)
...
}
}
}
Finally I made a protocol so that ResultViewController could dismiss the circle
protocol ResultDelegate: class {
func collapseCircle()
}
class ResultViewController: UIViewController {
weak var delegate: ResultDelegate!
var endFrame: CGRect!
var dataSource: ([Items], Int, String)! // same as in Bright Future's case
override func viewDidLoad() {
super.viewDidLoad()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
override func viewDidLayoutSubviews() {
if endFrame != nil {
view.frame = endFrame
}
}
#IBAction func closeResult(_ sender: UIButton) {
delegate.collapseCircle()
}
}
This has turned out to be quite a huge answer, sorry about that, I wrote it in a bit a of rush so if anything is not clear just say.
Hope this helps!
Edit: I found the problem, iOS 10 has changed the way they layout views, so to fix this I added an endFrame property to ResultViewController and set it's views frame to that in viewDidLayoutSubviews. I also set both the frame and endFrame at the same time in addCircle. I changed the code above to reflect the changes. It's not ideal but I'll have another look later to see if there is a better fix.
Edit: this is what it looks like open for me
Thanks to everyone for the suggestions, I tried to use a container view, here's how I did it:
First I added a containerView property in CircularTransition class:
class CircularTransition: NSObject {
...
var containerView: UIView
init(containerView: UIView) {
self.containerView = containerView
}
...
}
Then commented out these code in its extension:
// let containerView = transitionContext.containerView
// if UIDevice.current.userInterfaceIdiom == .pad {
// viewCenter = CGPoint(x: viewCenter.x, y: viewSize.height)
// viewSize = CGSize(width: viewSize.width, height: viewSize.height)
// }
In my mainViewController, I added a method to add a container view:
func addContainerView() {
let containerView = UIView()
containerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(containerView)
NSLayoutConstraint.activate([
containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10),
containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10),
containerView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.5),
containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -10),
])
transtion.containerView = containerView
}
The reason I don't use story board is, if I put the animated view controller (ResultViewController) in the container view, it gets loaded whenever mainViewController is loaded, however, ResultViewController needs the data from prepareForSegue, thus it'll crash.
Then I changed a little bit in prepareForSegue:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
transtion.containerView = view
if UIDevice.current.userInterfaceIdiom == .pad {
addContainerView()
}
let secondVC = segue.destination as! ResultViewController
secondVC.transitioningDelegate = self
secondVC.modalPresentationStyle = .custom
secondVC.dataSource = calculator!.iterateWPItems().0
}
And created CircularTransition class this way in mainViewController:
let transtion = CircularTransition(containerView: UIView())
That's basically all I did, I could display the gorgeous dual vc view
on the iPad, however, the return transition doesn't work, I still
haven't figured out what caused that.
Hi i did some changes in your animateTransition method try this out. You might have to play a little bit with withRelativeStartTime of the animations and the frame and center to perfect the animation. But i guess this should get you started.
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
if transitionMode == .present {
if let presentedView = transitionContext.view(forKey: UITransitionContextViewKey.to) {
var viewCenter = presentedView.center
var viewSize = presentedView.frame.size
if UIDevice.current.userInterfaceIdiom == .pad {
viewCenter = CGPoint(x: viewCenter.x, y: viewSize.height)
viewSize = CGSize(width: viewSize.width, height: viewSize.height)
}
circle = UIView()
circle.frame = frameForCircle(withViewCenter: viewCenter, size: viewSize, startPoint: startingPoint)
circle.layer.cornerRadius = circle.frame.size.width / 2
circle.center = startingPoint
circle.backgroundColor = circleColor
circle.transform = CGAffineTransform(scaleX: 0.001, y: 0.001)
circle.layer.masksToBounds = true
containerView.addSubview(circle)
presentedView.center = startingPoint
presentedView.transform = CGAffineTransform(scaleX: 0.001, y: 0.001)
presentedView.alpha = 0
containerView.addSubview(presentedView)
UIView.animateKeyframes(withDuration: duration, delay: 0, options: .calculationModeLinear, animations: {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 1, animations: {
self.circle.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
presentedView.alpha = 1
})
UIView.addKeyframe(withRelativeStartTime: 0.19, relativeDuration: 1, animations: {
presentedView.transform = CGAffineTransform(scaleX: 1, y: 1)
presentedView.frame = CGRect(x: 0, y: (containerView.frame.size.height / 2)+10, width: containerView.frame.size.width, height: containerView.frame.size.height*0.5)
})
}, completion: { (sucess) in
transitionContext.completeTransition(sucess)
})
}
} else {
if let returningView = transitionContext.view(forKey: UITransitionContextViewKey.from) {
let viewCenter = returningView.center
let viewSize = returningView.frame.size
circle.frame = frameForCircle(withViewCenter: viewCenter, size: viewSize, startPoint: startingPoint)
circle.layer.cornerRadius = circle.frame.size.width / 2
circle.center = startingPoint
UIView.animate(withDuration: duration + 0.1, animations: {
self.circle.transform = CGAffineTransform(scaleX: 0.001, y: 0.001)
returningView.transform = CGAffineTransform(scaleX: 0.001, y: 0.001)
returningView.center = self.startingPoint
returningView.alpha = 0
}, completion: {(success: Bool) in
returningView.center = viewCenter
returningView.removeFromSuperview()
self.circle.removeFromSuperview()
transitionContext.completeTransition(success)
})
}
}
}
Hope this helps.

Resources