Lancé una biblioteca basada en mi respuesta a continuación.
Imita la superposición de la aplicación Atajos. Vea este artículo para más detalles.
El componente principal de la biblioteca es el OverlayContainerViewController
. Define un área donde se puede arrastrar un controlador de vista hacia arriba y hacia abajo, ocultando o revelando el contenido debajo de él.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Implemente OverlayContainerViewControllerDelegate
para especificar el número de muescas deseadas:
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
}
}
Respuesta anterior
Creo que hay un punto significativo que no se trata en las soluciones sugeridas: la transición entre el desplazamiento y la traducción.
En Maps, como habrás notado, cuando alcanza TableView contentOffset.y == 0
, la hoja inferior se desliza hacia arriba o hacia abajo.
El punto es complicado porque no podemos simplemente habilitar / deshabilitar el desplazamiento cuando nuestro gesto panorámico comienza la traducción. Pararía el desplazamiento hasta que comience un nuevo toque. Este es el caso en la mayoría de las soluciones propuestas aquí.
Aquí está mi intento de implementar esta moción.
Punto de partida: aplicación de mapas
Para iniciar nuestra investigación, vamos a visualizar la vista de jerarquía de Mapas (iniciar Mapas en un simulador y seleccione Debug
> Attach to process by PID or Name
> Maps
en Xcode 9).
No dice cómo funciona el movimiento, pero me ayudó a entender la lógica del mismo. Puedes jugar con el lldb y el depurador de jerarquía de vistas.
Nuestras pilas de controladores de vista
Creemos una versión básica de la arquitectura Maps ViewController.
Comenzamos con un BackgroundViewController
(nuestra vista de mapa):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
Ponemos el tableView en un dedicado UIViewController
:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Ahora, necesitamos un VC para incrustar la superposición y administrar su traducción. Para simplificar el problema, consideramos que puede traducir la superposición de un punto estático OverlayPosition.maximum
a otro OverlayPosition.minimum
.
Por ahora solo tiene un método público para animar el cambio de posición y tiene una vista 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 necesitamos un ViewController para incrustar todo:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
En nuestro AppDelegate, nuestra secuencia de inicio se ve así:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
La dificultad detrás de la traducción superpuesta
Ahora, ¿cómo traducir nuestra superposición?
La mayoría de las soluciones propuestas utilizan un reconocedor de gestos panorámicos dedicado, pero en realidad ya tenemos uno: el gesto panorámico de la vista de tabla. Además, necesitamos mantener sincronizados el desplazamiento y la traducción, ¡y UIScrollViewDelegate
tiene todos los eventos que necesitamos!
Una implementación ingenua usaría un segundo gesto panorámico e intentaría restablecer la contentOffset
vista de la tabla cuando se produzca la traducción:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Pero no funciona. TableView actualiza contentOffset
cuando se activa su propia acción de reconocimiento de gestos panorámicos o cuando se llama a su devolución de llamada displayLink. No hay posibilidad de que nuestro reconocedor se active inmediatamente después de aquellos para anular con éxito el contentOffset
. Nuestra única posibilidad es participar en la fase de diseño (anulando layoutSubviews
las llamadas de la vista de desplazamiento en cada cuadro de la vista de desplazamiento) o responder al didScroll
método del delegado llamado cada vez que contentOffset
se modifica. Probemos con este.
La implementación de la traducción
OverlayVC
Agregamos un delegado a nuestro para enviar los eventos de la vista de desplazamiento a nuestro controlador de traducción, el 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)
}
}
En nuestro contenedor, hacemos un seguimiento de la traducción usando una enumeración:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
El cálculo de la posición actual se ve así:
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
Necesitamos 3 métodos para manejar la traducción:
El primero nos dice si necesitamos comenzar la traducción.
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
}
}
El segundo realiza la traducción. Utiliza el translation(in:)
método del gesto panorámico de 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)
)
}
El tercero anima el final de la traducción cuando el usuario suelta su dedo. Calculamos la posición usando la velocidad y la posición actual de la vista.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
La implementación de delegado de nuestra superposición simplemente se ve así:
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 los toques del contenedor de superposición
La traducción ahora es bastante eficiente. Pero todavía hay un problema final: los toques no se entregan a nuestra vista de fondo. Todos son interceptados por la vista del contenedor de superposición. No podemos establecer isUserInteractionEnabled
que false
, ya que también podría desactivar la interacción en nuestra opinión mesa. La solución es la que se usa masivamente en la aplicación Mapas 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
}
}
Se elimina de la cadena de respuesta.
En OverlayContainerViewController
:
override func loadView() {
view = PassThroughView()
}
Resultado
Aquí está el resultado:
Puedes encontrar el código aquí .
Por favor, si ve algún error, ¡avíseme! Tenga en cuenta que su implementación puede, por supuesto, usar un segundo gesto de desplazamiento, especialmente si agrega un encabezado en su superposición.
Actualización 23/08/18
Podemos reemplazar scrollViewDidEndDragging
con en
willEndScrollingWithVelocity
lugar de enabling
/ disabling
el desplazamiento cuando el usuario termina de arrastrar:
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 una animación de primavera y permitir la interacción del usuario mientras animamos para que el movimiento fluya mejor:
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)
}