История данной статьи начинается с того, что мне поставили задачу — реализовать анимацию, которая зависит от направления скролла UICollectionView. По макету в Figma анимация выглядела совсем не сложной. Нужно было изменять размер UIView, которая находится над коллекцией, при скролле контента. Однако чтобы реализовать ее, мне потребовалось приложить немало усилий.
Чтобы было понятней, о какой анимации идёт речь, приведу примеры похожей анимации из известных приложений:
Первым делом я полез в Google искать вдохновения в чужих решениях. Ничего похожего на эту анимацию мне найти не удалось, и я решил разобраться с этой задачей самостоятельно. После нескольких итераций неправильных анимаций и других ошибок я наконец нашел решение и смог реализовать требуемую анимацию. TLDR
После успешной реализации я решил поделиться своим опытом с другими разработчиками и написать данную статью. Она будет полезна для тех, кто столкнулся с аналогичной задачей и хочет реализовать похожее поведение в своих iOS-приложениях.
Все что нужно знать об UIScrollView
UIScrollView - объект, который предоставляет пользователю возможность прокручивать содержимое, которое больше, чем размер самого UIScrollView. UIScrollView имеет 2 важных свойства: contentInset и contentOffset, которые определяют отступы и позицию содержимого внутри UIScrollView соответственно. Перед переходом непосредственно к коду анимации разберёмся с этими свойствами, они нам пригодятся. Сильно углубляться не буду, в интернете полно статей, которые детально их разбирают.
contentInset
Свойство contentInset определяет отступы вокруг содержимого UIScrollView. contentInset является объектом UIEdgeInsets, который состоит из 4 свойств: top, left, bottom и right. Например, если я хочу, чтобы содержимое UIScrollView было смещено на 40 точек от верха UIScrollView, то я могу установить:
scrollView.contentInset.top = 40
contentOffset
Свойство contentOffset определяет текущую позицию содержимого UIScrollView внутри его рамок. Оно показывает, на сколько содержимое прокрутилось по оси x или y относительно начальной позиции. contentOffsetявляется объектом CGPoint, который состоит из 2 свойств: x и y. Например, если пользователь прокрутил содержимое UIScrollView на 40 точек вниз, то значение contentOffset.y, будет равно 40.
Эти свойства могут быть использованы для реализации сложной логики, связанной с прокруткой содержимого внутри UIScrollView. Для реализации анимации я также буду использовать contentOffset и contentInset.
Переходим к анимации
Перед тем как перейти непосредственно к коду, продемонстрирую итоговый результат, который должен получиться в конце этой статьи.
Когда пользователь начинает скролить таблицу вверх, то сначала UITableView меняет свой размер и поднимается вверх, и только после того, как она достигает нужной высоты, контент начинает прокручиваться. В обратную сторону все происходит точно так же. Сверху от таблицы я добавил индикатор прогресса. Индикатор отображает процент завершения анимации и в данном случае представляет собой всю анимацию. Анимация начинается, когда таблица начинает увеличивать высоту и заканчивается, когда высота достигает нужной точки.
Анимация делится на несколько частей:
У таблицы начинает меняться contentOffset, мы не даем ей его поменять и перенаправляем это изменение в увеличение высоты таблицы.
Высота таблицы увеличивается.
Пока contentOffset находится в разрешенном диапазоне, мы даем таблице прокручиваться (разрешаем изменение contentOffset), и она ведет себя как обычно.
Как только contentOffset переваливает за разрешенный диапазон, мы снова запрещаем таблице менять свой contentOffset и перенаправляем это изменение в уменьшение высоты таблицы.
Высота таблицы уменьшается.
Самое главное в этой анимации - это в определенный момент запретить UITableView изменять свой contentOffset, а также получить процент изменения высоты таблицы (обычно все анимации строятся вокруг него).
Как мне кажется, пример с процентом завершения анимации лучше всего подходит для объяснения принципа работы таких анимаций. Поведение UIScrollView и его наследников практически всегда будет одинаковым, а с полученным процентом можно сделать что угодно. Например, можно изменять размер, координаты UIView или фейдить её.
Покажи код!
Подготовка
Чтобы реализовать данную анимацию, нам потребуется NSLayoutConstraint, который будет изменяться, а также метод протокола UIScrollViewDelegate - scrollViewDidScroll. Весь код UIViewController-a прикладывать не буду, дабы не засорять статью. Если что, его можно посмотреть в репозитории.
Добавим NSLayoutConstraint, который будет отвечать за изменение высоты UITableView.
private let minConstraintConstant: CGFloat = 50
private let maxConstraintConstant: CGFloat = 200
private func setupTableView() {
view.addSubview(tableView)
animatedConstraint = tableView.topAnchor.constraint(equalTo: progressView.bottomAnchor, constant: maxConstraintConstant)
NSLayoutConstraint.activate([
animatedConstraint!,
tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
}
В коде описаны 2 константы: minConstraintConstant и maxConstraintConstant. Как можно догадаться по названию, это минимальная и максимальная высота констрейнта.
Did scroll?
Все последующие изменения будут происходить внутри метода scrollViewDidScroll. Попробуем разбить код на несколько логических кусков и будем шаг за шагом совершенствовать анимацию. Начнем!
Направление скролла и изменение позиции содержимого UITableView
Здесь нам поможет tableView.contentOffset.y. Посмотрим, как он работает, на примере. Чтобы увидеть его изменение, я добавил UILabel в середине UIViewController-a и выставляю этому лэйблу текст вот таким вот образом:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let yOffsetString = String(format: "%.2f", scrollView.contentOffset.y)
debugLabel.text = "contentOffset.y = \(yOffsetString)"
}
Но вам, скорее всего, будет удобнее вывести это значение через print. Смотрим и видим такую картину:
Что здесь важно: если контент ниже начальной позиции, то contentOffset.y < 0, если выше — contentOffset.y > 0. Но это все еще не направление скролла содержимого. Чтобы узнать направление скролла, нам нужно запомнить предыдущий contentOffset.y, а затем вычесть его из текущего. Это и будет величина, на которую изменился contentOffset.y по сравнению с предыдущим вызовом метода.
private var previousContentOffsetY: CGFloat = 0
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let currentContentOffsetY = scrollView.contentOffset.y
let scrollDiff = currentContentOffsetY - previousContentOffsetY
let contentMovesUp = scrollDiff > 0
let contentMovesDown = scrollDiff < 0
previousContentOffsetY = scrollView.contentOffset.y
}
Набираем высоту ✈️
У нас появилось знание о том, куда движется контент внутри UITableView, а также мы теперь знаем величину сдвига контента. Самое время подвигать UITableView. Для этого нам нужно изменять константу констрейнта, который отвечает за высоту таблицы, в то время как пользователь скроллит таблицу.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let currentContentOffsetY = scrollView.contentOffset.y
let scrollDiff = currentContentOffsetY - previousContentOffsetY
let contentMovesUp = scrollDiff > 0
let contentMovesDown = scrollDiff < 0
let currentConstraintConstant = animatedConstraint!.constant
var newConstraintConstant = currentConstraintConstant
if contentMovesUp {
// Уменьшаем константу констрэйнта
newConstraintConstant = currentConstraintConstant - scrollDiff
} else if contentMovesDown {
// Увеличиваем константу констрэйнта
newConstraintConstant = currentConstraintConstant - scrollDiff
}
animatedConstraint?.constant = newConstraintConstant
previousContentOffsetY = scrollView.contentOffset.y
}
Если контент двигается вверх, то константа констрейнта уменьшается, тем самым увеличивая высоту таблицы. Точно такая же логика, когда контент двигается вниз. Здесь вас может запутать тот факт, что, как при contentMovesUp, так и contentMovesDown, мы вычитаем scrollDiff из текущий константы, и это не ошибка. При contentMovesUp = true scrollDiff больше 0, а при contentMovesDown он меньше 0. Проверяем результат:
Здесь можно заметить несколько проблем:
Изменение значения константы констрейнта не ограничено, что может привести к превышению желаемых границ высоты таблицы
Во время прокрутки контента вверх таблица одновременно скроллится и увеличивает свою высоту.
Решаем проблемы
Для начала ограничим изменение константы констрейнта.
if contentMovesUp {
// Уменьшаем константу констрэйнта
newConstraintConstant = max(currentConstraintConstant - scrollDiff, minConstraintConstant)
} else if contentMovesDown {
// Увеличиваем константу констрэйнта
newConstraintConstant = min(currentConstraintConstant - scrollDiff, maxConstraintConstant)
}
При движении контента вверх мы устанавливаем ограничение на значение константы, которое не может стать меньше, чем minConstraintConstant (50). Аналогично при движении контента вниз — значение константы не может превысить maxConstraintConstant (200). Таким образом мы гарантируем, что контент всегда будет отображаться в определенном диапазоне.
Чтобы избавиться от второй проблемы, необходимо запретить UITableView прокручиваться во время изменения константы констрейнта. У наследников UIScrollView есть свойство isScrollEnabled, но оно не подходит, так как при isScrollEnabled = true делегатный метод scrollViewDidScroll не будет вызван. Решить данную проблему нам опять поможет свойство contentOffset. Именно оно отвечает за смещение контента внутри UIScrollView. Также нужно учесть, что отключать прокрутку нужно только в случае изменения высоты. Когда высота не изменяется, контент должен прокручиваться.
// Меняем высоту и запрещаем скролл, только в случае изменения константы
if newConstraintConstant != currentConstraintConstant {
animatedConstraint?.constant = newConstraintConstant
scrollView.contentOffset.y = previousContentOffsetY
}
Возможно, возникнет вопрос: почему scrollView.contentOffset.y = previousContentOffsetY? Если у таблицы не выставлено свойство contentInset, то в начале анимации contentOffset = -scrollView.contentInset.top и при первом изменении константы содержимое таблицы резко подпрыгнет. Выставление previousContentOffsetY как раз помогает избежать этой проблемы.
Полюбуемся на получившийся результат:
Константа констрейнта ограничена, контент не скроллится во время изменения константы. Но невооруженным глазом видно, что анимация все еще не работает как нужно. При срабатывании bounce эффекта таблицы константа резко меняет своё значение. Так происходит, потому что в этот момент scrollDiff меняет знак, и таблица как будто начинает прокручиваться в обратную сторону. Но это не так, она просто возвращается в нейтральное положение. Разберёмся с этой проблемой.
Обходим bounce эффект
Разберемся детальнее, что такое bounce эффект и как он нам мешает.
Bounce эффект можно наблюдать при достижении конца содержимого таблицы UITableView. Когда пользователь прокручивает контент вниз или вверх и достигает конца, таблица начинает "отскакивать" назад, чтобы указать на то, что достигнут конец списка.
Как я писал в начале статьи, за позицию содержимого внутри UITableView отвечает свойство contentOffset. Именно он и изменяется во время bounce эффекта.
Так как анимация полагается на свойство contentOffset, то, чтобы избавиться от неприятного бага, нужно научиться игнорировать изменение этого свойства после начала bounce эффекта.
// Верхняя граница начала bounce эффекта
let bounceBorderContentOffsetY = -scrollView.contentInset.top
let contentMovesUp = scrollDiff > 0 && currentContentOffsetY > bounceBorderContentOffsetY
let contentMovesDown = scrollDiff < 0 && currentContentOffsetY < bounceBorderContentOffsetY
Верхняя граница bounce эффекта это -scrollView.contentInset.top. Если contentOffset.y становится больше или меньше данной границы, то мы считаем, что контент не прокручивается.
Финальный штрих
Чтобы до конца завершить анимацию, нужно посчитать процент её завершения и вывести его в индикатор. Не вижу особого смысла объяснять, как считается процент, просто приведу финальный код:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let currentContentOffsetY = scrollView.contentOffset.y
let scrollDiff = currentContentOffsetY - previousContentOffsetY
// Верхняя граница начала bounce эффекта
let bounceBorderContentOffsetY = -scrollView.contentInset.top
let contentMovesUp = scrollDiff > 0 && currentContentOffsetY > bounceBorderContentOffsetY
let contentMovesDown = scrollDiff < 0 && currentContentOffsetY < bounceBorderContentOffsetY
let currentConstraintConstant = animatedConstraint!.constant
var newConstraintConstant = currentConstraintConstant
if contentMovesUp {
// Уменьшаем константу констрэйнта
newConstraintConstant = max(currentConstraintConstant - scrollDiff, minConstraintConstant)
} else if contentMovesDown {
// Увеличиваем константу констрэйнта
newConstraintConstant = min(currentConstraintConstant - scrollDiff, maxConstraintConstant)
}
// Меняем высоту и запрещаем скролл, только в случае изменения константы
if newConstraintConstant != currentConstraintConstant {
animatedConstraint?.constant = newConstraintConstant
scrollView.contentOffset.y = previousContentOffsetY
}
// Процент завершения анимации
let animationCompletionPercent = (maxConstraintConstant - currentConstraintConstant) / (maxConstraintConstant - minConstraintConstant)
progressView.progress = Float(animationCompletionPercent)
previousContentOffsetY = scrollView.contentOffset.y
}
Посмотрим, что получилось:
Заключение
Итак, мы разобрались в основных принципах работы семейства анимаций UIView, зависящих от скролла. Также детально рассмотрели код, который должен помочь при создании похожих анимаций.
P.S. Изначально я хотел добавить в статью конкретные примеры анимаций, но тогда она бы получилась громоздкой и нечитаемой. Поэтому я решил разделить статью на 2 части.
2 часть данной статьи с примерами