Привет, Хабр! Меня зовут Кристина, я разрабатываю мобильное приложение «Додо Пиццы» для iOS. Наша команда отвечает за персонализацию клиентского опыта в приложении.
В этой статье я расскажу, как мы придумали и реализовали акцию с игровой механикой. Поделюсь техническими подробностями, расскажу про анимации, а также про то, как необычно можно использовать стандартное поведение коллекции.
Что такое секретная акция?
У нас в приложении есть акции, которые можно найти в профиле или в корзине. Мы захотели немножко их разнообразить, добавить вау-опыт и увеличить число участников нашей программы лояльности. Побрейнштормив с командой и перебрав разные варианты, мы решили добавить новый тип акции с игровой механикой. Дизайнер видел эту фичу так:
Суть в том, что такую акцию нельзя просто так открыть свайпом, как это обычно бывает в горизонтальных коллекциях. Мы хотели сделать так, чтобы акция «сопротивлялась» вытягиванию, то есть чтобы вначале акция вытягивались легко, а под конец — сложно.
Ячейка с такой акцией не должна быть обычной. Она должна отличаться цветом и опытом взаимодействия. Например, корешком, потянув за который, пользователи его оторвут как на билетах с отрывной контрольной частью.
К тому же секретная акция должна не просто «выезжать» из-за границы экрана, а как-то встречать пользователя. Поэтому мы решили, что нам, во-первых, нужна анимация увеличения и уменьшения ячейки с акцией, а во-вторых — конфетти, сообщающие об открытии акции.
Из каких частей состоит разработка
Это не стандартная разработка, к которой привыкли мобильные разработчики. Тут не просто табличка или коллекция с какими-то ячейками. Поэтому сперва взглянув на макеты, мы подумали: «а это вообще возможно»? Но разбив фичу на маленькие этапы, мы стали её разрабатывать.
Не стану останавливаться на том, как сделать коллекцию и добавить в неё разные типы ячеек, — про это уже написано достаточное количество статей. Отмечу только, что у нас лейаут коллекции — это наследник UICollectionViewFlowLayout
, а не UICollectionViewCompositionalLayout
. Наш лейаут умеет добавлять тень, центрировать ячейки после скролла и адаптировать коллекцию под Right-To-Left языки — тексты на них читаются и пишутся справа налево, а подробнее о них можно прочитать здесь. Но всё, что относится к нашей задаче, делаем с помощью UIScrollViewDelegate
.
Самый сложный вопрос, на который мы должны были ответить: «как сделать сопротивление акции при вытягивании?». Мы вспомнили про то, что стандартный UIScrollView
уже имеет такое поведение. Если вы попробуете потянуть любую таблицу или коллекцию, которая поддерживает pull-to-refresh, то вы можете достаточно далеко утянуть скроллящийся элемент от начала, особенно если будете делать это двумя пальцами попеременно. Мы попробовали использовать тот же самый механизм и в нашем случае, поэкспериментировали в течение пары дней и решили, что он нас устраивает.
Делаем вёрстку с отрывным краем
Перед тем, как приступить к реализации анимаций и вытягивания, начинаем с простого — верстаем карточку секретной акции. По задумке часть акции, на которой написано «Потяните меня», в процессе вытягивания акции будет отрываться. Как на билетиках с отрывной контрольной частью.
Рисовать такую волнистую линию мы будем в стандартном методе draw(_:)
. Нам нужно нарисовать её как для левой view, так и для правой.
Наше приложение поддерживает RTL-языки, а значит левую и правую части акции нужно поменять местами. Из-за этого расчётов, что и на какой стороне нужно сделать, получается чуть больше.
Первое, что нам нужно сделать, — определить текущую ориентацию интерфейса. Это делается очень просто: let isRTL = effectiveUserInterfaceLayoutDirection == .rightToLeft
. Приведу пример расчётов для отрывной части, для второй части расчёты аналогичные.
Для начала мы должны определить количество итераций в цикле, которые нам нужно повторить, чтобы получить волнистую линию. Для дальнейшего погружения в код нужно зафиксировать терминологию. В этом нам поможет следующая схема, где:
inner circle — это внутренний круг радиуса 2;
outer circle — внешний круг радиуса 3;
1 cycle — внутренний и внешний круги, нарисованные подряд.
Так как круги частично пересекаются, в расчётах для внешнего круга берём диаметр, а для внутреннего — радиус.
let outerCircleRadius: CGFloat = 3
let innerCircleRadius: CGFloat = 2
let numberOfCircles = (bounds.height / (outerCircleRadius * 2 + innerCircleRadius)).rounded(.up)
Также нам понадобится рассчитать 10 величин — они нужны для дальнейших вычислений.
// С какой стороны нужно скруглить углы
let cornersToRound = isRTL ? [.topRight, .bottomRight] : [.topLeft, .bottomLeft]
// В зависимости от того, на какой стороне нужно нарисовать волнистую линию,
// берём либо минимальную, либо максимальную координату по оси абсцисс
let leadingX = isRTL ? bounds.minX : bounds.maxX
// Offset по оси абсцисс для внутренного круга
let innerCircleOffest = isRTL ? innerCircleRadius : -innerCircleRadius
// Offset по оси абсцисс для внешнего круга
let outerCircleCenterOffset = isRTL ? -1 : 1
// Стартовый угол в градусах для внутреннего круга
let innerStartAngle = isRTL ? 240 : 300
// Конечный угол в градусах для внутреннего круга
let innerEndAngle = isRTL ? 120 : 60
// По часовой стрелке или против часовой стрелки рисовать круги для внутреннего круга
let innerClockwise = !isRTL
// Стартовый угол в градусах для внешнего круга
let outerStartAngle = isRTL ? 300 : 240
// Конечный угол в градусах для внешнего круга
let outerEndAngle = isRTL ? 60 : 120
// По часовой стрелке или против часовой стрелки рисовать круги для внешнего круга
let outerClockwise = isRTL
Волнистую линию мы будем рисовать дугами по 120 градусов.
Обратите внимание на то, как расположены значения на тригонометрической окружности. В школьной программе значениебыло вверху окружности, а— внизу. У Apple наоборот: значениевнизу окружности, а— вверху. У Apple хорошо описано, как именно работает параметр clockwise
и даже есть картиночка для наглядности.
У нас получается такая схема для внешнего и внутреннего кругов. Выделила цветами части окружности по 120 градусов и направление часовой стрелки.
Теперь наконец-то начинаем рисовать! Для начала скругляем углы:
let mainPath = UIBezierPath(roundedRect: bounds, byRoundingCorners: cornersToRound, cornerRadii: CGSize(all: 24))
Создаём новый bezier path для волнистой линии и перемещаем его на начальную позицию вверх:
let circlesMaskPath = UIBezierPath()
circlesMaskPath.move(to: CGPoint(x: leadingX, y: bounds.minY))
А теперь рисуем волнистую линию:
for index in 1...numberOfCircles {
let diameter = outerCircleRadius * 2
// Считаем координату по оси ординат
let centerY = CGFloat(index) * (innerCircleRadius + diameter) - outerCircleRadius
// Координаты центра внутреннего круга
let innerCircleCenter = CGPoint(
x: leadingX + innerCircleOffest,
y: centerY - outerCircleRadius - innerCircleRadius / 2
)
// Добавляем дугу для внутреннего круга
circlesMaskPath.addArc(
withCenter: innerCircleCenter,
radius: innerCircleRadius,
startAngle: innerStartAngle.toRadians,
endAngle: innerEndAngle.toRadians,
clockwise: innerClockwise
)
// Координаты центра внешнего круга
let outerCircleCenter = CGPoint(
x: leadingX + outerCircleCenterOffset,
y: centerY
)
// Добавляем дугу для внешнего круга
circlesMaskPath.addArc(
withCenter: outerCircleCenter,
radius: outerCircleRadius,
startAngle: outerStartAngle.toRadians,
endAngle: outerEndAngle.toRadians,
clockwise: outerClockwise
)
}
// Добавляем линию до нижнего края view и закрываем bezier path с волнистой линией
circlesMaskPath.addLine(to: CGPoint(x: leadingX, y: bounds.maxY))
circlesMaskPath.close()
// Добавляем волнистую линию к основному bezier path, в котором мы скруглили края
mainPath.append(circlesMaskPath)
}
Градусы в радианы переводим вот таким маленьким extension:
extension CGFloat {
var toRadians: CGFloat {
self * .pi / 180
}
}
Переопределяем параметры маски, чтобы увидеть наш bezier path на view:
if let mask = layer.mask as? CAShapeLayer {
mask.frame = bounds
mask.path = mainPath.cgPath
}
В самой view указываем несколько дополнительных параметров для layer. Я делаю это в методе, который вызывается в init(frame:)
:
backgroundColor = .purple
let mask = CAShapeLayer()
mask.fillRule = .evenOdd
layer.mask = mask
layer.cornerCurve = .continuous
В итоге мы вырезали часть view. В этом нам помогло свойство mask.fillRule = .evenOdd
. Также для более плавного скругления мы используем continuous curve по аналогии со скруглением углов. Чтобы понять, что именно изменилось после наложения маски, я добавила на фон красную view такого же размера, что и отрывная часть.
Скрываем акцию за пределами экрана
Изначально секретная акция должна быть за пределами экрана. Для этого нам нужно поменять contentInset
, чтобы «спрятать» её:
// Ширина ячейки секретной акции. Она понадобится в дальнейших расчётах
var secretOfferWidth: CGFloat = 0
func updateCollectionViewContentInset() {
let contentInset: UIEdgeInsets
let standardContentInset = UIEdgeInsets(left: 4, right: 12)
let collectionViewLayoutLineSpacing = 8
let cellHorizonalInsets = standardContentInset + collectionViewFlowLayout.sectionInset.horizontals
// Проверяем, что секретная акция есть во viewModel и она ещё не открыта пользователем
if viewModel.hasSecretOffer && !isSecretOfferShown {
secretOfferWidth = frame.width - cellHorizonalInsets
let rightInset = secretOfferWidth - collectionViewLayoutLineSpacing
contentInset = UIEdgeInsets(left: 4, right: -rightInset)
} else {
contentInset = standardContentInset
}
collectionView.contentInset = contentInset
}
Метод updateCollectionViewContentInset()
у нас вызывается при конфигурации view с акциями, а также в layoutSubviews()
.
Так как мы ставим достаточно большой contentInset
справа, то в случае одной обычной и одной секретной акции получается так, что contentSize
будет меньше ширины экрана. В таком случае жесты свайпов становятся недоступны. Чтобы этого избежать, нужно поставить свойство collectionView.alwaysBounceHorizontal
в true
.
Показываем кусочек секретной акции
Когда пользователь пролистал все обычные акции, мы анимированно показываем ему кусочек секретной акции, чтобы привлечь к ней его внимание. Если акция одна, то показываем кусочек сразу при открытии экрана, на котором есть акции.
Мы начинаем показ анимации в методе scrollViewDidEndDecelerating(_:)
, так как именно в этом методе мы точно знаем, что у нас закончила скроллиться ячейка. Дальше нам нужно определить, до какой именно акции долистал пользователь. Нам, напомню, нужна последняя обычная.
Здесь всё достаточно просто: у коллекции есть метод indexPathForItem(at:)
, который по координате вернёт indexPath
, а по нему мы можем проверить, какая именно акция сейчас на экране.
Если на экране у пользователя последняя обычная акция, то мы показываем анимацию. Чтобы она отобразилась, нужно:
Визуально сместить видимую часть коллекции.
Увеличить и обратно уменьшить ячейку с секретной акцией.
Вернуть смещение коллекции на прежнее значение.
Для более удобной работы с состоянием анимации я завела такой enum
:
private enum SecretOfferShowPartAnimationState: Equatable {
case notStarted
case inProgress(previousContentOffset: CGPoint)
}
Перед началом анимации проверяем, что её текущее состояние .notStarted
, чтобы она не стартовала больше одного раза. С помощью изменения contentOffset
делаем видимой часть «Потяните меня».
var newContentOffset = collectionView.contentOffset
let offset: CGFloat = 20
// secretOfferCounterFoilViewWidth - это замыкание внутри ячейки, которое просто возвращает ширину отрывной части
// cellHorizonalInsets — сумма contentInset и sectionInset
// offset — константа, вычисленная эмпирическим путём, чтобы оставалось небольшое пространство справа от отрывной части
newContentOffset.x += secretOfferCounterFoilViewWidth?() - cellHorizonalInsets / 2 + offset
// Меняем статус анимации на .inProgress со значением contentOffset до начала анимации
secretOfferShowPartAnimationState = .inProgress(previousContentOffset: collectionView.contentOffset)
// Анимированно устанавливаем новый contentOffset
collectionView.setContentOffset(newContentOffset, animated: true)
После завершения анимации с установкой нового contentOffset
сработает метод scrollViewDidEndScrollingAnimation(_:)
. Именно в этом методе мы и будем стартовать анимацию увеличения и уменьшения ячейки. Проверяем, что текущее состояние анимации .inProgress
, и в ячейке вызываем метод, который увеличит и уменьшит акцию. Все анимации будем делать с помощью UIViewPropertyAnimator
.
func startIncreasingAnimation() {
let scaleAnimationDuration: CGFloat = 0.15
let scaleForShowingPart: CGFloat = 1.03
let scaleCurve: UIView.AnimationCurve = .easeInOut
// Аниматор для анимации увеличения
let animator1 = UIViewPropertyAnimator(duration: scaleAnimationDuration, curve: scaleCurve) {
// Создаём афинное преобразоваение с указанием нужного масштаба по оси абсцисс и оси ординат
self.secretOfferCardView.transform = CGAffineTransform(
scaleX: scaleForShowingPart,
y: scaleForShowingPart
)
}
// Аниматор для анимации уменьшения
let animator2 = UIViewPropertyAnimator(duration: scaleAnimationDuration, curve: scaleCurve) {
// Возвращаем свойство transform в исходное состояние
self.secretOfferCardView.transform = .identity
}
// По завершении анимации увеличения будем стартовать анимацию уменьшения
animator1.addCompletion { _ in
animator2.startAnimation()
}
// По завершении анимации уменьшения уведомляем delegate об этом
animator2.addCompletion { _ in
self.delegate?.increasingAnimationEnded()
}
// Стартуем анимацию увеличения
animator1.startAnimation()
}
Во view с коллекцией ловим метод делегата ячейки:
func increasingAnimationEnded() {
// Проверяем, что состояние анимации .inProgress
guard case let .inProgress(previousContentOffset) = secretOfferShowPartAnimationState else { return }
// Анимированно возвращаем предыдущий contentOffset для коллекции
collectionView.setContentOffset(previousContentOffset, animated: true)
// Состояние анимации сбрасываем в .notStarted
secretOfferShowPartAnimationState = .notStarted
}
Анимация показа кусочка секретной акции готова, но есть один нюанс. Если во время показа нашей анимации пользователь начнёт скроллить коллекцию, то нам нужно избежать ситуации некорректного выставления contentOffset
в самом конце. Для этого в методе scrollViewWillBeginDragging(_:)
сбрасываем состояние анимации в .notStarted
. С анимацией увеличения и уменьшения решили ничего не делать, так как она очень короткая и не влияет на взаимодействие с коллекцией.
Делаем вытягивание
Переходим к самой интересной части — делаем вытягивание акции! Сначала нам нужно скрыть секретную акцию за пределами экрана. Для этого мы меняем contentInset
. Это можно сделать в любом удобном месте, когда уже известны размеры view
и во viewModel
есть информация про секретную акцию.
var secretOfferWidth: CGFloat = 0
func updateCollectionViewContentInset() {
let contentInset: UIEdgeInsets
// Проверяем, что секретная акция есть во viewModel и что она ещё не открыта пользователем
if viewModel.hasSecretOffer, !isSecretOfferShown {
// Считаем ширину ячейки с акцией, убирая из неё cellHorizonalInsets — сумму contentInset и sectionInset
secretOfferWidth = frame.width - cellHorizonalInsets
// Расстояние между ячейками умножаем на 2, так как у нас слева и справа от центральной ячейки видны другие ячейки
let collectionViewLayoutLineSpacings = 8 * 2
// Видимая часть ячейки с секретной акцией
let cellVisiblePart = (cellHorizonalInsets - collectionViewLayoutLineSpacing) / 2
let rightInset = secretOfferWidth - cellVisiblePart
// Левый inset оставляем для случая без секретной акции, а правый меняем на рассчитанное значение
contentInset = UIEdgeInsets(left: 4, right: -rightInset)
} else {
contentInset = UIEdgeInsets(left: 4, right: 12)
}
collectionView.contentInset = contentInset
}
После того, как мы поменяли contentOffset
, переходим к отслеживанию скролла в методе scrollViewDidScroll(_:)
:
// Переменная, которая отвечает, находимся ли мы в процессе вытягивания акции
var isSecretOfferPullingInProgress = false
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// Проверяем, что секретная акция есть во viewModel и она ещё не открыта пользователем
// Также проверяем, что мы не в процессе анимации показа кусочка секретной акции
guard viewModel.hasSecretOffer,
!isSecretOfferShown,
secretOfferShowPartAnimationState == .notStarted else { return }
let collectionViewLayoutLineSpacing = 8
// Считаем ширину скрытой части секретной акции
let secretOfferHiddenWidth = scrollView.contentSize.width - scrollView.contentOffset.x - scrollView.frame.width + collectionViewLayoutLineSpacing
// Проверяем, что ширина скрытой части меньше, чем ширина ячейки с акцией
guard secretOfferHiddenWidth < secretOfferWidth else {
// Если попали сюда, значит мы перестали вытягивать акцию
// Также нужно остановить анимацию дрожания (про это будет в следующем разделе)
if isSecretOfferPullingInProgress {
isSecretOfferPullingInProgress = false
stopShakingAnimation?(false)
}
return
}
// Если пользователь вытянул акцию больше чем на 15% (скрытая часть — 85%)
if secretOfferHiddenWidth < secretOfferWidth * 0.85 {
isSecretOfferPullingInProgress = true
// Начинаем анимацию дрожания (про это будет в следующем разделе)
startShakingAnimation(secretOfferHiddenWidth: secretOfferHiddenWidth)
}
}
Вытянуть акцию за пределами экрана на 100% — задача практически невыполнимая. Поэтому подбирали значение эмпирическим путём. Сначала поставили процент вытягивания на 80%, но по фидбэку от пользователей поняли, что вытягивать слишком тяжело, и снизили процент до 70%.
// Проверяем, что пользователь вытянул акцию больше чем на 70% (скрытая часть — 30%)
guard secretOfferHiddenWidth < secretOfferWidth * 0.3 else { return }
// Считаем новый contentOffset, чтобы была видна вся ячейка с секретной акцией
// cellHorizonalInsets — сумма contentInset и sectionInset
let newContentOffset = CGPoint(
x: scrollView.contentSize.width - scrollView.frame.width + cellHorizonalInsets / 2,
y: scrollView.contentOffset.y
)
// Меняем contentInset на стандартный
collectionView.contentInset = UIEdgeInsets(left: 4, right: 12)
// Меняем contentOffset. Это нужно делать обязательно анимированно
// Иначе получается резкий переход с 70% видимости акции на 100%
collectionView.setContentOffset(newContentOffset, animated: true)
// Меняем флажок, что акция открыта пользователем
isSecretOfferShown = true
shouldResetContentOffset = true
// Останавливаем анимацию дрожания (про это будет в следующем разделе)
stopShakingAnimation?(true)
У нас используется кастомный UICollectionViewFlowLayout
. Он умеет центрировать ячейки изменением contentOffset
.
Однако это ломает скролл коллекции после вытягивания акции. Всё из-за того, что contentOffset
, посчитанный в методе targetContentOffset(forProposedContentOffset:withScrollingVelocity:)
, не совпадает с тем, который мы ставим после вытягивания.
Что можно сделать? Завести флажок shouldResetContentOffset
и поставить его в true
. А в методе scrollViewWillEndDragging(_:withVelocity:targetContentOffset:)
проверить shouldResetContentOffset
. Если он true
, сделать targetContentOffset.pointee = scrollView.contentOffset
.
Также нам нужно прервать текущий жест пользователя. Иначе будет 2 конфликтующих между собой изменения contentOffset
:
финальный
contentOffset
, который мы ставим с помощьюsetContentOffset(_:animated:)
;изменение пользователем, который продолжает вытягивать акцию.
Чтобы конфликта не было, мы временно отключаем жест scrollView.panGestureRecognizer.isEnabled = false
и включаем его обратно в методе scrollViewDidEndScrollingAnimation(:)
. Именно этот метод вызывается после анимированного вызова setContentOffset(:animated:)
.
И в самом конце показываем анимацию конфетти. Мы делаем это с помощью Lottie.
Результат на данном этапе выглядит так:
Делаем дрожание
Если пользователь вытянул акцию больше чем на 15%, то мы стартуем анимацию дрожания и вибрацию. Дрожание — это поворот относительно оси абсцисс на небольшой угол вверх и обратно вниз. Интенсивность дрожания и вибрации зависит от процента вытягивания: чем больше видно секретную акцию, тем сильнее она дрожит и тем ближе пользователь к её полному вытягиванию.
Значения подбирали эмпирическим путём. В итоге угол у нас изменяется относительно оси абсцисс от 1,4 до 1,9 градусов в обе стороны, а интенсивность вибрации — от 0,22 до 0,52.
После того, как мы рассчитали параметры, мы их обновляем и стартуем анимацию дрожания в ячейке. Для этого у нас два разных метода, так как анимацию нам не нужно стартовать на каждое изменение contentOffset
, а вот обновить её параметры нужно.
Анимацию дрожания мы разбили на 4 этапа:
Изменение угла вверх.
Возвращение в исходное положение.
Изменение угла вниз.
Возвращение в исходное положение.
Эта анимация зацикливается, пока не придёт сигнал о том, что её нужно остановить. Это происходит, когда пользователь полностью вытянул акцию.
Для удобной работы с анимацией завела такой enum
:
enum AnimationState {
case notStarted
case shaking
case shakingAndIncreasing
case decreasing
case ended
var isAnimationInProgress: Bool {
switch self {
case .notStarted, .ended:
false
case .shaking, .shakingAndIncreasing, .decreasing:
true
}
}
}
Анимацию всё так же делаем с помощью UIViewPropertyAnimator
:
var rotationAngle: CGFloat = 0
func startShakingAnimation() {
// Проверяем, что анимация ещё не началась
guard animationState == .notStarted else { return }
// Меняем состояние анимации на дрожание
animationState = .shaking
// Стартуем анимацию
animate()
func animate() {
guard animationState.isAnimationInProgress else { return }
// Этот animator отвечает за изменение угла вверх
let animator1 = makePropertyAnimator {
self.secretOfferCardView.transform = CGAffineTransform(rotationAngle: self.rotationAngle.toRadians)
}
// Этот animator отвечает за возвращение в исходное положение
let animator2 = makePropertyAnimator {
self.secretOfferCardView.transform = .identity
}
// Этот animator отвечает за изменение угла вниз
let animator3 = makePropertyAnimator {
self.secretOfferCardView.transform = CGAffineTransform(rotationAngle: -self.rotationAngle.toRadians)
}
// Этот animator отвечает за возвращение в исходное положение
let animator4 = makePropertyAnimator {
self.secretOfferCardView.transform = .identity
}
// Соединяем аниматоры через completion блоки
animator1.addCompletion { _ in animator2.startAnimation() }
animator2.addCompletion { _ in animator3.startAnimation() }
animator3.addCompletion { _ in animator4.startAnimation() }
animator4.addCompletion { _ in animate() }
// Стартуем самую первую анимацию
animator1.startAnimation()
}
// Вспомогательный метод, который создаёт animator с нужной анимацией
func makePropertyAnimator(with animations: @escaping (() -> Void)) -> UIViewPropertyAnimator {
UIViewPropertyAnimator(
duration: 0.05,
curve: .linear,
animations: animations
)
}
}
Если есть метод, который стартует анимацию, то должен быть и метод, который её останавливает. Напомню, что анимацию мы останавливаем в двух случаях: когда вытянули акцию успешно и когда перестали вытягивать акцию.
func stopShakingAnimation(secretOfferWasSuccessfullyPulled: Bool) {
if secretOfferWasSuccessfullyPulled {
// Если успешно вытянули акцию, то меняем состояние анимации на «дрожание и увеличение»
// А также стартуем анимацию отрывания кусочка секретной акции (про это будет дальше)
animationState = .shakingAndIncreasing
secretOfferCardView.startSeparationAnimation()
} else {
// Иначе сбрасываем состояние анимации на .notStarted
// Чтобы при следующей попытке вытянуть акцию мы снова могли начать анимацию дрожания
animationState = .notStarted
}
}
Вот как выглядит результат после добавления анимации дрожания:
Добавляем увеличение и уменьшение
После того, как пользователь вытянул анимацию, мы её немного увеличиваем (при этом акция продолжает дрожать), а потом возвращаем в исходное состояние. Для этого нам нужно изменить поле transform
для UIViewPropertyAnimator
. Сейчас это поле принимает 2 значения: либо преобразование для поворота, либо .identity
(сбрасывает все текущие преобразования).
В AnimationState
3 case отвечают за разную анимацию:
shaking (дрожание);
shakingAndIncreasing (дрожание и увеличение);
decreasing (уменьшение).
Именно эти состояния помогут нам правильно определить преобразования, которые нужно сделать с акцией.
Для состояния .shaking
у нас уже всё есть. Мы умеем применять поворот к акции. Для состояния .decreasing
мы тоже умеем менять масштаб, так как делали такое для показа кусочка секретной акции. А вот для состояния .shakingAndIncreasing
нам нужно научиться совмещать изменение поворота и масштаба акции. Для этого нам нужно создать два преобразования и объединить их.
let rotationTransform = CGAffineTransform(rotationAngle: rotationAngle.toRadians)
let scalingTranform = CGAffineTransform(scaleX: scale, y: scale)
let finalTranform = rotationTransform.concatenating(scalingTranform)
Добавляем 2 вспомогательных метода для создания преобразований в зависимости от состояния анимации. Первый метод нужен для создания изменения масштаба. Он вернёт преобразование, отличное от исходного масштаба, только для состояний .shakingAndIncreasing
и .decreasing
. Масштаб меняем с шагом 0.02, постепенно увеличивая от 1 до 1.12 и уменьшая от 1.12 до 1.
var currentScale: CGFloat = 1
func makeScalingTranform() -> CGAffineTransform {
let scaleStep = 0.02
if animationState == .shakingAndIncreasing,
currentScale < 1.12 {
currentScale += scaleStep
} else if currentScale > 1 {
currentScale -= 0.02
animationState = .decreasing
if currentScale == 1 {
animationState = .ended
}
}
return CGAffineTransform(scaleX: currentScale, y: currentScale)
}
Во втором методе мы возвращаем разные преобразования в зависимости от текущего состояния анимации.
// Вверх или вниз изменяем угол поворота
enum ShakeType {
case up
case down
}
func makeTransform(for shakeType: ShakeType) -> CGAffineTransform? {
let angle = shakeType == .up ? rotationAngle : -rotationAngle
let rotationTransform = CGAffineTransform(rotationAngle: angle.toRadians)
switch animationState {
case .notStarted, .ended:
return nil
case .shaking:
return rotationTransform
case .shakingAndIncreasing:
return rotationTransform.concatenating(makeScalingTranform())
case .decreasing:
return makeScalingTranform()
}
}
Вносим несколько изменений в метод animate()
. Меняем блок animations для всех animator.
let animator1 = makePropertyAnimator {
guard let transform = self.makeTransform(for: .up) else { return }
self.secretOfferCardView.transform = transform
}
let animator2 = makePropertyAnimator {
self.secretOfferCardView.transform = self.makeScalingTranform()
}
let animator3 = makePropertyAnimator {
guard let transform = self.makeTransform(for: .down) else { return }
self.secretOfferCardView.transform = transform
}
let animator4 = makePropertyAnimator {
self.secretOfferCardView.transform = self.makeScalingTranform()
}
Меняем completion блок для animator2
. Это нужно для того, чтобы после уменьшения акции и возвращения её к исходному размеру мы больше не запускали animator3
и animator4
. Нам не нужна больше никакая анимация, если мы достигли финального состояния. А это может произойти либо после завершения animator2
, либо после завершения animator4
.
animator2.addCompletion { _ in
if self.animationState != .ended {
animator3.startAnimation()
}
}
Результат на данном этапе выглядит так:
Добавляем вибрацию
На вибрациях подробно останавливаться не буду. Расскажу только о тех, что мы использовали для секретной акции:
во время вытягивания акции. Для этого мы рассчитываем интенсивность вибрации относительно процента вытягивания акции. Как я уже говорила выше, интенсивность вибрации при вытягивании меняется от 0,22 до 0,52. Здесь мы делаем кастомный
CHHapticEvent
сeventType: .hapticContinuous
, длительностью 0,1 и параметром.hapticIntensity
со значением интенсивности;на успешное вытягивание акции. Дефолтная реализация вибрации успеха нам не подошла, поэтому мы совместили несколько событий подряд с
eventType: .hapticTransient
и разными значениями для параметров.hapticIntensity
и.hapticSharpness
;-
на неуспешное вытягивание акции. Нам подошла дефолтная реализация вибрации ошибки, поэтому мы взяли её. Выглядит очень просто:
let feedback = UINotificationFeedbackGenerator() feedback.prepare() feedback.notificationOccurred(.error)
Добавляем отрывание кусочка секретной акции
После успешного вытягивания секретной акции одновременно с увеличением акции мы стартуем анимацию отрывания кусочка секретной акции. Анимацию по-прежнему делаем с помощью UIViewPropertyAnimator
.
func startSeparationAnimation() {
let isRTL = effectiveUserInterfaceLayoutDirection == .rightToLeft
let originalFrame = secretOfferCounterfoilView.frame
// Меняем anchorPoint — точку, относительно которой будет осуществляться поворот
// Для right-to-left языков — левый нижний угол
// Для left-to-right языков — правый нижний угол
secretOfferCounterfoilView.layer.anchorPoint = isRTL ? CGPoint(x: 0, y: 1) : CGPoint(x: 1, y: 1)
// После изменения anchorPoint слетает frame, поэтому возвращаем его на место
secretOfferCounterfoilView.frame = originalFrame
// Угол поворота тоже зависит от направления языка
let rotationAngle = isRTL ? 10 : -10
let rotationTranform = CGAffineTransform(rotationAngle: rotationAngle.toRadians)
// Первый animator отвечает за поворот отрывной части
let animator1 = UIViewPropertyAnimator(duration: 0.3, curve: .linear) {
self.secretOfferCounterfoilView.transform = rotationTranform
}
// Второй animator отвечает за перенос отрывной части за пределы экрана
let animator2 = UIViewPropertyAnimator(duration: 0.3, curve: .linear) {
// Указываем, на сколько нам нужно перенести отрывную часть
// После поворота меняется frame
// Поэтому нам хватает ширины секретной акции после поворота, чтобы она спряталась за видимой частью экрана
let xTranslation = isRTL ? self.secretOfferCounterFoilViewWidth : -self.secretOfferCounterFoilViewWidth
// Из-за изменения frame по оси ординат тоже нужно посчитать новый y
let yTranslataion = originalFrame.maxY - self.secretOfferCounterfoilView.frame.maxY
// Комбинируем поворот и перенос отрывной части
self.secretOfferCounterfoilView.transform = rotationTranform.translatedBy(x: xTranslation, y: yTranslataion)
}
animator1.addCompletion { _ in animator2.startAnimation() }
// После завершения анимации мы её скрываем и сбрасываем transform в .identity
animator2.addCompletion { _ in
self.secretOfferCounterfoilView.isHidden = true
self.secretOfferCounterfoilView.transform = .identity
}
animator1.startAnimation()
}
Делаем конфетти и переворот акции
После вытягивания акции мы сразу показываем конфетти. Эту анимацию мы сделали с помощью Lottie. Добавляем LottieAnimationView
поверх коллекции и запускаем анимацию с параметром LottieLoopMode.playOnce
, чтобы анимация проигралась только 1 раз. В completion метода play(completion:)
запускаем анимацию переворота акции.
Переворот акции нужен, чтобы из секретного состояния акция перешла в обычное с информацией о её условиях и сроках. Для открытой секретной акции мы оставляем особую картинку с шляпой, а также фиолетовую кнопку, чтобы выделить секретную акцию из других акций пользователя.
Сама анимация получается очень простой. Мы используем стандартный transition. Анимацию запускаем в ячейке с секретной акцией и применяем её к contentView
.
UIView.transition(
with: contentView,
duration: 0.3,
options: .transitionFlipFromBottom,
animations: {
// Скрываем секретное представление акции
secretOfferCardView.isHidden = true
// Показываем обычное представление акции
flippedSecretOfferCardView.isHidden = false
}
)
Вот такой финальный результат у нас получился:
Результаты
Весь процесс разработки фичи «секретная акция» занял около 3 месяцев. В это время я включила продуктовые и технические прожарки, согласование контрактов с бэкендом, согласование анимаций с дизайнером, написание кода и тестов, небольшой рефакторинг (куда же без него), тестирование и релиз приложения. Разрабатывать такую фичу было очень интересно, в процессе всплывали разные странности, так что местами было даже весело.
Кстати, о странностях. Во время разработки ловили неожиданное поведение на разных этапах. Вот несколько примеров.
-
из-за неверных расчётов отрывной край был разным вверху и внизу акции:
после вытягивания кусочек секретной акции улетал вниз экрана, а не оставался на одном уровне с акциями:
когда пользователь вытягивает акцию на 70%, мы её программно доскролливаем. Но если не прервать действие
panGestureRecognizer
, результат получится какой-то такой:
После релиза фичи мы проводили несколько тестов секретной акции. Результаты последнего теста такие:
применения выросли на 26%;
выручка выросла на 0,6%;
заказы выросли на 0,9%.
Также мы посчитали, насколько успешно пользователи справляются с задачей вытягивания акции:
54% пользователей вытягивают акцию на iOS;
69% пользователей вытягивают акцию на Android.
Мы учитываем только тех пользователей, которые увидели кусочек секретной акции и потом её вытянули. Это ожидаемые значения. Мы специально проектировали секретную акцию так, чтобы её было сложно вытянуть. Разница между iOS и Android получилась 15%, потому что на Android вытягивание получилось сделать чуть проще для пользователя, чем на iOS.
Кажется, у нашей «секретной акции» больше не осталось секретов. Ставьте плюсики статье, если тема коллекций вам интересна, рассказывайте, как вы их используете в своих проектах, и подписывайтесь на Telegram-канал Dodo Mobile. В нём мы активно делимся новостями мобильной разработке в «Додо».