December 4, 2018 Patch
I released a library based on this solution https://github.com/applidium/ADOverlayContainer .
It simulates an overlay of the Shortcuts application. See this article for details.
The main component of the library is OverlayContainerViewController . It defines the area where the view controller can be dragged up and down to hide or reveal the content below it.
let contentController = MapsViewController() let overlayController = SearchViewController() let containerController = OverlayContainerViewController() containerController.delegate = self containerController.viewControllers = [ contentController, overlayController ] window?.rootViewController = containerController
OverlayContainerViewControllerDelegate to specify the OverlayContainerViewControllerDelegate number of OverlayContainerViewControllerDelegate :
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 } }
Previous answer
I think that there is an important point that is not considered in the proposed solutions: the transition between scrolling and translation.

In Maps, as you may have noticed, when the tableView reaches contentOffset.y == 0 , the bottom sheet either slides up or down.
The point is tricky because we can't just turn on / off scrolling when our pan gesture starts translating. This will stop scrolling until a new touch begins. This applies to most of the solutions offered here.
Here is my attempt to realize this movement.
Starting point: Maps application
To begin our exploration, let us visualize the hierarchy of Maps views (run Maps on the simulator and choose Debug > Attach to process by PID or Name > Maps in Xcode 9).

This does not say how the movement works, but it helped me understand the logic of this. You can play with lldb and the view hierarchy debugger.
Our ViewController Stacks
Let's create a basic version of the Maps ViewController architecture.
We will start with the 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 task, we believe that it can transfer the overlay from one static point OverlayPosition.maximum to another OverlayPosition.minimum .
At the moment, he has only one public method for animating position changes and 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 everything:
class StackViewController: UIViewController { private var viewControllers: [UIViewController] override func viewDidLoad() { super.viewDidLoad() viewControllers.forEach { gz_addChild($0, in: view) } } }
In our AppDelegate, our launch sequence is as follows:
let overlay = OverlayViewController() let containerViewController = OverlayContainerViewController(overlayViewController: overlay) let backgroundViewController = BackgroundViewController() window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
Overlay translation complexity
Now, how to translate our overlay?
Most of the proposed solutions use a special pan gesture recognizer, but we already have one: a panning gesture of the table view. Moreover, we need to synchronize scrolling and translation, and UIScrollViewDelegate has all the necessary events!
A naive implementation will use the second Gesture pan and try to reset the table contentOffset table view when performing the translation:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) { if isTranslating { tableView.contentOffset = .zero } }
But that does not work. The TableView updates its contentOffset when the contentOffset native action of the pan gesture recognizer or when its displayLink callback is called. There is no chance that our resolver will work right after them to successfully override the contentOffset . Our only chance is to either take part in the layout phase (by overriding the layoutSubviews the scroll view in each frame of the scroll view), or respond to the didScroll method of the delegate called each time the contentOffset changes. Let's try it.
Translation Implementation
We add a delegate to our OverlayVC to send scrollview events to our translation handler, 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 track the transfer using the transfer:
enum OverlayInFlightPosition { case minimum case maximum case progressing }
The calculation of the current position looks like this:
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 process the translation:
The first tells us if we need to start a 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 is translating. It uses the translation(in:) panView gesture gesture scrollView.
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 animates the end of the translation when the user releases his finger. We calculate the position using the speed and current position of the view.
private func animateTranslationEnd() { let position: OverlayPosition =
Our overlay delegate implementation looks just like this:
class OverlayContainerViewController: UIViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { guard shouldTranslateView(following: scrollView) else { return } translateView(following: scrollView) } func scrollViewDidStopScrolling(_ scrollView: UIScrollView) {
Last issue: sending overlay container touches
Translation is now quite effective. But there is another problem: strokes are not transmitted in the background. They are all intercepted by the presentation of the overlay container. We cannot set isUserInteractionEnabled to false because it would also disable the interaction in our table view. A solution that is widely used in the PassThroughView Maps 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 } }
He removes himself from the respondent’s chain.
In OverlayContainerViewController :
override func loadView() { view = PassThroughView() }
Result
Here is the result:

You can find the code here .
Please, if you see any errors let me know! Note that your implementation may, of course, use a second pan gesture, especially if you add a title to the overlay.
Update 23/08/18
We can replace scrollViewDidEndDragging with willEndScrollingWithVelocity instead of enable / disable scrolling when the user finishes 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 spring animation and allow user interaction during the animation to improve movement:
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) }