Как работает UIScrollView
На конференции Mobius 2019 Moscow я подробно рассказывал о том, как работает UIScrollView, как реализованы замедление, баунс, spring animation и rubber band effect и как, применяя эти знания для собственных UI-компонентов, можно добиться естественной анимации и отзывчивого интерфейса в приложениях.
Этот пост основан на моем докладе.
- Тестовый SimpleScrollView
- Замедление (Deceleration)
- Реализация замедления
- Анимация пружины (Spring Animation)
- Spring Animation в iOS SDK
- Реализация анимации пружины
- Rubber Band Effect
- Реализация Rubber Band Effect
- Примеры использования
- Заключение
В Яндекс.Метро и на iOS, и на Android используется общая библиотека MetroKit, написанная на C++. В частности в MetroKit есть SchemeView
для отображения схемы метро. И перед нами встала задача реализовать скролл для этой схемы. В качестве референса мы выбрали UIScrollView
, потому что нам его поведение показалось наиболее естественным, наиболее подходящим.
Процесс разработки мы поделили на три механики, на три этапа реализации поведения UIScrollView
. Первый из них — это замедление (или deceleration):
Второй из них — анимация пружины (spring animation) для реализации баунса (bounce) на границах:
Третья механика — это так называемый rubber band effect для реализации сопротивления скролла на границах:
Я подробно расскажу о каждой из этих механик, какие формулы для этого используются, и о примерах, когда можно применять эти формулы.
Тестовый пример
Рассмотрим тестовый SimpleScrollView
, на примере которого мы будем рассматривать все механики, все формулы, и который мы будем постепенно улучшать.
По аналогии с UIScrollView
у него будет contentView
, contentSize
и contentOffset
:
Для реализации жестов будем использовать UIPanGestureRecognizer
и функцию handlePanRecognizer
.
У SimpleScrollView
будет одно из двух состояний:
.default
, когда ничего не происходит;.dragging
, когда мы скроллим.
Реализация handlePanRecognizer
будет выглядеть следующим образом:
- когда
UIPanGestureRecognizer
переходит в состояние.began
, мы выставляем состояние.dragging
. - когда переходит в состояние
.changed
, мы вычисляемtranslation
и соответствующим образом меняемcontentOffset
у нашегоScrollView
. При этом мы вызываем функциюclampOffset
, чтобы не выходить за границы контента. - когда в состояние
.ended
, мы просто возвращаемSimpleScrollView
в состояние.default
.
Объявим вспомогательное свойство contentOffsetBounds
, которое определяет границы contentOffset
, используя текущий contentSize
. А также функция clampOffset
, которая ограничивает contentOffset
с помощью этих границ.
И вот у нас готова простая реализация SimpleScrollView
.
Скролл уже как-то работает. У нас пока нет анимации, нет инерции движения. Все это мы будем постепенно добавлять в течение презентации, улучшая наш SimpleScrollView
.
Замедление (Deceleration)
Начнем с замедления.
В SDK нет информации о том, как реализовано замедление. У UIScrollView
есть DecelerationRate
, который может быть .normal
, либо .fast
, и с помощью него можно регулировать скорость замедления скролла. В документации говорится, что DecelerationRate
определяет степень замедления скролла.
На презентации Designing Fluid Interfaces на WWDC 2018 была показана формула для поиска конечной позиции скролла — точки, в которой скролл остановится после того, как вы отпустите палец.
Эту формулу нельзя использовать для реализации замедления, но мы можем использовать её как референс для проверки наших дальнейших вычислений. Функция принимает на вход начальную скорость жеста и коэффициент замедления и возвращает конечную точку, в которой бы скролл остановился после того, как мы отпустили палец.
Скорость замедления
Попробуем предположить, как может работать замедление и что может вообще обозначать DecelerationRate
. В документации говорится, что это коэффициент, который определяет степень замедления скролла. Мы можем предположить, что этот коэффициент указывает на то, как сильно изменится скорость скролла спустя одну миллисекунду (все величины UIScrollView
выражены в миллисекундах в отличие от UIPanGestureRecognizer
).
Если в момент отпускания пальца у нас была скорость v₀ и мы выбрали DecelerationRate.fast
, то
- спустя 1 миллисекунду наша скорость будет 0.99 от v₀;
- спустя 2 миллисекунды у нас будет скорость 0.99^2 от v₀;
- спустя k секунд у нас будет скорость 0.99^1000k от v₀.
В итоге мы получили формулу для скорости замедляющегося движения:
Уравнение движения
Формулу скорости нельзя использовать для реализации замедления. Нам нужно найти уравнение движения: зависимость координаты от времени x(t). И нам как раз поможет формула скорости. Для того, чтобы найти уравнение движения, нам достаточно проинтегрировать уравнение скорости:
Затем подставим вместо v(x) нашу формулу скорости, проинтегрируем и получим:
Уравнение конечной точки
Теперь мы можем найти формулу для конечной точки скролла, сравнить её с формулой Apple и проверить наши рассуждения. Для этого нужно устремить время t в бесконечность. Так как у нас d меньше единицы, d^1000t сходится к нулю, то мы получаем:
А теперь сравним найденную формулу с формулой Apple. Выпишем ее в тех же обозначениях:
И мы видим, что формулы немного отличаются в правых частях:
Однако если мы посмотрим, как раскладывается натуральный логарифм в ряд Тейлора в окрестности единицы, то мы увидим, что на самом деле формула от Apple — приближение нашей формулы:
Если мы построим графики этих функций, то увидим, что при приближении к единице он практически совпадают:
А я напомню, что стандартные значения DecelerationRate
очень близки к единице, а значит такая оптимизация со стороны Apple вполне корректна.
Время замедления
Осталось найти время замедления, чтобы создавать анимации. Для того чтобы найти конечную точку, мы устремляли время в бесконечность. Но мы не можем использовать бесконечное время для анимации.
Если построить график уравнения движения, то можно увидеть, что функция бесконечно приближается к конечной точке X. Но в тоже время, начиная с какого-то момента времени, функция настолько близко приближается к конечной точке X, что движение становится незаметным.
Поэтому мы можем переформулировать нашу задачу следующим образом: найдем такое время T, после которого функция достаточно близко приблизиться к конечной точке X (на какое-то небольшое расстояние ε). На практике в качестве такого расстояния можно брать, например, полпикселя.
Найдём такое T, при котором расстояние до конечной точки будет равно ε:
Подставим вместо x и X наши формулы и получим формулу для времени замедляющегося движения:
И теперь у нас есть вся информация для того, чтобы самостоятельно реализовать замедление. Именно эти формулы мы использовали для схемы метро.
Теперь улучшим наш SimpleScrollView
, добавив на него замедление.
Реализация замедления
Для начала опишем структуру DecelerationTimingParameters
, которая будет содержать всю необходимую информацию для создания анимации в момент отпуская пальца:
initialValue
— это начальныйcontentOffset
: точка, в которой мы отпустили наш палец;initialVelocity
— скорость жеста;decelerationRate
— коэффициент замедления;threshold
— порог для того, чтобы найти время замедления.
С помощью наших формул мы найдем точку, в которой скролл остановится:
Время замедления:
И уравнение движения:
В качестве анимации мы будем использовать TimerAnimation
, который будет вызывать переданный колбэк animations
60 раз в секунду, когда будет обновляться экран (или 120 раз в секунду на iPad Pro):
Блок animations
мы будем использовать для того, чтобы с помощью текущего времени вызывать уравнение движения и соответствующим образом менять contentOffset
. Реализацию TimerAnimation
можно найти в репозитории.
И теперь усовершенствуем функцию обработки жестов handlePanRecognizer
:
Замедление должно начаться в момент отпускания пальца. Поэтому, когда приходит состояние .ended
, мы будем вызывать функцию startDeceleration
, передавая в нее скорость жеста:
И реализация startDeceleration
будет следующей:
- выбираем коэффициента замедления
DecelerationRate.normal
и порогthreshold
полпикселя; - инициализируем
DecelerationTimingParameters
; - запускаем анимацию, передавая туда время анимации, а в блоке
animations
, используя текущее время, будем вызывать уравнение движения и обновлятьcontentOffset
.
Вот что у нас получилось:
На этом про замедление все, и теперь поговорим об анимации пружины.
Анимация пружины (Spring Animation)
Я напомню, что анимацию пружины мы использовали для того, чтобы реализовать bounce на границах.
Про анимацию пружины в отличие от замедления информации намного больше, она основана на затухающих колебаниях пружины. Поэтому понятия везде одинаковые: и в iOS SDK, и в Android SDK, и в статьях, которые описывают поведение пружины.
Чаще всего пружина параметризуется
- массой (mass, m)
- жесткостью (stiffness, k)
- затуханием (damping, d)
Уравнения движения пружины выглядит следующим образом. Оно также зависит от массы, жесткости и затухания:
Иногда вместо затухания можно встретить коэффициент затухания (damping ratio, ζ). Они связаны следующей формулой:
Коэффициент затухания (Damping Ratio)
Наиболее интересным параметром является коэффициент затухания. Именно по нему можно определить, как будет выглядеть анимация.
Чем ближе коэффициент затухания к 0, тем колебания более ярко выражены. И чем ближе они к 1, тем они слабее. Когда коэффициент равен 1, то скачков нет совсем, амплитуда просто угасает.
В зависимости от коэффициента затухающие колебания разделяют на три вида:
- 0 < ζ < 1 — слабое затухание (underdamped). В этом случае происходят скачки около состояния покоя. Чем ближе коэффициент к нулю, тем скачки более ярко выражены.
- ζ = 1 — граница апериодичности (critically damped). В этом случае скачков нет совсем. Возможен кратковременный рост амплитуды, но колебания со временем экспоненциально затухают.
- ζ > 1 — апериодичность (overdamped). В этом случае колебания просто экспоненциально затухают. Этот случай используется редко, поэтому мы его рассматривать не будем.
Уравнение движения
Как мы уже знаем, уравнение движения в общем виде выглядит следующим образом:
На практике его использовать сложно, поэтому нам нужно найти решение этого уравнения в виде x(t). Также нам нужно найти время колебаний для того, чтобы создавать анимации. Решение этого уравнения для разных коэффициентов затухания разное, поэтому мы рассмотрим каждый из случаев по отдельности.
Слабое затухание (0 < ζ < 1)
В случае, когда у нас коэффициент меньше единицы (слабое затухание) решение уравнения выглядит следующим образом:
- ω’ — собственная частота системы (damped natural frequency);
- β — вспомогательный параметр:
- коэффициенты C₁ и C₂ находятся с помощью начальных условий: начальное положение точки равно x₀, а начальная скорость — v₀:
Наличие синуса и косинуса в левой части нам говорит о том, что у колебаний есть какой-то период, а экспонента — о том, что колебания экспоненциально затухают.
Визуально это выглядит следующим образом: амплитуда экспоненциально затухает со временем с некоторой периодичностью из-за синуса и косинуса:
Граница апериодичности (ζ = 1)
Теперь рассмотрим границу апериодичности. Уравнение движения выглядит следующим образом:
- β — тот же вспомогательный параметр;
- коэффициенты C₁ и C₂ отличаются от предыдущего случая, но так же находятся с помощью начальных условий:
График уже будет таким:
Тут уже нет никаких скачков, амплитуда просто экспоненциально угасает.
Время колебаний
Теперь нам нужно найти время колебаний. Здесь время колебаний бесконечное, как и в случае с замедлением, но бесконечное время мы использовать не можем. Но можно заметить, что начиная с какого-то момента колебания настолько малы, что их будет невозможно разглядеть:
Поэтому мы опять переформулируем задачу: мы найдем такое время T, после которого амплитуда будет меньше какого-то маленького значения ε (например, полпикселя).
И используя все те же самые рассуждения, что и в случае с замедлением, сначала найдем время для слабого затухания (0 < ζ < 1):
И таким же образом находим время для границы апериодичности (ζ = 1):
В итоге мы нашли формулы для реализации анимации пружины.
Но у вас мог возникнуть вопрос: зачем нужно подробно знать, как работает анимация пружины, если в iOS SDK уже есть ее реализация? Понятно, что в Метро у нас не было возможности использовать iOS SDK, так как библиотека MetroKit мультиплатформенная, и поэтому мы были вынуждены разобраться, как работает анимация. Но непонятно, зачем это нужно для SimpleScrollView
, непонятно, зачем это нужно iOS-разработчикам.
Spring Animation в iOS SDK
В iOS SDK действительно есть несколько способов создать анимацию пружины, и самый простой из них — это UIView.animate.
UIView.animate
UIView.animate
параметризуется коэффициентом затухания (dampingRatio
) и начальной скоростью (velocity
). Но особенностью этой функции является то, что нам также необходимо передать время затухания (duration
). Но посчитать мы его не можем, потому что нам не известны другие параметры пружины: нам не известны ни масса, ни жесткость, а так же нам неизвестно начальное смещение пружины.
Эта функция решает немного другую задачу: мы задаем поведение пружины с помощью коэффициента затухания и желаемое время анимации, а уже в реализации функции все остальное будет автоматически посчитано.
Поэтому функцию UIView.animate
можно использовать для простых анимаций с определенным поведением и определенным временем без привязки к координатам. Но для ScrollView нам такая функция не подойдет.
CASpringAnimation
Другим способом является CASpringAnimation:
CASpringAnimation
хоть и нужна для анимирования свойств CALayer
, и пользоваться ей было бы как минимум неудобно, но давайте про нее тоже поговорим. CASpringAnimation
уже параметризуется массой, жесткостью и затуханием, но не коэффициентом затухания. Как мы говорили ранее, именно коэффициент затухания наибольшим образом определяет поведение пружины. Еcли не хотим колебаний, то выбираем 1, если хотим сильные колебания, то выбираем значения близкие к 0. Но такого параметра тут нет.
Но после того, как мы узнали формулы, мы можем расширить класс CASpringAnimation
и добавить конструктор, принимающий коэффициент затухания:
Также здесь нужно передать время затухания, как и в случае с UIView.animate
, но в отличие от UIView.animate
тут есть вспомогательное поле settlingDuration
, которое вернуло бы нам предполагаемое время затухания исходя из настроек CASpringAnimation
:
Проблема тут в том, что settlingDuration
никак не учитывает смещение пружины, он никак не учитывает fromValue
и toValue
. Что бы вы ни задали в fromValue
и toValue
, settlingDuration
будет всегда одинаковым. Это сделано для универсальности, потому что fromValue
и toValue
могут быть чем угодно: это могут быть координаты или цвет — и тут уже непонятно, как посчитать смещение пружины.
И на самом деле здесь происходит следующее. Вы наверняка знаете, что при вызове UIView.animate
, в качестве параметра можно передать кривую анимации: например, linear
, easeIn
, easeOut
или easeInOut
. И эта кривая будет указывать на то, как будет меняться прогресс анимации со временем от 0 до 1.
И то же самое касается анимации пружины в iOS SDK. Уравнение пружины как раз используется для кривой анимации, чтобы поменять прогресс с 0 до 1. И поэтому смещение пружины всегда одинаковое и оно равно 1, и значения fromValue
и toValue
игнорируются.
UISpringTimingParameters
Третьим способом создания анимации пружины, начиная с iOS 10, является UISpringTimingParameters. UISpringTimingParameters
можно создать двумя способами:
И интересно тут то, что поведение UISpringTimingParameters
будет разным, в зависимости от того, какой конструктор вы используете.
Если создать UISpringTimingParameters
с помощью конструктора с массой, жесткостью и затуханием, то время анимации будет вычислено автоматически. А переданное время будет проигнорировано:
Это так же сделано для универсальности, так как в блоке animations
вы можете делать все, что угодно. И поэтому смещение тут задано для прогресса от 0 до 1. Но даже если вы сами знаете смещение, и знаете как вычислить время анимации, выставить вручную его не получится.
В случае, когда вы создадите с UISpringTimingParameters помощью dampingRatio, то тогда время вычисляться автоматически не будет, и вам нужно будет передать его:
Но тут уже проблема в том, что чтобы его посчитать информации у нас не достаточно как в случае с UIView.animate
: тут нет ни массы, ни жесткости, ни смещения пружины.
Spring Animation c нулевым смещением
Общей проблемой всех анимаций пружины в iOS является то, что они не работают с нулевым смещением (fromValue == toValue
). То есть если вы захотите сделать такую анимацию, толкнув шарик с места, то у вас ничего не получится:
И такой код не будет делать ничего, несмотря на то, что вы передали скорость:
И даже если добавить присваивание фрэйма, ничего не изменится:
А как мы увидим дальше, для реализации bounce для SimpleScrollView
понадобится анимация с нулевым смещением. В итоге нам анимации пружины из iOS SDK не подходят, и поэтому мы будем использовать свою реализацию.
Реализация анимации пружины
Для параметризации анимации мы будем использовать структуру SpringTimingParameters
:
spring
— параметры пружины;displacement
— начальное смещение;initialVelocity
— начальная скорость;threshold
— порог в поинтах для того, чтобы найти время затухания.
С помощью наших формул мы найдем время затухания и уравнение движения:
Теперь рассмотрим, как будет работать bounce и как будет реализован переход из замедления в анимацию пружины:
- определим границу контента Scroll View;
- в момент отпускания пальца внутри контента, мы знаем текущий
contentOffset
и скорость жеста; - с помощью них мы можем найти точку, в которой бы скролл остановился (
destination
); - если мы понимаем, что мы столкнемся с границей, то мы найдем точку пересечения с границей, и посчитаем сколько времени потребуется до столкновения и скорость скролла в этот момент;
- и затем мы запустим анимацию замедления, а в момент столкновения с границей, запустим анимацию пружины со смещением 0, и со скоростью, которая будет у скролла в момент столкновения.
Но перед тем, как реализовать этот алгоритм, расширим DecelerationTimingParameters
, добавив две вспомогательные функции: duration:to:
, чтобы найти время до пересечения с границей, и velocity:at:
, чтобы найти скорость в момент столкновения с границей:
Вот наша функция handlePanRecognizer
, которая обрабатывает жесты:
И теперь нам надо усовершенствовать функцию startDeceleration
, которая вызывается в момент отпускания пальца:
- инициализируем
DecelerationTimingParameters
; - находим точку, в которой скролл остановится;
- находим точку пересечения с границей контента;
- если мы понимаем, что мы столкнемся с границей, находим время, которое нам нужно до столкновения с этой границей;
- сначала запускаем анимацию замедления, меняя соответствующим образом
contentOffset
; - в момент столкновения с границей, посчитаем текущую скорость и вызовем функцию
bounce
.
Функция bounce
будет реализована следующим образом:
- в начале посчитаем положение покоя пружины, которая будет на границе контента;
- вычислим начальное смещение пружины (в нашем случае тут будет 0, потому что мы и так уже находимся на границе);
- выберем порог полпикселя;
- выберем параметры пружины с коэффициентом затухания 1, чтобы у нас не было колебаний около границы контента;
- инициализируем
SpringTimingParameters
; - запустим анимацию, передав туда время затухания. В блоке animations, используя текущее время анимации, мы будем вызывать уравнение движения пружины, и будем соответствующим образом менять
contentOffset
.
Вот что у нас получилось:
Мы отпускаем палец внутри контента, у нас скролл летит к границе. В тот момент, когда мы сталкиваемся с границей, мы запускаем анимацию. Из-за того, что у нас в замедлении и в уравнении движения используется одно и то же понятие скорости, стык этих двух анимаций получился плавным. Нам не пришлось для этого думать о том, как нам нужно настроить замедление, как нам нужно настроить анимацию пружины.
Rubber Band Effect
Теперь поговрим о rubber band effect. Напомню, что этот эффект добавляет сопротивление скролла на границах, причем не только на границах по координатам, но и для скейла.
Про rubber band effect в документации нет никакой информации. Поэтому мы попытались предположить, как он работает. Для начала мы построили график того, как contentOffset
меняется при смещении пальца. И пытались определить, какая функция могла бы приближать его лучше всего.
Мы пытались как-то связать этот эффект с уравнением пружиной. Но что бы мы ни делали, у нас никак не получалось приблизиться к такому графику.
В итоге мы решили просто приблизить такой график многочленом. Для этого можно выбрать несколько точек, и найти такой многочлен, который будет проходить максимально близко к этим точкам. Можно сделать это вручную, а можно просто зайти на WolframAlpha, ввести наши точки
quadratic fit {0, 0} {500, 205} {1000, 328} {1500, 409}
и получить многочлен второй степени:
Полученный многочлен неплохо приближает искомую функцию:
И мы бы на этом остановились, если бы в какой-то момент мы не узнали, что такой эффект вообще называется rubber band effect.
И как только мы об этом узнали, мы довольно быстро наткнулись на твит, с некоторой формулой:
- x в правой части — это смещение пальца;
- c — это какой-то коэффициент;
- d — это dimension, который равен размеру scroll view.
Мы построили график для этой функции с коэффициентом 0.55
и увидели, что эта функция идеально приближает наш график:
Давайте подробнее рассмотрим, как работает эта формула. Выпишем её в более привычных обозначениях:
Построим несколько графиков для разных коэффициентов и в качестве d мы выберем 812 (высота iPhone X):
Из графиков видно, что коэффициент d влияет на жесткость rubber band: чем меньше этот коэффициент, тем поведение эффекта более жесткое, тем больше нам нужно прикладывать усилий для того, чтобы сместить contentOffset
.
Из функции мы видим, что при стремлении x к бесконечности наша функция стремится к d, но при этом она всегда меньше d:
Эта формула довольно удобная, потому что с помощью нее можно задать максимальное смещение с помощью параметра d. С помощью такой формулы можно добиться того, что контент Scroll View будет всегда на экране.
Это единственная формула, которая используется для rubber band effect, поэтому давайте усовершенствуем наш SimpleScrollView.
Реализация Rubber Band Effect
Объявим функцию rubberBandClamp
, которая будет обычным повторением функции из твита:
Затем для удобства добавим функцию rubberBandClamp
, передавая туда limits
:
Эта функция будет работать следующим образом:
- если
x
попадает вlimits
, то сx
ничего не будет происходить; - как только
x
начнет выходить за эти границы, то будет применятьсяrubberBandClamp
.
Эта функция понадобится для Scroll View, потому что применять rubberBandClamp
нужно только тогда, когда мы будем выходить за границу контента.
Расширим эту функцию до двухмерного случая и создадим структуру RubberBand
:
- в качестве
bounds
будем использовать границу Scroll View (границуcontentOffset
); - в качестве
dimensions
— размер Scroll View; - метод
clamp
будет работать следующим образом: когда переданная точкаpoint
будет попадать в границы, то ничего не будет происходить, а как только мы начнем выходить за эти границы, тогда мы будем применять rubber band effect.
Вот наша функция handlePanRecognizer
, которая обрабатывает жесты:
И сейчас нас интересует функция clampOffset
:
Сейчас функция просто обрезает переданный contentOffset
и блокирует движение на границах.
Чтобы добавить rubber band effect, нужно создать структуру RubberBand
и передать туда в качестве dimensions
размер Scroll View, а в качестве bounds
границы contentOffset
, и затем вызывать функцию clamp
.
Но это еще не все. Раньше при отпускании пальца мы сразу запускали анимацию замедления. Но теперь скролл может выйти за границы контента. Когда он выходит за границы, уже не нужно запускать анимацию замедления. Вместо этого нужно просто вернуться к ближайшей границе, то есть просто вызвать функцию bounce
.
Вынесем этот функционал в функцию completeGesture
, которую мы будем вызывать при обработке состояния .ended
в функции handlePanRecognizer
:
А реализация самой функции будет достаточной простой:
- когда
contentOffset
находится внутри границы, то есть когда мы отпустили палец внутри границы, тогда просто вызывается анимация замедленияstartDeceleration
; - если мы отпустили палец за пределами границы, то мы, не запуская замедления, сразу запускаем
bounce
и подскролливаемся к ближайшей границе.
Визуально это выглядит следующим образом:
Мы рассмотрели все три механики Scroll View, реализовали полностью поведение скролла. Подобным же образом реализован скролл для Яндекс.Метро, но есть небольшие, но очень важные отличия:
⚠️ в примере для SimpleScrollView
запускается анимация для contentOffset
целиком для простоты, но корректнее запускать анимацию отдельно для каждой из компонент: отдельно для x
, отдельно для y
и отдельно для scale
;
⚠️ в SimpleScrollView
функция bounce
вызывается дважды: при столкновении с границей и при отпускании пальца за пределами контента. Для более корректного поведения, я рекомендую реализовать два этих случая по отдельности с разным параметром жесткости (массу и коэффициент затухания можно оставить одинаковыми).
Примеры
Реализация скролла довольно редкая задача, которой мы занялись только потому, что у нас не было возможности использовать SDK, и поэтому хочу рассказать немного о других примерах, в которых можно использовать эти же самые функции.
Карточка. Переход между состояниями
Поговорим о вытягивающейся карточке, которая используется у нас в Яндекс.Метро, которая используется в некоторых приложениях Apple (Maps, Stocks, Find My и т. д.).
Тут есть три состояния: среднее, развернутое и свернутое. Карточка никогда не останавливается в промежуточном состоянии. Как только вы отпускаете палец, она должно подскролиться к одной из опорных точек. И основной задачей здесь является то, как найти нужную опорную точку в момент отпускания пальца.
Можно просто брать ближайшую. Но тогда будет сложно свайпнуть карточку в верхнее состояние, потому что нужно будет отпустить палец вверху:
И при таком подходе будет казаться, что карточка куда-то улетает из-под пальца. Поэтому нам нужно как-то учитывать скорость жеста.
Существуют разные способы реализации этого алгоритма, но наиболее удачным нам показалось решение, которое было показано на WWDC, на презентации Designing Fluid Interfaces. Там был немного другой пример, но его можно спроецировать на наш случай. Идея заключается в следующем.
- в момент отпускания пальца известна позиция карточки и скорость жеста;
- с помощью них находим точку, в которую бы карточка прилетела (проекцию карточки);
- находим ближайшую опорную точку именно к этой проекции;
- подскролливаем к найденной опорной точке.
Чтобы найти проекцию карточки, можно использовать формулу для поиска конечной точки замедляющегося движения:
Опишем функцию project
, в которую мы будем передавать текущее положение карточки (value
), начальную скорость жеста (velocity
) и коэффициент замедления (decelerationRate
). Функция будет возвращать проекцию карточки, то есть точку, в которую бы карточка прилетела:
Алгоритм будет выглядеть следующим образом.
- cначала выбираем
DecelerationRate.normal
; - находим проекцию (
projection
); - находим ближайшую опорную точку (
acnhor
); - анимировано меняем положение карточки.
Но анимация у нас получилась не совсем удачная. Из-за того что мы используем постоянное время, анимация выглядит неестественно, потому что она не учитывает ни скорости жеста, ни расстояния, которое нужно пролететь до опорной точки. И тут нам может помочь анимация пружины. Она будет работать следующим образом:
- в момент отпускания пальца известна позиция карточки и скорость жеста;
- с помощью них находим точку, в которую бы карточка прилетела (проекцию карточки);
- находим ближайшую опорную точку именно к этой проекции;
- создаем анимацию пружины, у которой состоянием покоя будет опорная точка, а начальным смещением — расстояние от опорной точки до карточки.
Учитывая эти изменения, функция completeGesture будет следующей:
И это будет выглядеть следующим образом:
Так как мы использовали коэффициент единицу, то скачков около опорной точки нет. Если мы выберем коэффициент близкий к нулю, то мы можем добиться вот такого эффекта:
Вообще такой подход с поиском проекций можно использовать и для двумерного случая:
Это пример летающего окошка (Picture in Picture или PiP), которое используется в FaceTime или Skype. Именно на таком примере был продемонстрирован алгоритм на WWDC. Тут так же есть фиксированный набор состояний и анимированный переход между ними, учитывающий жест.
Также с помощью поиска проекции и анимации пружины можно реализовать необычную пейджинацию: с разным размером пейджей или с разным поведением пейджинации, как это сделано в новом App Store.
Тут пейджинация используется с колебаниями. В стандартной пейджинации UIScrollView
размеры пейджей всегда одинаковые, а поведением пейджинации управлять нельзя.
Карточка. Rubber Band Effect
Мы можем усовершенствовать нашу карточку, добавив на неё rubber band effect. У вас мог возникнуть вопрос, зачем вообще нужно добавлять этот эффект для карточки? В iOS всё, что связанно со скроллом, так или иначе имеет rubber band effect. И если его нет, если скролл фризится, то кажется, что приложение зависло. Добавив такой эффект на карточку, мы сделаем ее более отзывчивой.
Но формулу для rubber band effect тут нужно использовать немного по-другому:
- в качестве
x
возьмем координату карточки, где нулевым положением будет верхняя границей контента; - в качестве
dimension
возьмем расстояние до верхней границы экрана, тем самым гарантируя, что мы не сможем заскроллить карточку куда-то далеко наверх, за пределы экрана.
То же самое касается свернутого случая:
- в качестве
x
возьмем положение карточки, где нулевым положением будет нижняя границей контента; - а в качестве
dimension
возьмем расстояние до нижней позиции экрана, тем самым гарантируя, что мы не сможем заскроллить карточку за пределы экрана и вообще скрыть её.
Схема метро. Scale
Rubber band effect можно использовать для ограничений любых величин: координат, скейла, цвета, поворота и т.д. В Метро мы добавили этот эффект для скейла:
Заключение
Если вам нужно сделать плавный переход между состояниями, причем этими состояниями могут быть не только координаты (это может быть и цвет, и поворот, и альфа), то вы можете использовать поиск проекций и анимацию пружины.
Если вы хотите сделать плавную границу какого-то состояния, то вы можете использовать rubber band effect.
Ещё немного о том, почему иногда важно понимать, как работает та или иная механика и какие формулы там используются. Ведь для rubber band effect мы нашли какое-то приближение в виде многочлена, который довольно близко приближается к нашей искомой формуле.
Отличие оригинальной формулы от приближения в том, что в оригинальной формуле есть коэффициент c, с помощью которого вы можете предсказуемо менять жёсткость rubber band effect. Для того, чтобы добиться того же самого в приближенной формуле, вам придется каким-то образом подбирать коэффициенты при x.
Также в оригинальной формуле есть параметр d, которым регулируется максимальное смещение, а с помощью приближения вы такого сделать вообще не сможете.
При реализации bounce мы использовали анимацию замедления, а затем анимацию пружины. Из-за того что у нас использовалось одно и то же понятие скорости, стык двух анимаций автоматически получился плавным. Нам не пришлось отдельно настраивать замедление, отдельно настраивать параметры пружины. Это нам позволяет в будущем независимо менять поведение замедления и поведение bounce, и стык у нас всегда будет плавным, а выглядеть это будет как одна цельная анимация.
Если вы какой-то момент захотите разобраться в том, как работает какая-то там механика, какие формулы там используются, то я бы рекомендовал попытаться найти оригинальную формулу, потому что это упростит вашу жизнь в будущем.
📚 Все примеры с кодом, включая PiP и TimerAnimation
, можно найти в репозитории: https://github.com/super-ultra/ScrollMechanics.
🚀 Вытягивающаяся карточка доступна в виде пода: https://github.com/super-ultra/UltraDrawerView.
👨💻 Также рекомендую посмотреть Designing Fluid Interfaces и Advanced Animations with UIKit.