
Привет, Хабр! Меня зовут Кристина, я разрабатываю мобильное приложение «Додо Пиццы» для 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. В нём мы активно делимся новостями мобильной разработке в «Додо».
ссылка на оригинал статьи https://habr.com/ru/articles/855560/

Добавить комментарий