Я випустив бібліотеку, грунтуючись на моїй відповіді нижче.
Це імітує накладку програми ярликів. Детальну інформацію див. У цій статті .
Основним компонентом бібліотеки є OverlayContainerViewController
. Він визначає область, де контролер перегляду можна перетягувати вгору та вниз, приховуючи або розкриваючи вміст під ним.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Виконати, OverlayContainerViewControllerDelegate
щоб вказати кількість бажаних насічок:
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
}
}
Попередня відповідь
Я думаю, що є важливий момент, який не розглядається у запропонованих рішеннях: перехід між прокруткою та перекладом.
На Картах, як ви могли помітити, коли таблиця ViewView досягає contentOffset.y == 0
, нижній аркуш або ковзає вгору, або знижується.
Справа хитра, тому що ми не можемо просто включити / відключити прокрутку, коли наш жест пан починає переклад. Це зупинить прокрутку, поки не почнеться новий штрих. Це стосується більшості запропонованих тут рішень.
Ось моя спроба здійснити цей рух.
Початкова точка: Додаток Maps
Для того, щоб почати наше дослідження, давайте візуалізувати ієрархію виду Карт (початок карти на тренажері і виберіть Debug
> Attach to process by PID or Name
> Maps
в Xcode 9).
Це не говорить про те, як працює рух, але це допомогло мені зрозуміти його логіку. Ви можете грати з налагоджувачем lldb та ієрархією перегляду.
Наш контролер подає стеки
Створимо базову версію архітектури Maps ViewController.
Почнемо з BackgroundViewController
(наш вид карти):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
Ми ставимо таблицю ViewView виділеним UIViewController
:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Тепер нам потрібен VC, щоб вбудувати накладку та керувати її перекладом. Для спрощення проблеми ми вважаємо, що вона може перекласти накладку з однієї статичної точки OverlayPosition.maximum
в іншу OverlayPosition.minimum
.
Наразі у нього є лише один публічний метод анімації зміни позиції, і він має прозорий вигляд:
enum OverlayPosition {
case maximum, minimum
}
class OverlayContainerViewController: UIViewController {
let overlayViewController: OverlayViewController
var translatedViewHeightContraint = ...
override func loadView() {
view = UIView()
}
func moveOverlay(to position: OverlayPosition) {
[...]
}
}
Нарешті нам потрібен ViewController, щоб вставити всі:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
У нашому AppDelegate наша послідовність запуску виглядає так:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
Складність перекладу накладання
Тепер, як перекласти нашу накладку?
Більшість запропонованих рішень використовують виділений розпізнавальник жестів панорами, але насправді вже є одне: жест панорами подання таблиці. Крім того, нам потрібно тримати прокрутку та переклад синхронізованими, а також UIScrollViewDelegate
усі необхідні нам події!
Наївна реалізація використовує другий жест пан і намагається скинути contentOffset
перегляд таблиці, коли відбувається переклад:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Але це не працює. Таблиця TableView оновлює її, contentOffset
коли спрацьовує власна дія розпізнавача жестів, або коли викликається зворотний виклик displayLink. Немає шансів, що наш розпізнавальник спрацьовує відразу після тих, хто успішно переможе їх contentOffset
. Наш єдиний шанс - або взяти участь у фазі компонування (шляхом переосмислення layoutSubviews
викликів подання прокрутки в кожному кадрі подання прокрутки), або відповісти на didScroll
метод делегата, що викликається щоразу, коли contentOffset
модифікація змінюється. Спробуємо цю.
Реалізація перекладу
Ми додаємо делегата до нашого, OverlayVC
щоб відправити події прокрутки до нашого обробника перекладів 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)
}
}
У нашому контейнері ми відстежуємо переклад за допомогою enum:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
Поточний розрахунок позиції виглядає так:
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
Нам потрібні 3 методи для обробки перекладу:
Перший повідомляє нам, чи потрібно починати переклад.
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
}
}
Другий виконує переклад. Він використовує translation(in:)
метод жесту 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)
)
}
Третя анімація закінчує переклад, коли користувач відпускає палець. Ми обчислюємо позицію, використовуючи швидкість & поточне положення подання.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
Реалізація нашого делегата просто виглядає так:
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()
}
}
Остаточна проблема: відправлення дотиків контейнера, що накладається
Переклад зараз досить ефективний. Але є ще остаточна проблема: штрихи не передаються нашому фоновому режиму. Усі вони перехоплені видом контейнера, що накладається. Ми не можемо встановити isUserInteractionEnabled
це, false
оскільки це також відключить взаємодію в нашому перегляді таблиці. Рішення - це те, що масово використовується в додатку Карти 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
}
}
Він видаляється з ланцюга реагування.
В OverlayContainerViewController
:
override func loadView() {
view = PassThroughView()
}
Результат
Ось результат:
Ви можете знайти код тут .
Якщо ви бачите будь-які помилки, повідомте мене! Зауважте, що ваша реалізація може, звичайно, використовувати другий жест переміщення, особливо якщо ви додаєте заголовок у накладку.
Оновлення 23.08.18
Ми можемо замінити scrollViewDidEndDragging
з
willEndScrollingWithVelocity
чим enabling
/ disabling
свиткою , коли кінці користувача перетягування:
func scrollView(_ scrollView: UIScrollView,
willEndScrollingWithVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
switch overlayInFlightPosition {
case .maximum:
break
case .minimum, .progressing:
targetContentOffset.pointee = .zero
}
animateTranslationEnd(following: scrollView)
}
Ми можемо використовувати весняну анімацію та дозволити взаємодію з користувачем під час анімації, щоб покращити рух руху:
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)
}