Custom paging в iOS

Ilya Lobanov
Яндекс.Карты Mobile
6 min readJan 18, 2019

Привет! В этой статье я расскажу, как реализовать пэйджинацию скролла с разным размером страниц, а в следующей — вытягивающуюся карточку как, например, в Apple Maps, Stocks или Voice Memos.

Apple Maps, Stocks and Voice Memos

В iOS SDK есть возможность включить пэйджинацию для UIScrollView с помощью параметра isPagingEnabled с фиксированным размером страниц, равным bounds.size. И если вы захотите использовать разный размер страниц, то придется реализовать механику пейджинации вручную. Существует несколько решений этой задачи.

Например, можно использовать вспомогательный невидимый UIScrollView с включенной пэйджинацией, и трансформировать его фиксированные страницы в страницы заданного UIScrollView. В качестве фиксированного размера можно использовать усредненный размер от заданных страниц.

Недостатком этого способа является то, что скролл становится нелинейным и перестает следовать напрямую за пальцем.

Другим способом является самостоятельный поиск необходимой страницы и подскролл к ней в тот момент, когда жест был завершен в методе делегата scrollViewWillEndDragging:

И задача сводится к поиску этой самой функции getPageOffset. С одной стороны, можно просто использовать ближайшую страницу к текущему scrollView.contentOffset. Но в этом случае поведение будет отличаться от стандартного, потому что скорость жеста не будет учитываться, и пейджинация будет выглядеть резкой и дерганой. Можно использовать ближайшую к targetContentOffset. Это значение учитывает скорость и зависит от параметров scrollView и, в частности, от decelerationRate. Чтобы targetContentOffset не возвращал слишком больших значений, необходимо выбрать UIScrollView.DecelerationRate.fast.

Но у этого способа есть несколько недостатков: во-первых, нельзя точно регулировать decelerationRate, так как UIScrollView не позволяет присваивать что-то, кроме значений .normal и .fast; а во-вторых, поиск targetContentOffset скрыт в реализации UIScrollView, и мы не можем использовать этот способ при использовании UIPanGestureRecognizer напрямую.

В интернете представлены и другие эвристики по поводу того, как именно нужно искать нужную страницу, но в итоге наиболее удачное и полное решение, которое бы подошло не только для UIScrollView, было найдено в презентации Designing Fluid Interfaces с конференции WWDC18. Эта презентация находится в секции Design, и она посвящена жестам и анимации, и тому, как сделать ее интуитивной и естественной.

41:05 / 803 Designing Fluid Interfaces / WWDC18

И, в частности, на примере FaceTime было показано, как при создании анимации учитывать всю информацию, полученную из жестов.

В FaceTime можно перебросить собственное изображение (PiP — picture in picture) в один из углов экрана. Чтобы анимация полета изображения выглядела естественной, необходимо учесть не только позицию, в которой был отпущен палец, но и скорость жеста. И в презентации был предложен следующий алгоритм для нахождения финальной позиции:

  1. Находим проекцию изображения — точку, в которую бы прилетело изображение, учитывая начальную позицию, скорость и замедление (например, UIScrollView.DecelerationRate.normal).
  2. Находим ближайший угол от найденной проекции, а не от точки, в которой был отпущен палец.
  3. Переносим изображение в найденный угол.

В презентации был не только продемонстрирован алгоритм, но и формула для нахождения проекции:

initialVelocity необходимо поделить на 1000.0, потому что UIPanGestureRecognizer и UIScrollView используют разные единицы измерения: скорость у UIPanGestureRecognizer выражена в pt/s (поинт в секунду), а скорость UIScrollView выражена в pt/ms (поинт в миллисекунду), и соответсвенно замедление UIScrollView.DecelerationRate обозначает то, во сколько раз уменьшится скорость за одну миллисекунду (.normal равно 0.998, а .fast равно 0.99).

И в итоге нахождение необходимой точки выглядит так:

Чтобы понять, подходит ли этот алгоритм для нашей задачи или нет, давайте попробуем реализовать его на простом примере.

Проверка идеи

В качестве примера я буду использовать UITableView с ячейками произвольной высоты, а в качестве страниц — сами ячейки. Ключевые точки, к которым будет подскролливаться таблица, будут называться опорными (anchors):

Теперь реализуем саму механику пэйджинации. В момент, когда мы отпустили палец и завершили жест, нам необходимо подскроллить tableView к нужной опорной точке. Для этого воспользуемся методом scrollViewWillEndDragging(_:withVelocity:targetContentOffset:) делегата UIScrollViewDelegate, так как он вызывается в нужный момент, и в нем есть скорость жеста, необходимая нам для корректного нахождения опорной точки.

Но перед этим реализуем все необходимые вспомогательные функции. В первую очередь нам понадобится функция для нахождения проекции. Возьмем ее из презентации, но не напрямую, а в виде метода для протокола FloatingPoint(чтобы мы могли использовать этот метод для Float, CGFloat и так далее), где self использовался бы для начального значения, которое необходимо спроецировать:

И для удобства использования с contentOffset добавим расширение для CGPoint:

Кроме этого, нам понадобится функция для нахождения ближайшей опорной точки, а также максимальное значение опорной точки, чтобы не выйти за пределы contentSize:

И теперь у нас есть все, чтобы реализовать scrollViewWillEndDragging:

Но поведение получилось совсем не таким, как при стандартной пэйджинации (при включенном isPagingEnabled): иногда подскролливание слишком резкое, иногда слишком медленное. Выставляя targetContentOffset, мы не можем гарантировать время и скорость скролла. Поэтому нам придется создать анимацию вручную.

В SDK есть необходимая spring-анимация, учитывающая скорость жеста:

UIView.animateWithDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion:

Проблема в том, что нам нужно передать в эту функцию время анимации, которое зависит от скорости и замедления, и вычислить которое непросто. А если мы передадим в качестве параметра 0 или отрицательное число, то анимации не произойдет совсем. Поэтому, чтобы не высчитывать время вручную, пойдем более простым путем и воспользуемся POPSpringAnimation из пода facebook/pop.

Новая реализация scrollViewWillEndDragging будет такой:

Теперь поведение стало больше похожим на стандартную пейджинацию. При необходимости можно скорректировать поведение, изменяя параметр замедления decelerationRate, влияющий на определение проекции, и параметры springBounciness и springSpeed у анимации POPSpringAnimation.

Обратите внимание на строку:

targetContentOffset.pointee = scrollView.contentOffset

Это необходимо сделать, чтобы остановить системную доводку скролла.

Код с примером лежит на GitHub: https://github.com/super-ultra/CustomPaging

Обобщение для всех типов UIScrollView

Теперь попробуем обобщить пэйджинацию на примере UICollectionView с горизонтальным скроллом и ячейками произвольного размера.

Вынесем функционал с пейджинацией в отдельный класс PagingView. Начнем с того, что нам потребуется конструктор с UIScrollView и поле acnhors.

И тут возникает проблема: мы не можем реализовать делегат переданного UIScrollView, потому что UIScrollViewDelegate является и UICollectionViewDelegate. Передавая UICollectionView в качестве контента, мы не ожидаем того, что поле delegate будет перезаписано. Решить эту проблему можно разными способами:

  1. Явно передавать необходимые события о жестах в PagingView.
  2. Создать внутри PagingView дополнительный UIScrollView, подписаться на него и синхронизировать его contentOffset с переданным UIScrollView. У этого способа есть недостатки: перехватывая жесты и выставляя вручную contentOffset, мы нарушим работу скролл-индикатора и некоторых связанных с жестами методов UICollectionViewDelegate у переданного UIScrollView.
  3. Создать прокси-делегат, который бы реализовывал только необходимые нам методы, а все остальные перенаправлял в исходный делегат.

Выберем первый вариант, так как он самый простой. В интерфейс PagingView добавятся два дополнительных метода для обработки жестов, которые соответствуют методам UIScrollViewDelegate:

  • в contentViewWillEndDragging реализуем подскроллирование к контрольным точкам
  • в contentViewWillBeginDragging реализуем прерывание анимации

Но перед тем, как реализовывать эти методы, добавим вычисление опорных точек по аналогии с предыдущим примером.

Анимацию реализуем с помощью facebook/pop.

И наконец мы можем реализовать необходимые методы обработки жестов:

В итоге мы получили PagingView, который может добавить пэйджинацию любому UIScrollView. В данном примере мы обобщили поиск проекции contentOffset для произвольного UIScrollView.

Код с примером лежит в том же репозитории на GitHub: https://github.com/super-ultra/CustomPaging

Вы можете использовать этот небольшой проект для поиска необходимых параметров пейджинации.

В этой статье мы рассмотрели, как можно реализовать пейджинацию с разным размером страниц. В следующей статье я расскажу, как с помощью этих знаний реализовать вытягивающуюся карточку.

--

--