Я выпустил библиотеку на основе моего ответа ниже.
Он имитирует наложение приложения «Ярлыки». Смотрите эту статью для деталей.
Основным компонентом библиотеки является 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
}
}
Предыдущий ответ
Я думаю, что есть важный момент, который не рассматривается в предлагаемых решениях: переход между прокруткой и переводом.
В Картах, как вы могли заметить, когда таблица достигает contentOffset.y == 0
, нижний лист либо сдвигается вверх, либо опускается.
Суть хитрая, потому что мы не можем просто включить / отключить прокрутку, когда наш жест панорамирования начинает перевод. Это остановит прокрутку, пока не начнется новое касание. Это имеет место в большинстве предлагаемых здесь решений.
Вот моя попытка реализовать это движение.
Начальная точка: приложение «Карты»
Для того, чтобы начать наше исследование, давайте визуализировать иерархию вида Карт (начало карты на тренажере и выберите Debug
> Attach to process by PID or Name
> Maps
в Xcode 9).
Это не говорит, как работает движение, но оно помогло мне понять логику этого. Вы можете поиграть с lldb и отладчиком иерархии представлений.
Наш вид контроллеров стеков
Давайте создадим базовую версию архитектуры Maps ViewController.
Мы начнем с BackgroundViewController
(наш вид карты):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
Мы помещаем tableView в выделенный 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
все необходимые события!
Наивная реализация будет использовать второй панорамирование Gesture и попытаться сбросить contentOffset
представление таблицы при выполнении перевода:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Но это не работает. TableView обновляет его, contentOffset
когда срабатывает его собственное действие распознавателя жестов панорамирования или когда вызывается его обратный вызов displayLink. Нет никаких шансов, что наш распознаватель сработает сразу после того, как они успешно переопределят contentOffset
. Наш единственный шанс - либо принять участие в фазе макета (путем переопределения layoutSubviews
вызовов представления прокрутки в каждом кадре представления прокрутки), либо ответить на didScroll
метод делегата, вызываемого каждый раз, когда contentOffset
изменяется. Давайте попробуем это.
Реализация перевода
Мы добавляем делегата к нашему OverlayVC
для отправки событий scrollview в наш обработчик перевода 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 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)
}