Lancei uma biblioteca com base na minha resposta abaixo.
Ele imita a sobreposição do aplicativo Atalhos. Veja este artigo para detalhes.
O principal componente da biblioteca é o OverlayContainerViewController. Ele define uma área na qual um controlador de exibição pode ser arrastado para cima e para baixo, ocultando ou revelando o conteúdo abaixo dele.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Implemente OverlayContainerViewControllerDelegatepara especificar o número de entalhes desejados:
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
}
}
Resposta anterior
Eu acho que há um ponto significativo que não é tratado nas soluções sugeridas: a transição entre o pergaminho e a tradução.

No Maps, como você deve ter notado, quando o tableView chega contentOffset.y == 0, a folha inferior desliza para cima ou para baixo.
A questão é complicada, porque não podemos simplesmente ativar / desativar a rolagem quando nosso gesto de panorâmica inicia a tradução. Pararia o pergaminho até um novo toque começar. Este é o caso na maioria das soluções propostas aqui.
Aqui está minha tentativa de implementar esta moção.
Ponto de partida: aplicativo do Google Maps
Para iniciar nossa investigação, vamos visualizar a vista hierarquia de Mapas (iniciar Mapas em um simulador e selecione Debug> Attach to process by PID or Name> Mapsno Xcode 9).

Não diz como o movimento funciona, mas me ajudou a entender a lógica dele. Você pode jogar com o lldb e o depurador da hierarquia de visualizações.
Nossas pilhas de controladores de exibição
Vamos criar uma versão básica da arquitetura do Maps ViewController.
Começamos com um BackgroundViewController(nossa visualização de mapa):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
Colocamos o tableView em um dedicado UIViewController:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Agora, precisamos de um VC para incorporar a sobreposição e gerenciar sua tradução. Para simplificar o problema, consideramos que ele pode converter a sobreposição de um ponto estático OverlayPosition.maximumpara outro OverlayPosition.minimum.
Por enquanto, ele possui apenas um método público para animar a mudança de posição e possui uma visão transparente:
enum OverlayPosition {
case maximum, minimum
}
class OverlayContainerViewController: UIViewController {
let overlayViewController: OverlayViewController
var translatedViewHeightContraint = ...
override func loadView() {
view = UIView()
}
func moveOverlay(to position: OverlayPosition) {
[...]
}
}
Finalmente, precisamos de um ViewController para incorporar tudo:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
Em nosso AppDelegate, nossa sequência de inicialização se parece com:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
A dificuldade por trás da tradução da sobreposição
Agora, como traduzir nossa sobreposição?
A maioria das soluções propostas usa um reconhecedor de gesto de panorâmica dedicado, mas na verdade já temos um: o gesto de panorâmica da exibição da tabela. Além disso, precisamos manter o pergaminho e a tradução sincronizados e UIScrollViewDelegatetodos os eventos de que precisamos!
Uma implementação ingênua usaria um segundo gesto de panorâmica e tentaria redefinir a contentOffsetexibição da tabela quando a tradução ocorrer:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Mas não funciona. O tableView atualiza contentOffsetquando sua própria ação do reconhecedor de gestos de movimento é acionada ou quando seu retorno de chamada displayLink é chamado. Não há nenhuma chance de que nosso reconhecedor seja acionado logo após aqueles para substituir com êxito o contentOffset. Nossa única chance é participar da fase de layout (substituindo layoutSubviewsas chamadas de exibição de rolagem em cada quadro da exibição de rolagem) ou responder ao didScrollmétodo do delegado chamado toda vez que a contentOffsetmodificação for modificada. Vamos tentar este.
A tradução Implementation
Adicionamos um delegado ao nosso OverlayVCpara despachar os eventos do scrollview ao nosso manipulador de traduções, o 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)
}
}
Em nosso contêiner, acompanhamos a tradução usando uma enumeração:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
O cálculo da posição atual é semelhante a:
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
Precisamos de 3 métodos para lidar com a tradução:
O primeiro diz-nos se precisamos iniciar a tradução.
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
}
}
O segundo realiza a tradução. Ele usa o translation(in:)método do gesto de panorâmica do 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)
)
}
O terceiro anima o final da tradução quando o usuário solta o dedo. Calculamos a posição usando a velocidade e a posição atual da vista.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
A implementação delegada da nossa sobreposição simplesmente se parece com:
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()
}
}
Problema final: despachar os toques do contêiner de sobreposição
A tradução agora é bastante eficiente. Mas ainda há um problema final: os retoques não são entregues à nossa visão de segundo plano. Todos eles são interceptados pela visão do contêiner de sobreposição. Não podemos definir isUserInteractionEnabledcomo, falseporque isso também desativaria a interação em nossa visualização de tabela. A solução é aquela usada maciçamente no aplicativo 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
}
}
Ele se remove da cadeia de respostas.
Em OverlayContainerViewController:
override func loadView() {
view = PassThroughView()
}
Resultado
Aqui está o resultado:

Você pode encontrar o código aqui .
Por favor, se você encontrar algum erro, me avise! Observe que sua implementação pode, é claro, usar um segundo gesto de panorâmica, especialmente se você adicionar um cabeçalho em sua sobreposição.
Atualização 23/08/18
Podemos substituir scrollViewDidEndDraggingpor, em
willEndScrollingWithVelocityvez de enabling/ disablingo rolo, quando o usuário terminar de arrastar:
func scrollView(_ scrollView: UIScrollView,
willEndScrollingWithVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
switch overlayInFlightPosition {
case .maximum:
break
case .minimum, .progressing:
targetContentOffset.pointee = .zero
}
animateTranslationEnd(following: scrollView)
}
Podemos usar uma animação de primavera e permitir a interação do usuário durante a animação para melhorar o fluxo do movimento:
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)
}