Всем привет! Я Леша, ведущий разработчик Совкомбанк Технологий, работаю над приложением Рокетбанка для iOS, в том числе над модулем «Кредиты».

В этой статье хотел бы поделиться опытом реализации необычной и сложной анимации, которая есть в приложении банка. А именно матрицы чисел, которая появляется, когда пользователь входит в оформление цифровой кредитной карты. Состоит она из самой анимации, матрицы и анимации появления условий использования кредитной карты.

Этап планирования

На этапе планирования командой дизайна был предложен вот такой вариант анимации:

Первое, что пришло мне в голову, когда я увидел такую анимацию, было реализовать её с помощью Lottie.

Lottie — фреймворк для показа векторной анимации. Сама анимация делается обычно в Adobe After Effects, потом выгружается в JSON‑файл, загружается в приложение, а Lottie делает рендеринг и показывает её. Также фреймворк позволяет управлять анимацией и немного её конфигурировать. Это был самый простой и быстрый вариант — по началу я его и использовал. Но было пару моментов, в которых он не подходил. О них расскажу далее.

Вообще видео анимации, которое показано, — это не первая итерация. Первоначально цифры должны были постепенно переходить в текст, а сам текст должен был быть маской.

Первый прототип:

Но в нашем конкретном сценарии Lottie оказался неудобен из‑за динамического текста, адаптивной матрицы и необходимости полного контроля над алгоритмом. Плюс в первой итерации анимации для Lottie текст приходилось встраивать в json файл анимации. То есть при смене условий использования кредитной карты разработчикам приходилось бы загружать новый файл.

Также решение должно было подходить не только iOS, но Android: не стоит забывать об устройствах с широкими экранами, такие как Samsung Galaxy Fold.Там количество элементов, содержащихся в матрице, должно быть больше, иначе анимация будет смотреться не выигрышно.

Обдумав все эти моменты, я решил написать нативное решение — чтобы можно было легко адаптировать анимацию под разные экраны, менять цвет, длительность и остальное.

Мне самому было интересно «покопаться» в чём‑то большем, чем UIView.animate, и я решил сделать собственную реализацию. Решение старался сделать универсальное, чтобы оно подходило не только для этого конкретного кейса, но и для других анимаций с матрицей. Решение также планировалось переиспользовать на Android.

Краткое описание

Вместо Lottie я создал собственную реализацию анимации на основе CADisplayLink, где:

  • Матрица в виде двумерного массива из UILabel генерируется динамически по заданным размерам. Можно было бы вместо UILabel добавлять только CATextLayer,но посмотрев на перформанс, я не заметил каких‑то значимых улучшений, так что оставил UILabel.

  • Анимация управляется скриптами — последовательностями координат элементов.

  • Для скрытия используется гауссово распределение вероятностей, чтобы элементы исчезали «от центра».

Нормальное распределение (или гауссово распределение) — распределение вероятностей в статистике. Оно описывает случайную величину, значения которой чаще всего находятся около среднего, а чем дальше от среднего — тем реже они встречаются. График отлично подходит для реализации анимации, он дает ту плавность и естественность нужную для скрытия матрицы.

Анимация поддерживает реверс, разные типы easing и кастомные стратегии анимации.

Содержание

  1. Структура MatrixAnimation

  2. Верстка

  3. Анимация появления

  4. Анимация скрытия

  5. Выполнение анимации

  6. Итог

Структура

Для анимации создан отдельный класс MatrixAnimation:

class MatrixAnimation {
    typealias `Case` = String
    typealias Matrix = [[UIView]]

    var configuration: Configuration
    var matrix: Matrix

    func setup(for view: UIView) { ... }
    func animate(with case: `Case`) { ... }
}
extension MatrixAnimation {
    struct Configuration {
        let rows: Int
        let cols: Int

        let fillStrategy: any FillMatrixStrategy

        var cases: [String: MatrixCaseAnimation]

        var appearAnimationScript: Script? = nil 
        let appearAnimationScriptGenerator: ScriptGenerator
        let appearAnimationItemStrategy: AnimationItemStrategy
        let appearDuration: TimeInterval


        var disappearAnimationScript: Script? = nil 
        let disappearAnimationScriptGenerator: ScriptGenerator
        let disappearAnimationItemStrategy: AnimationItemStrategy
        let disapperDuration: TimeInterval
    }
}
  • configuration — настройки матрицы: количество строк и столбцов, стратегия заполнения элементами, и словарь cases, где ключ — действие, значение — MatrixCaseAnimation.

enum Easing {
    case linear
    case easeIn
    case easeOut
    case easeInOut

    func value(_ t: Double) -> Double {
        switch self {
        case .linear:
            return t
        case .easeIn:
            return t * t
        case .easeOut:
            return 1 - pow(1 - t, 2)
        case .easeInOut:
            return t < 0.5
                ? 2 * t * t
                : 1 - pow(-2 * t + 2, 2) / 2
        }
    }
}
extension MatrixAnimation {
    protocol MatrixCaseAnimation {
        var script: Script? { get set }
        var scriptGenerator: ScriptGenerator { get }
        var animationItemStrategy: AnimationItemStrategy { get }
        var duration: TimeInterval { get set }
        var easing: Easing { get set }
    }
}
struct Script {
    struct Item {
        let row: Int
        let col: Int
    }
    var items: [Item]
}
  • script — готовый скрипт на конкретную матрицу с последовательностью координат анимируемых элементов.

  • scriptGenerator — генератор скрипта по определённому алгоритму. Результат сохраняется, чтобы не тратить ресурсы на повторную генерацию.

  • animationItemStrategy — определяет, как анимировать конкретный элемент матрицы.

  • duration — длительность всей анимации.

Верстка

За верстку матрицы отвечает метод setup(for view: UIView):

func setup(for view: UIView) {
    let viewHeight = view.bounds.height / CGFloat(configuration.rows)
    let viewWidth = view.bounds.width / CGFloat(configuration.cols)

    let symbolMatrix = configuration.fillStrategy.fill()

    for rowIndex in 0..<configuration.rows {
        var row: [Element] = []
        for colIndex in 0..<configuration.cols {
            let symbol = symbolMatrix[rowIndex][colIndex]
            let itemView = createView(
                value: symbol,
                frame: CGRect(
                    x: CGFloat(colIndex) * viewWidth,
                    y: CGFloat(rowIndex) * viewHeight,
                    width: viewWidth,
                    height: viewHeight
                )
            )

            view.addSubview(itemView)
            row.append(itemView)
        }

        matrix.append(row)
    }
}
func fill() -> Matrix {
    var generator = SeededRandomNumberGenerator(
        seed: configuration.seed ?? UInt64.random(in: UInt64.min..<UInt64.max)
    )
    return (0..<configuration.rows).map { _ in
        (0..<configuration.cols).map { _ in
            Bool.random(using: &generator) ? "1" : "0"
        }
    }
}

Для того чтобы каждый раз при заходе на экран видеть одну и ту же последовательность анимации везде, где есть случайные последовательности, используем генерацию по сиду.

Seed — начальное значение, задающее последовательность псевдослучайных чисел. Одинаковый seed → одинаковая последовательность.

Генерация скрипта анимации

У анимации есть 2 части: показ и скрытие. Их поведение отличается друг от друга. Разберём по порядку каждую часть.

Анимация появления

Показ элементов матрицы происходит в случайном порядке. Тут нет ничего сложного.

Чтобы сгенерировать скрипт с таким поведением, надо перемешать индексы элементов и добавить их в массив items.

func generate(
    for matrix: Matrix
) -> MatrixAnimation.Script {
    let indices = (0..<matrix.rowCount * matrix.colCount).shuffled(using: &generator)
    let items = indices.map { index in
        let row = index / matrix.colCount
        let col = index % matrix.colCount
        return MatrixAnimation.Script.Item(row: row, col: col)
    }

    return .init(items: items)
}

Анимация скрытия

Тут уже посложнее. Элементы уходят в основном из центра, но хаотичность всё равно присутствует. Сделать точь‑в-точь как на примере довольно сложно, но мне и не требовалось — нужно было повторить основной паттерн этой анимации.

Решение — гауссово распределение.

Идея в следующем: пусть при появлении у элементов будет одинаковый шанс показа, а при скрытии элементы в середине матрицы будут иметь больший шанс на скрытие, чем крайние. Программа делает выборку по этим вероятностям и заносит их в скрипт.

Описание алгоритма

Берем строку из матрицы:

Подставляем график гауссового распределения для элементов строки. Коэффициенты для каждой строки будут разные — чем строка ближе к краю, тем больше у неё отклонение, то есть график будет более плоским и широким.

Исходя из графика строим таблицу вероятностей для каждого элемента. Для читаемости я написал большие показатели (в процентах), в действительности показатели нормализуются и сумма равна 100%

Делаем выборку с учетом процента каждого элемента и получаем порядок, по которому в строке будут скрываться символы.

Используем данный алгоритм во всех строках в матрице.

Теперь порядок скрытия элементов есть в каждой строке. Чтобы получить итоговый порядок — берем первые элементы из каждой полученной строки и перемешиваем их. В итоге получаем массив элементов матрицы, используя который получаем нужную анимацию.

func generate(
    for matrix: Matrix
) -> MatrixAnimation.Script {
    let probabilitiesFactors = generateCustomCurve(
        count: matrix.rowCount,
        minValue: Constants.minVerticalFactor,
        maxValue: Constants.maxVerticalFactor
    )

    var probabilitiesMatrix: [[Double]] = []

    for row in 0..<matrix.rowCount {
        probabilitiesMatrix.append(probabilities(
            for: matrix.colCount,
            intensity: probabilitiesFactors[row]
        ))
    }

    var orderMatrix: [[Int]] = []

    for probabilitiesRow in probabilitiesMatrix {
        let orderRow = probSample(probabilities: probabilitiesRow)
        orderMatrix.append(orderRow)
    }

    let verticalOrderMatrix = (0..<orderMatrix[0].count).map { columnIndex in
        orderMatrix.map { row in row[columnIndex] }
    }

    var result: [MatrixAnimation.Script.Item] = []
    var temp: [MatrixAnimation.Script.Item] = []
    for col in verticalOrderMatrix {
        temp = []
        for index in 0..<col.count {
            temp.append(.init(row: index, col: col[index]))
        }
        result.append(contentsOf: temp.shuffled(using: &generator))
    }

    return .init(items: result)
}

Выполнение анимаций

После получения нужного массива элементов, надо их анимировать. Я делал это с помощью CADisplayLink.

@MainActor
func animate(with caseAnimation: String) async {
    var script: Script?
    if let readyMadeScript = configuration.cases[caseAnimation]?.script {
        script = readyMadeScript
    } else {
        script = configuration.cases[caseAnimation]?.scriptGenerator.generate(for: matrix)
        configuration.cases[caseAnimation]?.script = script
    }
    guard let script else { return }
    await runScript(
        indexes: script.items,
        duration: configuration.appearDuration,
        easing: configuration.appearEasing,
        onShow: { [weak self] item in
            guard let self else { return }
            configuration.cases[caseAnimation]?.animationItemStrategy.animate(for: matrix[item.row][item.col].view)
        }
    )
}

func runScript(
    indexes: [Script.Item],
    duration: Double,
    easing: Easing,
    onShow: @escaping (Script.Item) -> Void
) async {
    finish()

    self.indexes = indexes
    self.duration = duration
    self.easing = easing
    self.showHandler = onShow
    self.lastShownIndex = -1

    await withCheckedContinuation { continuation in
        self.continuation = continuation
        self.startTime = CACurrentMediaTime()

        let link = CADisplayLink(
            target: self,
            selector: #selector(update)
        )
        link.add(to: .main, forMode: .common)
        self.displayLink = link
    }
}

@objc private func update() {
    let currentTime = CACurrentMediaTime()
    let elapsed = currentTime - startTime

    var progress = elapsed / duration
    progress = min(max(progress, 0), 1)

    let easedProgress = easing.value(progress)

    let targetIndex = Int(
        floor(easedProgress * Double(indexes.count))
    )

    guard targetIndex > lastShownIndex else { return }

    for i in (lastShownIndex + 1)..<min(targetIndex, indexes.count) {
        showHandler?(indexes[i])
    }

    lastShownIndex = targetIndex - 1

    if progress >= 1 {
        finish()
    }
}


  private func finish() {
       displayLink?.invalidate()
       displayLink = nil
       showHandler = nil
       lastShownIndex = -1
       let continuation = self.continuation
       self.continuation = nil
       continuation?.resume()
   }

В начале проверяем, есть ли закешированный скрипт — если нет, то генерируем его. После передаём параметры и добавляем CADisplayLink. Функция update() запускает анимацию тех элементов, которые должны быть выполнены согласно параметру easing.

Итог

В итоге у меня получилась следующая анимация:

Подведу итоги:

  1. Для анимаций в приложении я больше советую использовать инструмент Lottie — он быстрый и универсальный, но если нужно более конфигурируемое и масштабируемое решение, то лучше смотреть в сторону собственного решения.

  2. Решение можно развивать: улучшить производительность, реализовать известную анимацию как в фильме «Матрица» и другое.

Надеюсь, данная статья будет полезна мобильным разработчикам. Если у вас тоже был похожий опыт работы с анимацией — делитесь им в комментариях, буду рад обсудить ваши истории и ответить на вопросы.

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