Секретная акция, или Как выйти за пределы UICollectionView

от автора

Привет, Хабр! Меня зовут Кристина, я разрабатываю мобильное приложение «Додо Пиццы» для 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 градусов.

Обратите внимание на то, как расположены значения на тригонометрической окружности. В школьной программе значение\frac{π}{2}было вверху окружности, а\frac{3π}{2}— внизу. У Apple наоборот: значение\frac{π}{2}внизу окружности, а\frac{3π}{2}— вверху. У 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, а по нему мы можем проверить, какая именно акция сейчас на экране.

Если на экране у пользователя последняя обычная акция, то мы показываем анимацию. Чтобы она отобразилась, нужно:

  1. Визуально сместить видимую часть коллекции.

  2. Увеличить и обратно уменьшить ячейку с секретной акцией.

  3. Вернуть смещение коллекции на прежнее значение.

Для более удобной работы с состоянием анимации я завела такой 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 этапа:

  1. Изменение угла вверх.

  2. Возвращение в исходное положение.

  3. Изменение угла вниз.

  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/