How do I hide/show tabBar when tapped using Swift in iOS8 - ios

I am trying to mimic the UINavigationController's new hidesBarsOnTap with a tab bar. I have seen many answers to this that either point to setting the hidesBottomBarWhenPushed on a viewController which only hides it entirely and not when tapped.
#IBAction func tapped(sender: AnyObject) {
// what goes here to show/hide the tabBar ???
}
thanks in advance
EDIT: as per the suggestion below I tried
self.tabBarController?.tabBar.hidden = true
which does indeed hide the tabBar (toggles true/false on tap), but without animation. I will ask that as a separate question though.

After much hunting and trying out various methods to gracefully hide/show the UITabBar using Swift I was able to take this great solution by danh and convert it to Swift:
func setTabBarVisible(visible: Bool, animated: Bool) {
//* This cannot be called before viewDidLayoutSubviews(), because the frame is not set before this time
// bail if the current state matches the desired state
if (tabBarIsVisible() == visible) { return }
// get a frame calculation ready
let frame = self.tabBarController?.tabBar.frame
let height = frame?.size.height
let offsetY = (visible ? -height! : height)
// zero duration means no animation
let duration: TimeInterval = (animated ? 0.3 : 0.0)
// animate the tabBar
if frame != nil {
UIView.animate(withDuration: duration) {
self.tabBarController?.tabBar.frame = frame!.offsetBy(dx: 0, dy: offsetY!)
return
}
}
}
func tabBarIsVisible() -> Bool {
return (self.tabBarController?.tabBar.frame.origin.y)! < self.view.frame.maxY
}
// Call the function from tap gesture recognizer added to your view (or button)
#IBAction func tapped(_ sender: Any?) {
setTabBarVisible(visible: !tabBarIsVisible(), animated: true)
}

Loved Michael Campsall's answer. Here's the same code as extension, if somebody is interested:
Swift 2.3
extension UITabBarController {
func setTabBarVisible(visible:Bool, animated:Bool) {
// bail if the current state matches the desired state
if (tabBarIsVisible() == visible) { return }
// get a frame calculation ready
let frame = self.tabBar.frame
let height = frame.size.height
let offsetY = (visible ? -height : height)
// animate the tabBar
UIView.animateWithDuration(animated ? 0.3 : 0.0) {
self.tabBar.frame = CGRectOffset(frame, 0, offsetY)
self.view.frame = CGRectMake(0, 0, self.view.frame.width, self.view.frame.height + offsetY)
self.view.setNeedsDisplay()
self.view.layoutIfNeeded()
}
}
func tabBarIsVisible() ->Bool {
return self.tabBar.frame.origin.y < CGRectGetMaxY(self.view.frame)
}
}
Swift 3
extension UIViewController {
func setTabBarVisible(visible: Bool, animated: Bool) {
//* This cannot be called before viewDidLayoutSubviews(), because the frame is not set before this time
// bail if the current state matches the desired state
if (isTabBarVisible == visible) { return }
// get a frame calculation ready
let frame = self.tabBarController?.tabBar.frame
let height = frame?.size.height
let offsetY = (visible ? -height! : height)
// zero duration means no animation
let duration: TimeInterval = (animated ? 0.3 : 0.0)
// animate the tabBar
if frame != nil {
UIView.animate(withDuration: duration) {
self.tabBarController?.tabBar.frame = frame!.offsetBy(dx: 0, dy: offsetY!)
return
}
}
}
var isTabBarVisible: Bool {
return (self.tabBarController?.tabBar.frame.origin.y ?? 0) < self.view.frame.maxY
}
}

I had to adapt the accepted answer to this question a bit. It was hiding the bar but my view wasn't sizing itself appropriately so I was left with a space at the bottom.
The following code successfully animates the hiding of the tab bar while resizing the view to avoid that issue.
Updated for Swift 3 (now with less ugly code)
func setTabBarVisible(visible: Bool, animated: Bool) {
guard let frame = self.tabBarController?.tabBar.frame else { return }
let height = frame.size.height
let offsetY = (visible ? -height : height)
let duration: TimeInterval = (animated ? 0.3 : 0.0)
UIView.animate(withDuration: duration,
delay: 0.0,
options: UIViewAnimationOptions.curveEaseIn,
animations: { [weak self] () -> Void in
guard let weakSelf = self else { return }
weakSelf.tabBarController?.tabBar.frame = frame.offsetBy(dx: 0, dy: offsetY)
weakSelf.view.frame = CGRect(x: 0, y: 0, width: weakSelf.view.frame.width, height: weakSelf.view.frame.height + offsetY)
weakSelf.view.setNeedsDisplay()
weakSelf.view.layoutIfNeeded()
})
}
func handleTap(recognizer: UITapGestureRecognizer) {
setTabBarVisible(visible: !tabBarIsVisible(), animated: true)
}
func tabBarIsVisible() -> Bool {
guard let tabBar = tabBarController?.tabBar else { return false }
return tabBar.frame.origin.y < UIScreen.main.bounds.height
}
Older Swift 2 Version
func setTabBarVisible(visible: Bool, animated: Bool) {
// hide tab bar
let frame = self.tabBarController?.tabBar.frame
let height = frame?.size.height
var offsetY = (visible ? -height! : height)
println ("offsetY = \(offsetY)")
// zero duration means no animation
let duration:NSTimeInterval = (animated ? 0.3 : 0.0)
// animate tabBar
if frame != nil {
UIView.animateWithDuration(duration) {
self.tabBarController?.tabBar.frame = CGRectOffset(frame!, 0, offsetY!)
self.view.frame = CGRectMake(0, 0, self.view.frame.width, self.view.frame.height + offsetY!)
self.view.setNeedsDisplay()
self.view.layoutIfNeeded()
return
}
}
}
#IBAction func handleTap(recognizer: UITapGestureRecognizer) {
setTabBarVisible(!tabBarIsVisible(), animated: true)
}
func tabBarIsVisible() -> Bool {
return self.tabBarController?.tabBar.frame.origin.y < UIScreen.mainScreen().bounds.height
}

You can just add this line to ViewDidLoad() in swift :
self.tabBarController?.tabBar.hidden = true

I use tabBar.hidden = YES in ObjC to hide the tab bar in certain cases. I have not tried wiring it up to a tap event, though.

Code is okay but when you use presentViewController, tabBarIsVisible() is not working. To keep UITabBarController always hidden use just this part:
extension UITabBarController {
func setTabBarVisible(visible:Bool, animated:Bool) {
let frame = self.tabBar.frame
let height = frame.size.height
let offsetY = (visible ? -height : height)
UIView.animateWithDuration(animated ? 0.3 : 0.0) {
self.tabBar.frame = CGRectOffset(frame, 0, offsetY)
self.view.frame = CGRectMake(0, 0, self.view.frame.width, self.view.frame.height + offsetY)
self.view.setNeedsDisplay()
self.view.layoutIfNeeded()
}
}
}

Swift 3 version:
func setTabBarVisible(visible:Bool, animated:Bool) {
//* This cannot be called before viewDidLayoutSubviews(), because the frame is not set before this time
// bail if the current state matches the desired state
if (tabBarIsVisible() == visible) { return }
// get a frame calculation ready
let frame = self.tabBarController?.tabBar.frame
let height = frame?.size.height
let offsetY = (visible ? -height! : height)
// zero duration means no animation
let duration:TimeInterval = (animated ? 0.3 : 0.0)
// animate the tabBar
if frame != nil {
UIView.animate(withDuration: duration) {
self.tabBarController?.tabBar.frame = (self.tabBarController?.tabBar.frame.offsetBy(dx: 0, dy: offsetY!))!
return
}
}
}
func tabBarIsVisible() ->Bool {
return (self.tabBarController?.tabBar.frame.origin.y)! < self.view.frame.midY
}

Swift 5
To hide
override func viewWillAppear(_ animated: Bool) {
self.tabBarController?.tabBar.isHidden = true
}
To show again
override func viewDidDisappear(_ animated: Bool) {
self.tabBarController?.tabBar.isHidden = false
}

For Swift 4, and animating + hiding by placing tabBar outside the view:
if let tabBar = tabBarController?.tabBar,
let y = tabBar.frame.origin.y + tabBar.frame.height {
UIView.animate(withDuration: 0.2) {
tabBar.frame = CGRect(origin: CGPoint(x: tabBar.frame.origin.x, y: y), size: tabBar.frame.size)
}
}

To make the animations work with self.tabBarController?.tabBar.hidden = true just do this:
UIView.animateWithDuration(0.2, animations: {
self.tabBarController?.tabBar.hidden = true
})
Other than the other solution this will also work nicely with autolayout.

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

Swift UITabBarController hide with animation

I'm trying to add animation to my tabBarController when hidden. Im able to accomplish this effect with the navigationBarController by using self.navigationController?.isNavigationBarHidden = true. I'm able to hide the tabBar by using self.tabBarController?.tabBar.isHidden = true but i do not get the animation how can I do this thank you in advance.
You could change the tab bar's frame inside an animation, so something like:
func hideTabBar() {
var frame = self.tabBarController?.tabBar.frame
frame?.origin.y = self.view.frame.size.height + (frame?.size.height)!
UIView.animate(withDuration: 0.5, animations: {
self.tabBarController?.tabBar.frame = frame!
})
}
func showTabBar() {
var frame = self.tabBarController?.tabBar.frame
frame?.origin.y = self.view.frame.size.height - (frame?.size.height)!
UIView.animate(withDuration: 0.5, animations: {
self.tabBarController?.tabBar.frame = frame!
})
}
Which sets the tab bar just below the visible screen, so that it slides up/down from the bottom.
I've developed a util extension for UIViewController
Swift 4 compatible:
extension UIViewController {
func setTabBarHidden(_ hidden: Bool, animated: Bool = true, duration: TimeInterval = 0.3) {
if animated {
if let frame = self.tabBarController?.tabBar.frame {
let factor: CGFloat = hidden ? 1 : -1
let y = frame.origin.y + (frame.size.height * factor)
UIView.animate(withDuration: duration, animations: {
self.tabBarController?.tabBar.frame = CGRect(x: frame.origin.x, y: y, width: frame.width, height: frame.height)
})
return
}
}
self.tabBarController?.tabBar.isHidden = hidden
}
}
Improvement of the response of #Luca Davanzo. If the bar is already hidden, it will continue hiding it and moving it lower. Also get rid of the return, so the state of the tabbar.hidden changes when the animation happens.
So I added a check:
extension UIViewController {
func setTabBarHidden(_ hidden: Bool, animated: Bool = true, duration: TimeInterval = 0.5) {
if self.tabBarController?.tabBar.isHidden != hidden{
if animated {
//Show the tabbar before the animation in case it has to appear
if (self.tabBarController?.tabBar.isHidden)!{
self.tabBarController?.tabBar.isHidden = hidden
}
if let frame = self.tabBarController?.tabBar.frame {
let factor: CGFloat = hidden ? 1 : -1
let y = frame.origin.y + (frame.size.height * factor)
UIView.animate(withDuration: duration, animations: {
self.tabBarController?.tabBar.frame = CGRect(x: frame.origin.x, y: y, width: frame.width, height: frame.height)
}) { (bool) in
//hide the tabbar after the animation in case ti has to be hidden
if (!(self.tabBarController?.tabBar.isHidden)!){
self.tabBarController?.tabBar.isHidden = hidden
}
}
}
}
}
}
}
In case if you need to toggle it from hide to visible and vice versa:
func toggleTabbar() {
guard var frame = tabBarController?.tabBar.frame else { return }
let hidden = frame.origin.y == view.frame.size.height
frame.origin.y = hidden ? view.frame.size.height - frame.size.height : view.frame.size.height
UIView.animate(withDuration: 0.3) {
self.tabBarController?.tabBar.frame = frame
}
}
Swift 4 solution:
tabBarController?.tabBar.isHidden = true
UIView.transition(with: tabBarController!.view, duration: 0.35, options: .transitionCrossDissolve, animations: nil)
Here is a simple extension :
func setTabBar(hidden:Bool) {
guard let frame = self.tabBarController?.tabBar.frame else {return }
if hidden {
UIView.animate(withDuration: 0.3, animations: {
self.tabBarController?.tabBar.frame = CGRect(x: frame.origin.x, y: frame.origin.y + frame.height, width: frame.width, height: frame.height)
})
}else {
UIView.animate(withDuration: 0.3, animations: {
self.tabBarController?.tabBar.frame = UITabBarController().tabBar.frame
})
}
}
So I've been playing around for 3 days with this now, finding out that the one that worked for me in my code was Adriana's post from 14th Sept 2018. But I was not sure how to use the coding once copied into my Project. So, after much experimenting I found that the way I could use this func was to put the following into into the respective swipe actions.
setTabBarHidden(false)
setTabBarHidden(true)
My next step is to try to get the swipe actions working while using UIScrollView in the same UIView at the same time.
You have to add UIView transitionWithView class func
Swift 2
func hideTabBarWithAnimation() -> () {
UIView.transitionWithView(tableView, duration: 1.0, options: .TransitionCrossDissolve, animations: { () -> Void in
self.tabBarController?.tabBar.hidden = true
}, completion: nil)
}
Swift 3, 4, 5
func hideTabBarWithAnimation() -> () {
UIView.transition(with: tableView, duration: 1.0, options: .transitionCrossDissolve, animations: { () -> Void in
self.tabBarController?.tabBar.isHidden = true
}, completion: nil)
}

How to change the view's bounds within UIPresentationController?

I use subclass of UIPresentationController to present some controller on the screen. This is how I prepare it:
controller.transitioningDelegate = self
controller.modalPresentationStyle = .Custom
presentViewController(controller, animated: true, completion: nil)
But within controller there is a textField, and I have added there observer for UIKeyboardDidShowNotification. Is it possible to update view's frame when keyboard appear?
This is how it looks like:
I need to change the bounds of that view because of keyboard.
containerView?.setNeedsLayout() needs to be called after whatever I change.
It is relative easy.
First you will need to observe for keyboard changes in your presenter
Listen to notifications .UIKeyboardWillShow, .UIKeyboardDidShow, .UIKeyboardWillHide, .UIKeyboardDidHide
I would recommend making a KeyboardObserver class for this with in example a static instance and store the keyboard variables (frame, animation speed etc) in there and add a delegate on that class to inform you on keyboard changes.
You then end up with something like this
extension PresentationController: KeyboardManagerDelegate {
internal func keyboardManager(_ manager: KeyboardManager, action: KeyboardManager.KeyBoardDisplayAction, info: KeyboardManager.Info) {
guard let containerView = containerView else { return }
UIView.animate(withDuration: info.animationDuration, delay: 0, options: info.animationOptions, animations: {
containerView.setNeedsLayout()
containerView.layoutIfNeeded()
}, completion: nil)
}
}
Next you will need to override frameOfPresentedViewInContainerView.
Example:
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerView = containerView else {
return .zero
}
let desiredSize = CGSize(width: 540, height: 620)
let width = min(desiredSize.width, containerView.width)
let x = round((containerView.width - width) / 2)
if KeyboardManager.shared.isKeyboardVisible {
let availableHeight = containerView.height - KeyboardManager.shared.keyboardFrame.height
let height = availableHeight - 40
return CGRect(x: x, y: 25, width: width, height: height)
} else {
let height = min(desiredSize.height, containerView.height)
let y = round((containerView.height - height) / 2)
return CGRect(x: x, y: y, width: width, height: height)
}
}
At the end also implement a layout method to update the view
override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()
presentedView?.frame = frameOfPresentedViewInContainerView
}

iOS/Swift - Hide/Show UITabBarController when scrolling down/up

I'm quite new to iOS development. Right now i'm trying to hide my tabbar when I scroll down and when scrolling up the tabbar should appear. I would like to have this animated in the same way like the navigation bar. For the navigation bar I simply clicked the option in the Attributes Inspector. I saw some examples for the toolbar, but I cant adopt it the tabbar.
self.tabBarController?.tabBar.hidden = true just hides my tabbar, but its not animated like the navigation controller.
This is code that i'm actually using in a production app.
It's in Swift and it also updates UITabBar.hidden var.
func scrollViewWillBeginDragging(scrollView: UIScrollView) {
if scrollView.panGestureRecognizer.translation(in: scrollView).y < 0{
changeTabBar(hidden: true, animated: true)
}
else{
changeTabBar(hidden: false, animated: true)
}
}
You can also use the other callback method:
func scrollViewDidScroll(scrollView: UIScrollView) {
...
}
but if you choose so, then you must handle multiple calls to the helper method that actually hides the tabBar.
And then you need to add this method that animates the hide/show of the tabBar.
func changeTabBar(hidden:Bool, animated: Bool){
var tabBar = self.tabBarController?.tabBar
if tabBar!.hidden == hidden{ return }
let frame = tabBar?.frame
let offset = (hidden ? (frame?.size.height)! : -(frame?.size.height)!)
let duration:NSTimeInterval = (animated ? 0.5 : 0.0)
tabBar?.hidden = false
if frame != nil
{
UIView.animateWithDuration(duration,
animations: {tabBar!.frame = CGRectOffset(frame!, 0, offset)},
completion: {
println($0)
if $0 {tabBar?.hidden = hidden}
})
}
}
Update Swift 4
func changeTabBar(hidden:Bool, animated: Bool){
guard let tabBar = self.tabBarController?.tabBar else { return; }
if tabBar.isHidden == hidden{ return }
let frame = tabBar.frame
let offset = hidden ? frame.size.height : -frame.size.height
let duration:TimeInterval = (animated ? 0.5 : 0.0)
tabBar.isHidden = false
UIView.animate(withDuration: duration, animations: {
tabBar.frame = frame.offsetBy(dx: 0, dy: offset)
}, completion: { (true) in
tabBar.isHidden = hidden
})
}
This answer is a slight modification to Ariel answer which adds animation while user scrolls.
extension ViewController:UIScrollViewDelegate{
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.panGestureRecognizer.translation(in: scrollView).y < 0{
//scrolling down
changeTabBar(hidden: true, animated: true)
}
else{
//scrolling up
changeTabBar(hidden: false, animated: true)
}
}
func changeTabBar(hidden:Bool, animated: Bool){
let tabBar = self.tabBarController?.tabBar
let offset = (hidden ? UIScreen.main.bounds.size.height : UIScreen.main.bounds.size.height - (tabBar?.frame.size.height)! )
if offset == tabBar?.frame.origin.y {return}
print("changing origin y position")
let duration:TimeInterval = (animated ? 0.5 : 0.0)
UIView.animate(withDuration: duration,
animations: {tabBar!.frame.origin.y = offset},
completion:nil)
}
}
Building on Ariel's answer, I have updated the code for Swift3. This worked great on my collection views.
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.panGestureRecognizer.translation(in: scrollView).y < 0 {
changeTabBar(hidden: true, animated: true)
}else{
changeTabBar(hidden: false, animated: true)
}
}
func changeTabBar(hidden:Bool, animated: Bool){
let tabBar = self.tabBarController?.tabBar
if tabBar!.isHidden == hidden{ return }
let frame = tabBar?.frame
let offset = (hidden ? (frame?.size.height)! : -(frame?.size.height)!)
let duration:TimeInterval = (animated ? 0.5 : 0.0)
tabBar?.isHidden = false
if frame != nil
{
UIView.animate(withDuration: duration,
animations: {tabBar!.frame = frame!.offsetBy(dx: 0, dy: offset)},
completion: {
print($0)
if $0 {tabBar?.isHidden = hidden}
})
}
}
You can control UITabBar precisly by setting up your class as delegate for scrollView and implementing scrolling in scrollViewDidScroll: method.
Here is an example how I do it my application. You can probably easily modify that for your needs. Some helper function to get UITabBar included.
#define LIMIT(__VALUE__, __MIN__, __MAX__) MAX(__MIN__, MIN(__MAX__, __VALUE__))
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
CGFloat scrollOffset = scrollView.contentOffset.y;
CGFloat scrollDiff = scrollOffset - self.previousScrollViewYOffset;
CGFloat scrollHeight = scrollView.frame.size.height;
CGFloat scrollContentSizeHeight = scrollView.contentSize.height + scrollView.contentInset.bottom;
CGFloat scrollOffsetGlobal = scrollOffset + scrollView.contentInset.top;
[self updateUITabBarY:[self UITabBarView].frame.origin.y + scrollDiff];
self.previousScrollViewYOffset = scrollOffset;
}
- (UITabBar*) UITabBarView
{
for(UIView *view in self.tabBarController.view.subviews)
{
if([view isKindOfClass:[UITabBar class]])
{
return (UITabBar*) view;
}
}
return nil;
}
- (void) updateUITabBarY:(CGFloat) y
{
UITabBar* tabBar = [self UITabBarView];
if(tabBar)
{
CGRect frame = tabBar.frame;
frame.origin.y = LIMIT(y, [self UITabBarMiny], [self UITabBarMaxY]);
tabBar.frame = frame;
}
}
- (CGFloat) UITabBarMiny
{
return [UIScreen mainScreen].bounds.size.height - [self UITabBarView].frame.size.height - [[UIApplication sharedApplication] statusBarFrame].size.height + 20.0f;
}
- (CGFloat) UITabBarMaxY
{
return [UIScreen mainScreen].bounds.size.height;
}
Ariels answer works, but has some values that seem off. When you compare the y-value of the scrollView scrollView.panGestureRecognizer.translation(in: scrollView).y, "0" has the side effect, that the tabBar shows or hides when you stop scrolling. It calls the method one more time with a "0" value. I tried it with didEndDragging, didScroll and willBeginDragging with similar effects. And that feels very counter intuitive or buggy.
I used +/- 0.1 when comparing the y-value and got the desired effect, that it just shows and hides when you are really scrolling up or down.
Another thing that isn't mentioned is that the offset that you set with tabBar.frame = frame.offsetBy(dx: 0, dy: offset) will be reset when the app moves to the background. You scroll down, the tabBar disappears, you change the app, open it up again, the tabBar is still hidden but the frame is back to the old location. So when the function is called again, the tabBar moves up even more and you have a gap of the size of the tabBar.frame.
To get rid of this I compared the current frame location and animated the alpha value. I couldn't get the usual coming back up animation to work, maybe somebody will try, can't be that hard. But its okay this way, as it doesn't happen that often.
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let yValue = scrollView.panGestureRecognizer.translation(in: scrollView).y
if yValue < -0.1 {
//hide tabBar
changeTabBar(hidden: true, animated: true)
} else if yValue > 0.1 {
//show tabBar
changeTabBar(hidden: false, animated: true)
}
}
func changeTabBar(hidden:Bool, animated: Bool) {
guard let tabBar = self.tabBarController?.tabBar else {
return
}
if tabBar.isHidden == hidden{
return
}
let frame = tabBar.frame
let frameMinY = frame.minY //lower end of tabBar
let offset = hidden ? frame.size.height : -frame.size.height
let viewHeight = self.view.frame.height
//hidden but moved back up after moving app to background
if frameMinY < viewHeight && tabBar.isHidden {
tabBar.alpha = 0
tabBar.isHidden = false
UIView.animate(withDuration: 0.5) {
tabBar.alpha = 1
}
return
}
let duration:TimeInterval = (animated ? 0.5 : 0.0)
tabBar.isHidden = false
UIView.animate(withDuration: duration, animations: {
tabBar.frame = frame.offsetBy(dx: 0, dy: offset)
}, completion: { (true) in
tabBar.isHidden = hidden
})
}
According to #Ariel Hernández Amador answer for black screen after hiding Tabbar just use this line of code in your ViewDidLoad(). Working Superbly...I have posted this here as I am unable to comment over there.
viewDidLoad()
{
if #available(iOS 11.0, *) {
self.myScroll.contentInsetAdjustmentBehavior = .never
}
}
Here myScroll is the Scrollview I am using in my VC. Just replace it with your VC.

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

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.

Resources