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 OverlayContainerViewControllerDelegate
para 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
> Maps
no 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.maximum
para 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 UIScrollViewDelegate
todos os eventos de que precisamos!
Uma implementação ingênua usaria um segundo gesto de panorâmica e tentaria redefinir a contentOffset
exibição da tabela quando a tradução ocorrer:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Mas não funciona. O tableView atualiza contentOffset
quando 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 layoutSubviews
as chamadas de exibição de rolagem em cada quadro da exibição de rolagem) ou responder ao didScroll
método do delegado chamado toda vez que a contentOffset
modificação for modificada. Vamos tentar este.
A tradução Implementation
Adicionamos um delegado ao nosso OverlayVC
para 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 isUserInteractionEnabled
como, false
porque 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 scrollViewDidEndDragging
por, em
willEndScrollingWithVelocity
vez de enabling
/ disabling
o 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)
}