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

Комментарии (1)