Wydałem bibliotekę na podstawie mojej odpowiedzi poniżej.
Naśladuje nakładkę aplikacji Skróty. Zobacz ten artykuł .
Głównym składnikiem biblioteki jest OverlayContainerViewController
. Definiuje obszar, w którym kontroler widoku można przeciągać w górę iw dół, ukrywając lub ujawniając zawartość pod nim.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Zaimplementuj, OverlayContainerViewControllerDelegate
aby określić liczbę żądanych wycięć:
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
}
}
Poprzednia odpowiedź
Myślę, że w sugerowanych rozwiązaniach istnieje znacząca kwestia: przejście między zwojem a tłumaczeniem.
W Mapach, jak być może zauważyłeś, kiedy tableView sięga contentOffset.y == 0
, dolny arkusz przesuwa się w górę lub spada.
Chodzi o to, ponieważ nie możemy po prostu włączyć / wyłączyć przewijania, gdy nasz gest przesuwania rozpoczyna tłumaczenie. Zatrzymuje przewijanie do momentu rozpoczęcia nowego dotknięcia. Tak jest w przypadku większości proponowanych tutaj rozwiązań.
Oto moja próba realizacji tego ruchu.
Punkt początkowy: aplikacja Maps
Aby rozpocząć dochodzenie, niech wizualizacji hierarchii wyświetl Maps (Mapy rozpocząć na symulatorze, a następnie wybierz Debug
> Attach to process by PID or Name
> Maps
w Xcode 9).
Nie mówi, jak działa ruch, ale pomogło mi zrozumieć jego logikę. Możesz grać z lldb i debugerem hierarchii widoków.
Nasz stos kontrolerów widoków
Stwórzmy podstawową wersję architektury Maps ViewController.
Zaczynamy od BackgroundViewController
(naszego widoku mapy):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
Umieszczamy tableView w dedykowanym UIViewController
:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Teraz potrzebujemy VC do osadzenia nakładki i zarządzania jej tłumaczeniem. Aby uprościć problem, uważamy, że może on przełożyć nakładkę z jednego punktu statycznego OverlayPosition.maximum
na innyOverlayPosition.minimum
.
Na razie ma tylko jedną publiczną metodę animacji zmiany pozycji i ma przejrzysty widok:
enum OverlayPosition {
case maximum, minimum
}
class OverlayContainerViewController: UIViewController {
let overlayViewController: OverlayViewController
var translatedViewHeightContraint = ...
override func loadView() {
view = UIView()
}
func moveOverlay(to position: OverlayPosition) {
[...]
}
}
Wreszcie potrzebujemy ViewController do osadzenia wszystkich:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
W naszej AppDelegate nasza sekwencja uruchamiania wygląda następująco:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
Trudność związana z tłumaczeniem nakładki
Jak przetłumaczyć naszą nakładkę?
Większość proponowanych rozwiązań wykorzystuje dedykowany moduł rozpoznawania gestów panoramy, ale tak naprawdę już go mamy: gest panoramy widoku tabeli. Ponadto musimy synchronizować zwój i tłumaczenie oraz UIScrollViewDelegate
mieć wszystkie potrzebne zdarzenia!
Naiwna implementacja użyłaby drugiego gestu panoramy i spróbowała zresetować contentOffset
widok tabeli, gdy nastąpi tłumaczenie:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Ale to nie działa. TableView aktualizuje go, contentOffset
gdy wyzwala własne działanie rozpoznawania gestów panoramy lub gdy wywoływane jest wywołanie zwrotne displayLink. Nie ma szans, że nasz program rozpoznający uruchomi się zaraz po udanym zastąpieniu contentOffset
. Naszą jedyną szansą jest albo wzięcie udziału w fazie układu (poprzez przesłonięcie layoutSubviews
wywołań widoku przewijania w każdej ramce widoku przewijania) lub odpowiedź na didScroll
metodę uczestnika wywoływaną przy każdej contentOffset
modyfikacji. Spróbujmy tego.
Implementacja tłumaczenia
Dodajemy do naszego delegata, aby OverlayVC
wysłać zdarzenia scrollview do naszego modułu obsługi tłumaczeń 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)
}
}
W naszym kontenerze śledzimy tłumaczenie za pomocą wyliczenia:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
Obecne obliczenie pozycji wygląda następująco:
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
Potrzebujemy 3 metod do obsługi tłumaczenia:
Pierwszy mówi nam, czy musimy rozpocząć tłumaczenie.
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
}
}
Drugi wykonuje tłumaczenie. Wykorzystuje translation(in:)
metodę gestu przewijania 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)
)
}
Trzeci animuje koniec tłumaczenia, gdy użytkownik puści palec. Pozycję obliczamy na podstawie prędkości i aktualnej pozycji widoku.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
Implementacja naszej delegacji po prostu wygląda następująco:
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()
}
}
Ostatni problem: wywołanie zmian w kontakcie nakładki
Tłumaczenie jest teraz całkiem wydajne. Ale wciąż jest ostatni problem: poprawki nie są dostarczane do naszego widoku tła. Wszystkie są przechwytywane przez widok pojemnika nakładki. Nie możemy ustawić isUserInteractionEnabled
się false
bo to również wyłączyć interakcje w naszym widoku tabeli. Rozwiązanie to jest szeroko stosowane w aplikacji Mapy 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
}
}
Usuwa się z łańcucha odpowiadającego.
W OverlayContainerViewController
:
override func loadView() {
view = PassThroughView()
}
Wynik
Oto wynik:
Możesz znaleźć kod tutaj .
Jeśli zauważysz jakieś błędy, daj mi znać! Pamiętaj, że twoja implementacja może oczywiście użyć drugiego gestu przesuwania, szczególnie jeśli dodasz nagłówek w nakładce.
Zaktualizuj 23/08/18
Możemy wymienić scrollViewDidEndDragging
ze
willEndScrollingWithVelocity
zamiast enabling
/ disabling
zwoju kiedy użytkownik przeciągając końce:
func scrollView(_ scrollView: UIScrollView,
willEndScrollingWithVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
switch overlayInFlightPosition {
case .maximum:
break
case .minimum, .progressing:
targetContentOffset.pointee = .zero
}
animateTranslationEnd(following: scrollView)
}
Możemy użyć animacji wiosennej i umożliwić interakcję użytkownika podczas animacji, aby usprawnić przepływ ruchu:
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)
}