Всем привет! Я Леша, ведущий разработчик Совкомбанк Технологий, работаю над приложением Рокетбанка для 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 и кастомные стратегии анимации.
Содержание
Структура MatrixAnimation
Верстка
Анимация появления
Анимация скрытия
Выполнение анимации
Итог
Структура
Для анимации создан отдельный класс 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.
Итог
В итоге у меня получилась следующая анимация:
Подведу итоги:
Для анимаций в приложении я больше советую использовать инструмент Lottie — он быстрый и универсальный, но если нужно более конфигурируемое и масштабируемое решение, то лучше смотреть в сторону собственного решения.
Решение можно развивать: улучшить производительность, реализовать известную анимацию как в фильме «Матрица» и другое.
Надеюсь, данная статья будет полезна мобильным разработчикам. Если у вас тоже был похожий опыт работы с анимацией — делитесь им в комментариях, буду рад обсудить ваши истории и ответить на вопросы.