Ho pubblicato una libreria basata sulla mia risposta di seguito.
Imita la sovrapposizione dell'applicazione Scorciatoie. Vedi questo articolo per i dettagli.
Il componente principale della libreria è il OverlayContainerViewController
. Definisce un'area in cui un controller di visualizzazione può essere trascinato su e giù, nascondendo o rivelando il contenuto sottostante.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Attuare OverlayContainerViewControllerDelegate
per specificare il numero di tacche desiderate:
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
}
}
Risposta precedente
Penso che ci sia un punto significativo che non viene trattato nelle soluzioni suggerite: la transizione tra lo scroll e la traduzione.
In Maps, come avrai notato, quando tableView raggiunge contentOffset.y == 0
, il foglio inferiore scorre verso l'alto o verso il basso.
Il punto è complicato perché non possiamo semplicemente abilitare / disabilitare lo scorrimento quando il nostro gesto di panoramica inizia la traduzione. Arresterebbe la pergamena fino all'inizio di un nuovo tocco. Questo è il caso nella maggior parte delle soluzioni proposte qui.
Ecco il mio tentativo di attuare questo movimento.
Punto di partenza: app Maps
Per iniziare la nostra indagine, cerchiamo di visualizzare la vista gerarchia di Maps (Mappe avviare su un simulatore e selezionare Debug
> Attach to process by PID or Name
> Maps
in Xcode 9).
Non dice come funziona il movimento, ma mi ha aiutato a comprenderne la logica. Puoi giocare con lldb e il debugger della gerarchia di visualizzazione.
Il nostro controller di visualizzazione si accumula
Creiamo una versione di base dell'architettura Maps ViewController.
Iniziamo con un BackgroundViewController
(la nostra vista sulla mappa):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
Mettiamo il tableView in un apposito UIViewController
:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Ora, abbiamo bisogno di un VC per incorporare l'overlay e gestirne la traduzione. Per semplificare il problema, riteniamo che possa tradurre l'overlay da un punto statico OverlayPosition.maximum
a un altro OverlayPosition.minimum
.
Per ora ha solo un metodo pubblico per animare il cambio di posizione e ha una vista trasparente:
enum OverlayPosition {
case maximum, minimum
}
class OverlayContainerViewController: UIViewController {
let overlayViewController: OverlayViewController
var translatedViewHeightContraint = ...
override func loadView() {
view = UIView()
}
func moveOverlay(to position: OverlayPosition) {
[...]
}
}
Finalmente abbiamo bisogno di un ViewController per incorporare il tutto:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
Nel nostro AppDelegate, la nostra sequenza di avvio è simile a:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
La difficoltà dietro la traduzione overlay
Ora, come tradurre il nostro overlay?
La maggior parte delle soluzioni proposte utilizza un riconoscitore di gesti di panoramica dedicato, ma ne abbiamo già uno: il gesto di panoramica della vista tabella. Inoltre, dobbiamo mantenere sincronizzati lo scorrimento e la traduzione e UIScrollViewDelegate
tutti gli eventi di cui abbiamo bisogno!
Un'implementazione ingenua userebbe un secondo gesto Pan e proverebbe a reimpostare la contentOffset
vista della tabella quando si verifica la traduzione:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Ma non funziona TableView si aggiorna contentOffset
quando si attiva la propria azione di riconoscimento dei gesti di pan o quando viene chiamato il callback displayLink. Non vi è alcuna possibilità che il nostro riconoscitore si attivi subito dopo quelli per sovrascrivere correttamente contentOffset
. La nostra unica possibilità è quella di prendere parte alla fase del layout (sovrascrivendo layoutSubviews
le chiamate della vista di scorrimento in ciascun frame della vista di scorrimento) o di rispondere al didScroll
metodo del delegato chiamato ogni volta che contentOffset
viene modificato. Proviamo questo.
L'implementazione della traduzione
Aggiungiamo un delegato al nostro OverlayVC
per inviare gli eventi dello scrollview al nostro gestore di traduzione, il 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)
}
}
Nel nostro contenitore, teniamo traccia della traduzione usando un enum:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
Il calcolo della posizione corrente è simile a:
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
Abbiamo bisogno di 3 metodi per gestire la traduzione:
Il primo ci dice se dobbiamo iniziare la traduzione.
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
}
}
Il secondo esegue la traduzione. Utilizza il translation(in:)
metodo del gesto panoramica di 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)
)
}
Il terzo anima la fine della traduzione quando l'utente rilascia il dito. Calcoliamo la posizione utilizzando la velocità e la posizione corrente della vista.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
L'implementazione delegata del nostro overlay si presenta semplicemente come:
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 finale: invio dei tocchi del contenitore di overlay
La traduzione è ora abbastanza efficiente. Ma c'è ancora un problema finale: i tocchi non vengono consegnati alla nostra vista di sfondo. Sono tutti intercettati dalla vista del contenitore di overlay. Non possiamo impostarlo isUserInteractionEnabled
su false
perché disabiliterebbe anche l'interazione nella nostra vista tabella. La soluzione è quella utilizzata in modo massiccio nell'app 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
}
}
Si rimuove dalla catena del risponditore.
In OverlayContainerViewController
:
override func loadView() {
view = PassThroughView()
}
Risultato
Ecco il risultato:
Puoi trovare il codice qui .
Per favore, se vedi qualche bug, fammi sapere! Nota che l'implementazione può ovviamente usare un secondo gesto di panoramica, specialmente se aggiungi un'intestazione nel tuo overlay.
Aggiornamento 23/08/18
Possiamo sostituire scrollViewDidEndDragging
con
willEndScrollingWithVelocity
anziché enabling
/ disabling
lo scorrimento quando l'utente termina il trascinamento:
func scrollView(_ scrollView: UIScrollView,
willEndScrollingWithVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
switch overlayInFlightPosition {
case .maximum:
break
case .minimum, .progressing:
targetContentOffset.pointee = .zero
}
animateTranslationEnd(following: scrollView)
}
Possiamo usare un'animazione di primavera e consentire l'interazione dell'utente durante l'animazione per migliorare il flusso di 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)
}