Создание анимаций это здорово. Они являются важной частью iOS Human Interface Guidelines. Анимации помогают привлечь внимание пользователя к важным вещам или попросту делают приложение не таким скучным.
Существует несколько способов реализовать анимацию в iOS. Вероятно, самым популярным способом является использование UIView.animate(withDuration: animations:). Вы можете анимировать слой изображения с помощью CABasicAnimation. Кроме того, UIKit позволяет настроить кастомную анимацию для отображения контроллера с помощью UIViewControllerTransitioningDelegate.
В этой статье я хочу обсудить еще один захватывающий способ анимирования вьюшек — UIViewPropertyAnimator. Данный класс предоставляет гораздо больше функций управления, чем его предшественник UIView.animate. С его помощью можно создавать временные, интерактивные и прерываемые анимации. Кроме того, имеется возможность быстрой смены аниматора.
Знакомство с UIViewPropertyAnimator
UIViewPropertyAnimator был представлен в iOS 10. Он позволяет создавать анимации объектно-ориентированным способом. Давайте рассмотрим пример анимации, созданной с помощью UIViewPropertyAnimator.
Вот как это было при использовании UIView.
UIView.animate(withDuration: 0.3) {
view.frame = view.frame.offsetBy(dx: 100, dy: 0)
}
И вот как это можно сделать с помощью UIViewPropertyAnimator:
let animator = UIViewPropertyAnimator(duration:0.3, curve: .linear) {
view.frame = view.frame.offsetBy(dx:100, dy:0)
}
animator.startAnimation()
При необходимости проверить анимацию, просто создайте Playground и запустите код, как показано ниже. Оба фрагмента кода приведут к одному и тому ж результату.
Можно подумать, что в данном примере нет большой разницы. Итак, какой смысл добавлять новый способ создания анимации? UIViewPropertyAnimator становится более полезным, когда необходимо создавать интерактивные анимации.
Интерактивная и прерываемая анимация
Вы помните классический жест «Сдвиг пальцем для разблокировки устройства»? Или жест «Перемещение пальцем на экране снизу вверх» для открытия Центра управления? Это и есть прекрасные примеры интерактивной анимации. Вы можете начать перемещение изображения пальцем, затем отпустите его, и изображение вернется в исходное положение. Кроме того, вы можете поймать изображение во время анимации и продолжить его перемещение с помощью пальца руки.
Анимации UIView не обеспечивают простой способ контроля процентного соотношения завершения анимации. Вы не можете приостановить анимацию в середине цикла и продолжить ее выполнение после прерывания.
В данном случае речь пойдет о UIViewPropertyAnimator. Далее мы рассмотрим, как можно легко создать полностью интерактивную, прерываемую анимацию, и реверсивную анимацию за несколько шагов.
Подготовка стартового проекта
Для начала необходимо скачать стартовый проект. Открыв архив, вы найдите приложение CityGuide, которое помогает пользователям планировать свой отпуск. Пользователь может пролистать список городов, а затем открыть развернутое описание с детальной информацией о городе, который ему понравился.
Рассмотрим исходный код проекта перед тем как мы начнем созданием красивую анимацию. Вот что можно найти в проекте открыв его в Xcode:
- ViewController.swift: Основной контроллер приложения с UICollectionView который отображает массив объектов City.
- CityCollectionViewCell.swift: Ячейка для отображения City. Фактически, в этой статье большинство изменений будут применяться к данному классу. Можно заметить, что descriptionLabel и closeButton, уже определены в классе. Однако, после запуска приложения эти объекты будут скрыты. Не стоит волноваться, они будут видны немного позже. В данном классе также имеются свойства collectionView и index. Позже они будут использованы для анимации.
- CityCollectionViewFlowLayout.swift: Этот класс отвечает за горизонтальную прокрутку. Его изменять пока мы не будем.
- City.swift: Основная модель приложения имеет метод, который использовался в ViewController.
- Main.storyboard: Там можно найти пользовательский интерфейс для ViewController и CityCollectionViewCell.
Попробуем выполнить сборку и запуск примера приложения. В результате этого получаем следующее.
Реализация анимации развертывания и свертывания
После запуска приложения отображается список городов. Но пользователь не может взаимодействовать с объектами в виде ячеек. Теперь необходимо отобразить информацию для каждого города, когда пользователь нажимает на одну из ячеек. Взгляните на окончательный вариант приложения. Вот собственно то, что требовалось разработать:
Анимация выглядит хорошо, не правда ли? Но здесь нет ничего особенного, это просто базовая логика UIViewPropertyAnimator. Давайте посмотрим, как реализовать этот тип анимации. Создайте метод collectionView(_: didSelectItemAt), добавьте следующий фрагмент кода в конец файла ViewController:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let selectedCell = collectionView.cellForItem(at: indexPath)! as! CityCollectionViewCell
selectedCell.toggle()
}
Теперь нам нужно реализовать метод toggle. Давайте переключимся на CityCollectionViewCell.swift и реализуем данный метод.
Сначала добавим перечисление State в начало файла, прямо перед объявлением класса CityCollectionViewCell. Это перечисление позволяет отслеживать состояние ячейки:
private enum State {
case expanded
case collapsed
var change: State {
switch self {
case .expanded: return .collapsed
case .collapsed: return .expanded
}
}
}
Добавим несколько свойств для управления анимацией в класс CityCollectionViewCell:
private var initialFrame: CGRect?
private var state: State = .collapsed
private lazy var animator: UIViewPropertyAnimator = {
return UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut)
}()
Переменная initialFrame используется для хранения фрейма ячейки до выполнения анимации. state используется для отслеживания, если ячейка развернутая или свернута. А переменная animator используется для управления анимацией.
Теперь добавьте метод toggle и вызовите его из метода close, например:
@IBAction func close(_ sender: Any) {
toggle()
}
func toggle() {
switch state {
case .expanded:
collapse()
case .collapsed:
expand()
}
}
Затем добавим еще два метода: expand() и collapse(). Продолжим их реализацию. Сначала мы начнем с метода expansiond():
private func expand() {
guard let collectionView = self.collectionView, let index = self.index else { return }
animator.addAnimations {
self.initialFrame = self.frame
self.descriptionLabel.alpha = 1
self.closeButton.alpha = 1
self.layer.cornerRadius = 0
self.frame = CGRect(x: collectionView.contentOffset.x, y:0, width: collectionView.frame.width, height: collectionView.frame.height)
if let leftCell = collectionView.cellForItem(at: IndexPath(row: index - 1, section: 0)) {
leftCell.center.x -= 50
}
if let rightCell = collectionView.cellForItem(at: IndexPath(row: index + 1, section: 0)) {
rightCell.center.x += 50
}
self.layoutIfNeeded()
}
animator.addCompletion { position in
switch position {
case .end:
self.state = self.state.change
collectionView.isScrollEnabled = false
collectionView.allowsSelection = false
default:
()
}
}
animator.startAnimation()
}
Как много кода. Позвольте объяснить происходящее шаг за шагом:
- Сначала проверяем, не равны ли collectionView и index нулю. В противном случае мы не сможем запустить анимацию.
- Далее начинаем создавать анимацию, вызывая animator.addAnimations.
- Далее сохраняем текущий фрейм, который используется для его восстановления в анимации свертывания.
- Затем мы устанавливаем альфа-значение для descriptionLabel и closeButton, чтобы сделать их видимыми.
- Далее убираем закругленный угол и устанавливаем новый фрейм для ячейки. Ячейка будет показана в полноэкранном режиме.
- Далее мы перемещаем соседние ячейки.
- Теперь вызываем метод animator.addComplete(), чтобы отключить взаимодействие изображения коллекции. Это не позволяет пользователям прокручивать ее во время расширения ячейки. Также меняем текущее состояние ячейки. Важно, поменять состояние ячейки и только после этого происходит завершения анимации.
Теперь добавим анимацию свертывания. Вкратце, это просто мы восстанавливаем ячейку в прежнее состояние:
private func collapse() {
guard let collectionView = self.collectionView, let index = self.index else { return }
animator.addAnimations {
self.descriptionLabel.alpha = 0
self.closeButton.alpha = 0
self.layer.cornerRadius = self.cornerRadius
self.frame = self.initialFrame!
if let leftCell = collectionView.cellForItem(at: IndexPath(row: index - 1, section: 0)) {
leftCell.center.x += 50
}
if let rightCell = collectionView.cellForItem(at: IndexPath(row: index + 1, section: 0)) {
rightCell.center.x -= 50
}
self.layoutIfNeeded()
}
animator.addCompletion { position in
switch position {
case .end:
self.state = self.state.change
collectionView.isScrollEnabled = true
collectionView.allowsSelection = true
default:
()
}
}
animator.startAnimation()
}
Теперь пришло время скомпилировать и запустить приложение. Попробуйте нажать на ячейку, и вы увидите анимацию. Чтобы закрыть изображение, нажмите на значок крестика в правом верхнем углу.
Добавление обработки жеста
Можно утверждать о достижении того же результата, используя UIView.animate. Какой смысл использовать UIViewPropertyAnimator?
Хорошо, пришло время сделать анимацию интерактивной. Добавим UIPanGestureRecognizer и новое свойство с именем popupOffset, чтобы отслеживать, сколько можно двигать ячейку. Давайте объявим эти переменные в классе CityCollectionViewCell:
private let popupOffset: CGFloat = (UIScreen.main.bounds.height - cellSize.height)/2.0
private lazy var panRecognizer: UIPanGestureRecognizer = {
let recognizer = UIPanGestureRecognizer()
recognizer.addTarget(self, action: #selector(popupViewPanned(recognizer:)))
return recognizer
}()
Затем добавьте следующий метод для регистрации определения свайпа:
override func awakeFromNib() {
self.addGestureRecognizer(panRecognizer)
}
Теперь необходимо добавить метод popupViewPanned для отслеживания жеста свайпа. Вставим следующий код в CityCollectionViewCell:
@objc func popupViewPanned(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
toggle()
animator.pauseAnimation()
case .changed:
let translation = recognizer.translation(in: collectionView)
var fraction = -translation.y / popupOffset
if state == .expanded { fraction *= -1 }
animator.fractionComplete = fraction
case .ended:
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
default:
()
}
}
Здесь имеется три состояния. В начале жеста мы инициализируем аниматор методом toggle и сразу же приостанавливаем его. В то время как пользователь перетаскивает ячейку, обновляем анимацию, установив свойства мультипликатора fractionComplete. Это основная магия аниматора, которая позволяет им управлять. Наконец, когда пользователь отпускает палец, вызывается метод аниматора continueAnimation, чтобы продолжить выполнение анимации. Затем ячейка перейдет в целевую позицию.
После запуска приложения, можно перетащить ячейку вверх, чтобы ее развернуть. Затем перетащим расширенную ячейку вниз, чтобы ее свернуть.
Теперь анимация выглядит довольно хорошо, но прервать анимацию в середине не возможно. Поэтому, чтобы сделать анимацию полностью интерактивной, необходимо добавить еще одну функцию — прерывание. Пользователь может запускать анимацию развертывания / свертывания как обычно, но анимация должна быть приостановлена сразу же после того, как пользователь нажмет на ячейку во время цикла анимации.
Для этого необходимо сохранить прогресс анимации и затем принять это значение во внимание, чтобы вычислить процент завершения анимации.
Сначала объявим новое свойство в CityCollectionViewCell:
private var animationProgress: CGFloat = 0
Затем обновим .began блок метода popupViewPanned со следующей строки кода, чтобы запомнить прогресс:
animationProgress = animator.fractionComplete
В блоке .changed необходимо обновить следующую строку кода, чтобы правильно рассчитать процент выполнения:
animator.fractionComplete = fraction + animationProgress
Теперь приложение готово к тестированию. Запустите проект и посмотрите, что получится. Если все действия выполнены правильно следуя моим указаниям, анимация должна выглядеть следующим образом:
Реверс анимации
Можно найти недостаток для текущей реализации. Если немного перетащить ячейку, а затем вернуть ее в исходное положение, ячейка будет продолжать расширяться, когда вы отпустите палец. Давайте исправим эту проблему, чтобы сделать интерактивную анимацию еще лучше.
Выполним обновление блока .end метода popupViewPanned, согласно описанию ниже:
let velocity = recognizer.velocity(in: self)
let shouldComplete = velocity.y > 0
if velocity.y == 0 {
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
break
}
switch state {
case .expanded:
if !shouldComplete && !animator.isReversed { animator.isReversed = !animator.isReversed }
if shouldComplete && animator.isReversed { animator.isReversed = !animator.isReversed }
case .collapsed:
if shouldComplete && !animator.isReversed { animator.isReversed = !animator.isReversed }
if !shouldComplete && animator.isReversed { animator.isReversed = !animator.isReversed }
}
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
Теперь мы учитываем скорость жеста, чтобы определить, должна ли анимация быть реверсирована.
И, наконец, вставим еще одну строку кода в блок .changed. Поместите данный код справа от вычисления animator.fractionComplete.
if animator.isReversed { fraction *= -1 }
Давайте запустим приложение снова. Теперь все должно работать без сбоев.
Исправление pan gesture
Итак, мы завершили реализацию анимации с помощью UIViewPropertyAnimator. Однако есть одна неприятная ошибка. Возможно, вы встретились с ней во время тестирования приложения. Проблема состоит в том, прокрутка ячейки по горизонтали не возможна. Попробуем провести пальцем влево/вправо по ячейкам, и мы стыкаемся с проблемой.
Основная причина связана с созданным нами UIPanGestureRecognizer. Он также улавливает жест смахивания, и конфликтует со встроенным распознавателем жестов UICollectionView.
Хотя пользователь все еще может пролистывать верхнюю/нижнюю часть ячеек или пространство между ячейками для прокрутки по городам, мне все же не нравится такой плохой пользовательский интерфейс. Давайте это исправим.
Чтобы разрешить конфликты, нам нужно реализовать метод делегата с именем gestRecognizerShouldBegin(_:). Этот метод контролирует, должен ли распознаватель жестов продолжать интерпретацию касаний. Если вы вернете false в метод, распознаватель жестов будет игнорировать касания. Итак, что мы собираемся сделать — это дать нашему собственному средству распознавания панорамы возможность игнорировать горизонтальные движения.
Для этого давайте установим delegate нашего распознавателя панорамирования. Вставим следующую строку кода в инициализацию panRecognizer (вы можете поместить код прямо перед return recognizer:
recognizer.delegate = self
Затем, реализуем метод gestRecognizerShouldBegin(_ :) следующим образом:
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return abs((panRecognizer.velocity(in: panRecognizer.view)).y) > abs((panRecognizer.velocity(in: panRecognizer.view)).x)
}
Мы будем открывать/закрывать, если его скорость вертикального движения больше, чем скорость горизонтального движения.
Здорово! Давайте снова протестируем приложение. Теперь вы сможете перемещаться по списку городов, проводя пальцем влево/вправо по ячейкам.
Bonus: Пользовательские функции синхронизации
Прежде чем мы закончим этот туториал, давайте поговорим о timing functions. Вы еще помните тот случай, когда разработчик просил вас реализовать пользовательскую функцию синхронизации для анимации, которую вы создаете?
Обычно следует изменить UIView.animation на CABasicAnimation или обернуть его в CATransaction. Посредством UIViewPropertyAnimator вы можете легко реализовать кастомную timing functions.
Под timing functions (или easing functions) понимаются функции скорости анимации, которые влияют на темп изменения того или иного анимируемого свойства. Сейчас поддерживается четыре типа: easeInOut, easeIn, easeOut, linear.
Замените инициализацию аниматора этой timing functions (попробуйте нарисовать свою собственную кубическую кривую Безье) следующим образом:
private lazy var animator: UIViewPropertyAnimator = {
let cubicTiming = UICubicTimingParameters(controlPoint1: CGPoint(x: 0.17, y: 0.67), controlPoint2: CGPoint(x: 0.76, y: 1.0))
return UIViewPropertyAnimator(duration: 0.3, timingParameters: cubicTiming)
}()
В качестве альтернативы, вместо использования параметров кубической синхронизации, вы также можете использовать синхронизацию пружины, например:
let springTiming = UISpringTimingParameters(mass: 1.0,
stiffness: 2.0,
damping: 0.2,
initialVelocity: .zero)
Попробуйте запустить проект еще раз и посмотреть, что получится.
Заключение
Посредством UIViewPropertyAnimator можно улучшать статические экраны и взаимодействие с пользователем с помощью интерактивных анимаций.
Я знаю, что вам не терпится реализовать то, что вы узнали, в своем собственном проекте. Если вы примените этот подход в своем проекте, будет очень здорово, сообщите мне об этом, оставив комментарий ниже.
В качестве справочного материала, здесь можно скачать финальный проект.
Дальнейшие ссылки
Профессиональные анимации посредством использования UIKit — https://developer.apple.com/videos/play/wwdc2017/230/
UIViewPropertyAnimator Документация для разработчиков Apple — https://developer.apple.com/documentation/uikit/uiviewpropertyanimator
Автор: Dima