Custom paging в iOS
Привет! В этой статье я расскажу, как реализовать пэйджинацию скролла с разным размером страниц, а в следующей — вытягивающуюся карточку как, например, в Apple Maps, Stocks или 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, и она посвящена жестам и анимации, и тому, как сделать ее интуитивной и естественной.
И, в частности, на примере FaceTime было показано, как при создании анимации учитывать всю информацию, полученную из жестов.
В FaceTime можно перебросить собственное изображение (PiP — picture in picture) в один из углов экрана. Чтобы анимация полета изображения выглядела естественной, необходимо учесть не только позицию, в которой был отпущен палец, но и скорость жеста. И в презентации был предложен следующий алгоритм для нахождения финальной позиции:
- Находим проекцию изображения — точку, в которую бы прилетело изображение, учитывая начальную позицию, скорость и замедление (например,
UIScrollView.DecelerationRate.normal
). - Находим ближайший угол от найденной проекции, а не от точки, в которой был отпущен палец.
- Переносим изображение в найденный угол.
В презентации был не только продемонстрирован алгоритм, но и формула для нахождения проекции:
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
будет перезаписано. Решить эту проблему можно разными способами:
- Явно передавать необходимые события о жестах в
PagingView
. - Создать внутри
PagingView
дополнительныйUIScrollView
, подписаться на него и синхронизировать егоcontentOffset
с переданнымUIScrollView
. У этого способа есть недостатки: перехватывая жесты и выставляя вручнуюcontentOffset
, мы нарушим работу скролл-индикатора и некоторых связанных с жестами методовUICollectionViewDelegate
у переданногоUIScrollView
. - Создать прокси-делегат, который бы реализовывал только необходимые нам методы, а все остальные перенаправлял в исходный делегат.
Выберем первый вариант, так как он самый простой. В интерфейс PagingView
добавятся два дополнительных метода для обработки жестов, которые соответствуют методам UIScrollViewDelegate
:
- в
contentViewWillEndDragging
реализуем подскроллирование к контрольным точкам - в
contentViewWillBeginDragging
реализуем прерывание анимации
Но перед тем, как реализовывать эти методы, добавим вычисление опорных точек по аналогии с предыдущим примером.
Анимацию реализуем с помощью facebook/pop.
И наконец мы можем реализовать необходимые методы обработки жестов:
В итоге мы получили PagingView
, который может добавить пэйджинацию любому UIScrollView
. В данном примере мы обобщили поиск проекции contentOffset
для произвольного UIScrollView
.
Код с примером лежит в том же репозитории на GitHub: https://github.com/super-ultra/CustomPaging
Вы можете использовать этот небольшой проект для поиска необходимых параметров пейджинации.
В этой статье мы рассмотрели, как можно реализовать пейджинацию с разным размером страниц. В следующей статье я расскажу, как с помощью этих знаний реализовать вытягивающуюся карточку.