[ios] How can I mimic the bottom sheet from the Maps app?

Can anyone tell me how I can mimic the bottom sheet in the new Maps app in iOS 10?

In Android, you can use a BottomSheet which mimics this behaviour, but I could not find anything like that for iOS.

Is that a simple scroll view with a content inset, so that the search bar is at the bottom?

I am fairly new to iOS programming so if someone could help me creating this layout, that would be highly appreciated.

This is what I mean by "bottom sheet":

screenshot of the collapsed bottom sheet in Maps

screenshot of the expanded bottom sheet in Maps

This question is related to ios swift uiview uiscrollview mapkit

The answer is


If you are looking for a SwiftUI 2.0 solution that uses View Struct, here it is:

https://github.com/kenfai/KavSoft-Tutorials-iOS/tree/main/MapsBottomSheet

enter image description here


I wrote my own library to achieve the intended behaviour in ios Maps app. It is a protocol oriented solution. So you don't need to inherit any base class instead create a sheet controller and configure as you wish. It also supports inner navigation/presentation with or without UINavigationController.

See below link for more details.

https://github.com/OfTheWolf/UBottomSheet

ubottom sheet


I released a library based on my answer below.

It mimics the Shortcuts application overlay. See this article for details.

The main component of the library is the OverlayContainerViewController. It defines an area where a view controller can be dragged up and down, hiding or revealing the content underneath it.

let contentController = MapsViewController()
let overlayController = SearchViewController()

let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
    contentController,
    overlayController
]

window?.rootViewController = containerController

Implement OverlayContainerViewControllerDelegate to specify the number of notches wished:

enum OverlayNotch: Int, CaseIterable {
    case minimum, medium, maximum
}

func numberOfNotches(in containerViewController: OverlayContainerViewController) -> Int {
    return OverlayNotch.allCases.count
}

func overlayContainerViewController(_ containerViewController: OverlayContainerViewController,
                                    heightForNotchAt index: Int,
                                    availableSpace: CGFloat) -> CGFloat {
    switch OverlayNotch.allCases[index] {
        case .maximum:
            return availableSpace * 3 / 4
        case .medium:
            return availableSpace / 2
        case .minimum:
            return availableSpace * 1 / 4
    }
}

SwiftUI (12/29/20)

A SwiftUI version of the library is now available.

Color.red.dynamicOverlay(Color.green)

Previous answer

I think there is a significant point that is not treated in the suggested solutions: the transition between the scroll and the translation.

Maps transition between the scroll and the translation

In Maps, as you may have noticed, when the tableView reaches contentOffset.y == 0, the bottom sheet either slides up or goes down.

The point is tricky because we can not simply enable/disable the scroll when our pan gesture begins the translation. It would stop the scroll until a new touch begins. This is the case in most of the proposed solutions here.

Here is my try to implement this motion.

Starting point: Maps App

To start our investigation, let's visualize the view hierarchy of Maps (start Maps on a simulator and select Debug > Attach to process by PID or Name > Maps in Xcode 9).

Maps debug view hierarchy

It doesn't tell how the motion works, but it helped me to understand the logic of it. You can play with the lldb and the view hierarchy debugger.

Our view controller stacks

Let's create a basic version of the Maps ViewController architecture.

We start with a BackgroundViewController (our map view):

class BackgroundViewController: UIViewController {
    override func loadView() {
        view = MKMapView()
    }
}

We put the tableView in a dedicated UIViewController:

class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    lazy var tableView = UITableView()

    override func loadView() {
        view = tableView
        tableView.dataSource = self
        tableView.delegate = self
    }

    [...]
}

Now, we need a VC to embed the overlay and manage its translation. To simplify the problem, we consider that it can translate the overlay from one static point OverlayPosition.maximum to another OverlayPosition.minimum.

For now it only has one public method to animate the position change and it has a transparent view:

enum OverlayPosition {
    case maximum, minimum
}

class OverlayContainerViewController: UIViewController {

    let overlayViewController: OverlayViewController
    var translatedViewHeightContraint = ...

    override func loadView() {
        view = UIView()
    }

    func moveOverlay(to position: OverlayPosition) {
        [...]
    }
}

Finally we need a ViewController to embed the all:

class StackViewController: UIViewController {

    private var viewControllers: [UIViewController]

    override func viewDidLoad() {
        super.viewDidLoad()
        viewControllers.forEach { gz_addChild($0, in: view) }
    }
}

In our AppDelegate, our startup sequence looks like:

let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])

The difficulty behind the overlay translation

Now, how to translate our overlay?

Most of the proposed solutions use a dedicated pan gesture recognizer, but we actually already have one : the pan gesture of the table view. Moreover, we need to keep the scroll and the translation synchronised and the UIScrollViewDelegate has all the events we need!

A naive implementation would use a second pan Gesture and try to reset the contentOffset of the table view when the translation occurs:

func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
    if isTranslating {
        tableView.contentOffset = .zero
    }
}

But it does not work. The tableView updates its contentOffset when its own pan gesture recognizer action triggers or when its displayLink callback is called. There is no chance that our recognizer triggers right after those to successfully override the contentOffset. Our only chance is either to take part of the layout phase (by overriding layoutSubviews of the scroll view calls at each frame of the scroll view) or to respond to the didScroll method of the delegate called each time the contentOffset is modified. Let's try this one.

The translation Implementation

We add a delegate to our OverlayVC to dispatch the scrollview's events to our translation handler, the OverlayContainerViewController :

protocol OverlayViewControllerDelegate: class {
    func scrollViewDidScroll(_ scrollView: UIScrollView)
    func scrollViewDidStopScrolling(_ scrollView: UIScrollView)
}

class OverlayViewController: UIViewController {

    [...]

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        delegate?.scrollViewDidScroll(scrollView)
    }

    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        delegate?.scrollViewDidStopScrolling(scrollView)
    }
}

In our container, we keep track of the translation using a enum:

enum OverlayInFlightPosition {
    case minimum
    case maximum
    case progressing
}

The current position calculation looks like :

private var overlayInFlightPosition: OverlayInFlightPosition {
    let height = translatedViewHeightContraint.constant
    if height == maximumHeight {
        return .maximum
    } else if height == minimumHeight {
        return .minimum
    } else {
        return .progressing
    }
}

We need 3 methods to handle the translation:

The first one tells us if we need to start the translation.

private func shouldTranslateView(following scrollView: UIScrollView) -> Bool {
    guard scrollView.isTracking else { return false }
    let offset = scrollView.contentOffset.y
    switch overlayInFlightPosition {
    case .maximum:
        return offset < 0
    case .minimum:
        return offset > 0
    case .progressing:
        return true
    }
}

The second one performs the translation. It uses the translation(in:) method of the scrollView's pan gesture.

private func translateView(following scrollView: UIScrollView) {
    scrollView.contentOffset = .zero
    let translation = translatedViewTargetHeight - scrollView.panGestureRecognizer.translation(in: view).y
    translatedViewHeightContraint.constant = max(
        Constant.minimumHeight,
        min(translation, Constant.maximumHeight)
    )
}

The third one animates the end of the translation when the user releases its finger. We calculate the position using the velocity & the current position of the view.

private func animateTranslationEnd() {
    let position: OverlayPosition =  // ... calculation based on the current overlay position & velocity
    moveOverlay(to: position)
}

Our overlay's delegate implementation simply looks like :

class OverlayContainerViewController: UIViewController {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard shouldTranslateView(following: scrollView) else { return }
        translateView(following: scrollView)
    }

    func scrollViewDidStopScrolling(_ scrollView: UIScrollView) {
        // prevent scroll animation when the translation animation ends
        scrollView.isEnabled = false
        scrollView.isEnabled = true
        animateTranslationEnd()
    }
}

Final problem: dispatching the overlay container's touches

The translation is now pretty efficient. But there is still a final problem: the touches are not delivered to our background view. They are all intercepted by the overlay container's view. We can not set isUserInteractionEnabled to false because it would also disable the interaction in our table view. The solution is the one used massively in the Maps app, PassThroughView:

class PassThroughView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let view = super.hitTest(point, with: event)
        if view == self {
            return nil
        }
        return view
    }
}

It removes itself from the responder chain.

In OverlayContainerViewController:

override func loadView() {
    view = PassThroughView()
}

Result

Here is the result:

Result

You can find the code here.

Please if you see any bugs, let me know ! Note that your implementation can of course use a second pan gesture, specially if you add a header in your overlay.

Update 23/08/18

We can replace scrollViewDidEndDragging with willEndScrollingWithVelocity rather than enabling/disabling the scroll when the user ends dragging:

func scrollView(_ scrollView: UIScrollView,
                willEndScrollingWithVelocity velocity: CGPoint,
                targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    switch overlayInFlightPosition {
    case .maximum:
        break
    case .minimum, .progressing:
        targetContentOffset.pointee = .zero
    }
    animateTranslationEnd(following: scrollView)
}

We can use a spring animation and allow user interaction while animating to make the motion flow better:

func moveOverlay(to position: OverlayPosition,
                 duration: TimeInterval,
                 velocity: CGPoint) {
    overlayPosition = position
    translatedViewHeightContraint.constant = translatedViewTargetHeight
    UIView.animate(
        withDuration: duration,
        delay: 0,
        usingSpringWithDamping: velocity.y == 0 ? 1 : 0.6,
        initialSpringVelocity: abs(velocity.y),
        options: [.allowUserInteraction],
        animations: {
            self.view.layoutIfNeeded()
    }, completion: nil)
}

Maybe you can try my answer https://github.com/AnYuan/AYPannel, inspired by Pulley. Smooth transition from moving the drawer to scrolling the list. I added a pan gesture on the container scroll view, and set shouldRecognizeSimultaneouslyWithGestureRecognizer to return YES. More detail in my github link above. Wish to help.


You can try my answer https://github.com/SCENEE/FloatingPanel. It provides a container view controller to display a "bottom sheet" interface.

It's easy to use and you don't mind any gesture recognizer handling! Also you can track a scroll view's(or the sibling view) in a bottom sheet if needed.

This is a simple example. Please note that you need to prepare a view controller to display your content in a bottom sheet.

import UIKit
import FloatingPanel

class ViewController: UIViewController {
    var fpc: FloatingPanelController!

    override func viewDidLoad() {
        super.viewDidLoad()

        fpc = FloatingPanelController()

        // Add "bottom sheet" in self.view.
        fpc.add(toParent: self)

        // Add a view controller to display your contents in "bottom sheet".
        let contentVC = ContentViewController()
        fpc.set(contentViewController: contentVC)

        // Track a scroll view in "bottom sheet" content if needed.
        fpc.track(scrollView: contentVC.tableView)    
    }
    ...
}

Here is another example code to display a bottom sheet to search a location like Apple Maps.

Sample App like Apple Maps


Try Pulley:

Pulley is an easy to use drawer library meant to imitate the drawer in iOS 10's Maps app. It exposes a simple API that allows you to use any UIViewController subclass as the drawer content or the primary content.

Pulley Preview

https://github.com/52inc/Pulley


I recently created a component called SwipeableView as subclass of UIView, written in Swift 5.1 . It support all 4 direction, has several customisation options and can animate and interpolate different attributes and items ( such as layout constraints, background/tint color, affine transform, alpha channel and view center, all of them demoed with the respective show case ). It also supports the swiping coordination with the inner scroll view if set or auto detected. Should be pretty easy and straightforward to be used ( I hope )

Link at https://github.com/LucaIaco/SwipeableView

proof of concept:

enter image description here

Hope it helps


Examples related to ios

Adding a UISegmentedControl to UITableView Crop image to specified size and picture location Undefined Symbols error when integrating Apptentive iOS SDK via Cocoapods Keep placeholder text in UITextField on input in IOS Accessing AppDelegate from framework? Autoresize View When SubViews are Added Warp \ bend effect on a UIView? Speech input for visually impaired users without the need to tap the screen make UITableViewCell selectable only while editing Xcode 12, building for iOS Simulator, but linking in object file built for iOS, for architecture arm64

Examples related to swift

Make a VStack fill the width of the screen in SwiftUI Xcode 10.2.1 Command PhaseScriptExecution failed with a nonzero exit code Command CompileSwift failed with a nonzero exit code in Xcode 10 Convert Json string to Json object in Swift 4 iOS Swift - Get the Current Local Time and Date Timestamp Xcode 9 Swift Language Version (SWIFT_VERSION) How do I use Safe Area Layout programmatically? How can I use String substring in Swift 4? 'substring(to:)' is deprecated: Please use String slicing subscript with a 'partial range from' operator Safe Area of Xcode 9 The use of Swift 3 @objc inference in Swift 4 mode is deprecated?

Examples related to uiview

How can I mimic the bottom sheet from the Maps app? UIView touch event in controller Swift addsubview and remove it Swift UIView background color opacity How do I change UIView Size? How to add constraints programmatically using Swift Load a UIView from nib in Swift Remove all constraints affecting a UIView How do I write a custom init for a UIView subclass in Swift? How to use UIVisualEffectView to Blur Image?

Examples related to uiscrollview

How can I mimic the bottom sheet from the Maps app? Is it possible for UIStackView to scroll? Set UITableView content inset permanently UIScrollView Scrollable Content Size Ambiguity How to use UIScrollView in Storyboard Get UIScrollView to scroll to the top How to scroll UITableView to specific position Disabling vertical scrolling in UIScrollView How do I auto size a UIScrollView to fit its content UIScrollView not scrolling

Examples related to mapkit

How can I mimic the bottom sheet from the Maps app? How to pass prepareForSegue: an object Setting the zoom level for a MKMapView