How to display UITableViewController as a swipe up gesture in front of another ViewController?

i0S Swift Issue

Question or problem in the Swift programming language:

I am trying to get something like this to work. This is the Uber App. Where a user can swipe another view up, in front of a background view.

The background view is fairly simple, it has been done already. The view which will be swiped on top will be a UITableView. I want the user to be able to see just a little top part first when the app launches, then upon swiping a little it should stop in the middle and then after fully swiping up should take it all the way to the top, replacing the Background view.

Frameworks I have looked at are pullable view for iOS. But it is way too old and doesn’t get any nice animations across. I have also looked at SWRevealViewController but I can’t figure out how to swipe up from below.

I have also tried to use a button so when a user clicks on it, the table view controller appears modally, covering vertical, but that is not what I want. It needs to recognize a gesture.

Any help is greatly appreciated. Thanks.

EDIT: I’m going to un-accept my answer and keep it open if anyone in the future has a much better way of implementing it. Even though my answer works, it can be improved. Plus, it isn’t as flush as what’s going on in the Uber app, which was my original question.

How to solve the problem:

Solution 1:

I’m aware that the question is almost 2 and a half years old, but just in case someone finds this through a search engine:

I’d say that your best bet is to use UIViewPropertyAnimator. There’s a great article about it here: http://www.swiftkickmobile.com/building-better-app-animations-swift-uiviewpropertyanimator/

EDIT:

I managed to get a simple prototype working with UIViewPropertyAnimator, here’s a GIF of it:

Here’s the project on Github: https://github.com/Luigi123/UIViewPropertyAnimatorExample

Basically I have two UIViewControllers, the main one called ViewController and the secondary one called BottomSheetViewController. The secondary view has an UIPanGestureRecognizer to make it draggable, and inside the recognizer’s callback function I do 3 things (after actually moving it):

① calculate how much percent of the screen have been dragged,
② trigger the animations in the secondary view itself,
③ notify the main view about the drag action so it can trigger it’s animations. In this case I use a Notification, passing the percentage inside notification.userInfo.

I’m not sure how to convey ①, so as an example if the screen is 500 pixels tall and the user dragged the secondary view up to the 100th pixel, I calculate that the user dragged it 20% of the way up. This percentage is exactly what I need to pass into the fractionComplete property inside the UIViewPropertyAnimator instances.

⚠️ One thing to note is that I couldn’t make it work with an actual navigation bar, so I used a “normal” view with a label in it’s place.

I tried making the code smaller by removing some utility functions like checking if the user interaction is finished, but that means that the user can stop dragging in the middle of the screen and the app wouldn’t react at all, so I really suggest you see the entire code in the github repo. But the good news is that the entire code that executes the animations fits in about 100 lines of code.

With that in mind, here’s the code for the main screen, ViewController:

import UIKit
import MapKit
import NotificationCenter

class ViewController: UIViewController {
    @IBOutlet weak var someView: UIView!
    @IBOutlet weak var blackView: UIView!

    var animator: UIViewPropertyAnimator?

    func createBottomView() {
        guard let sub = storyboard!.instantiateViewController(withIdentifier: "BottomSheetViewController") as? BottomSheetViewController else { return }
        self.addChild(sub)
        self.view.addSubview(sub.view)
        sub.didMove(toParent: self)
        sub.view.frame = CGRect(x: 0, y: view.frame.maxY - 100, width: view.frame.width, height: view.frame.height)
    }

    func subViewGotPanned(_ percentage: Int) {
        guard let propAnimator = animator else {
            animator = UIViewPropertyAnimator(duration: 3, curve: .linear, animations: {
                self.blackView.alpha = 1
                self.someView.transform = CGAffineTransform(scaleX: 0.8, y: 0.8).concatenating(CGAffineTransform(translationX: 0, y: -20))
            })
            animator?.startAnimation()
            animator?.pauseAnimation()
            return
        }
        propAnimator.fractionComplete = CGFloat(percentage) / 100
    }

    func receiveNotification(_ notification: Notification) {
        guard let percentage = notification.userInfo?["percentage"] as? Int else { return }
        subViewGotPanned(percentage)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        createBottomView()

        let name = NSNotification.Name(rawValue: "BottomViewMoved")
        NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil, using: receiveNotification(_:))
    }
}

And the code for the secondary view (BottomSheetViewController):

import UIKit
import NotificationCenter

class BottomSheetViewController: UIViewController, UIGestureRecognizerDelegate {
    @IBOutlet weak var navBarView: UIView!

    var panGestureRecognizer: UIPanGestureRecognizer?
    var animator: UIViewPropertyAnimator?

    override func viewDidLoad() {
        gotPanned(0)
        super.viewDidLoad()

        let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(respondToPanGesture))
        view.addGestureRecognizer(gestureRecognizer)
        gestureRecognizer.delegate = self
        panGestureRecognizer = gestureRecognizer
    }

    func gotPanned(_ percentage: Int) {
        if animator == nil {
            animator = UIViewPropertyAnimator(duration: 1, curve: .linear, animations: {
                let scaleTransform = CGAffineTransform(scaleX: 1, y: 5).concatenating(CGAffineTransform(translationX: 0, y: 240))
                self.navBarView.transform = scaleTransform
                self.navBarView.alpha = 0
            })
            animator?.isReversed = true
            animator?.startAnimation()
            animator?.pauseAnimation()
        }
        animator?.fractionComplete = CGFloat(percentage) / 100
    }

    // MARK: methods to make the view draggable

    @objc func respondToPanGesture(recognizer: UIPanGestureRecognizer) {
        let translation = recognizer.translation(in: self.view)
        moveToY(self.view.frame.minY + translation.y)
        recognizer.setTranslation(.zero, in: self.view)
    }

    private func moveToY(_ position: CGFloat) {
        view.frame = CGRect(x: 0, y: position, width: view.frame.width, height: view.frame.height)
        let maxHeight = view.frame.height - 100
        let percentage = Int(100 - ((position * 100) / maxHeight))
        gotPanned(percentage)
        let name = NSNotification.Name(rawValue: "BottomViewMoved")
        NotificationCenter.default.post(name: name, object: nil, userInfo: ["percentage": percentage])
    }
}

Solution 2:

EDIT: So, some time has passed and now there is a really awesome library called Pulley. It does exactly what I wanted it to do, and its a breeze to setup!

Original answer:

Thanks to both Rikh and Tj3n for giving me hints. I managed to do something very basic, it doesn’t have nice animations like Uber but it gets the job done.

With the following code, you can swipe any UIViewController. I use a UIPanGestureRecognizer on my image, which will stay on top of the dragged view at all times. Basically, you use that image and it recognizes where it gets dragged, and it sets the view’s frame according to the user’s input.

First go to your storyboard and add an identifier for the UIViewController that will be dragged.

enter image description here

Then in the MainViewController, use the following code:

class MainViewController: UIViewController {

// This image will be dragged up or down.
@IBOutlet var imageView: UIImageView!

// Gesture recognizer, will be added to image below.
var swipedOnImage = UIPanGestureRecognizer()

// This is the view controller that will be dragged with the image. In my case it's a UITableViewController.
var vc = UIViewController()

override func viewDidLoad() {
    super.viewDidLoad()

    // I'm using a storyboard.
    let sb = UIStoryboard(name: "Main", bundle: nil)

    // I have identified the view inside my storyboard.
    vc = sb.instantiateViewController(withIdentifier: "TableVC")

    // These values can be played around with, depending on how much you want the view to show up when it starts.
    vc.view.frame = CGRect(x: 0, y: self.view.frame.height, width: self.view.frame.width, height: -300)


    self.addChildViewController(vc)
    self.view.addSubview(vc.view)
    vc.didMove(toParentViewController: self)

    swipedOnImage = UIPanGestureRecognizer(target: self, action: #selector(self.swipedOnViewAction))
    imageView.addGestureRecognizer(swipedOnImage)

    imageView.isUserInteractionEnabled = true

}

// This function handles resizing of the tableview.
func swipedOnViewAction() {

    let yLocationTouched = swipedOnImage.location(in: self.view).y

    imageView.frame.origin.y = yLocationTouched

    // These values can be played around with if required. 
    vc.view.frame =  CGRect(x: 0, y: yLocationTouched, width: UIScreen.main.bounds.width, height: (UIScreen.main.bounds.height) - (yLocationTouched))
    vc.view.frame.origin.y = yLocationTouched + 50

}

Final Product

enter image description here

Now, It is possible that my answer might not be the most efficient way of going at this, but I am new to iOS so this is the best I could come up with for the time being.

Solution 3:

You can embed that table view inside a custom scroll view that will only handle touch when touch that table view part (override hittest), then drag it up (disable tableview scroll), till the upper part then disable scroll view and enable tableview scroll again

Or, you can just add the swipe gesture into your tableview and change it’s frame along and disable swipe when it reach the top

Experiment with those and eventually you will achieve the effect you wanted

Solution 4:

As Tj3n pointed out, you could use a UISwipeGesture to display the UITableView. So using constraints (instead of frames) heres how you could go about doing that:

Go to your UIViewController inside your Story board on which you wish to display the UITableView. Drag and drop the UITableView and add a leading, trailing and height to the UITableView. Now add a vertical constraint between the UIViewController and UITableView so that the UITableView appears below the UIViewController(Play around with this vertical value until you can display the top part of the UITableView to suit your need). Create outlets for the vertical spacing constraint and height constraint (in case you need to set a specific height that you can figure out at run time). On the swipe up just animatedly set the vertical constraint to be equal to the negative value of the height sort of like:

topSpaceToViewControllerConstraint.constant = -mainTableViewHeightConstraint.constant

 UIView.animate(withDuration: 0.3) { 

     view.layoutIfNeeded()
 };

Alternatively

If you want to be able to bring the UITableView up depending on the pan amount (i.e depending on how much the user has moved across the screen or how fast) you should use a UIPanGestureRecognizer instead and try and set frames instead of autoLayout for the UITableView (as I’m not a big fan of calling view.layoutIfNeeded repeatedly. I read somewhere that it is an expensive operation, would appreciate it if someone would confirm or correct this).

func handlePan(sender: UIPanGestureRecognizer) {

        if sender.state == .Changed {
               //update y origin value here based on the pan amount
        }
    }

Alternatively using UITableViewController

Doing what you wish to perform is also possible using a UITableViewController if you wish to but it involves a lot of faking and effort by creating a custom UINavigationControllerDelegate mainly to create a custom animation that will use UIPercentDrivenInteractiveTransition to pull the new UITableViewController up using a UIPanGestureRecognizer if you want it depending on the pan amount. Otherwise you can simply add a UISwipeGestureRecognizer to present the UITableViewController but you will still have to again create a custom animation to “fake” the effect you want.

Hope this helps!