Site icon AppTractor

Анимируем зачеркивание текста в SwiftUI

В процессе работы над одним из наших внутренних инструментов мы обнаружили, что в дизайне необходимо было «зачеркивать» выполненные задачи в списках дел, добавляя приложению немного игривости и физической выразительности. SwiftUI предоставляет модификатор зачеркивания, но он не анимируется. Для нашего инструмента мы хотели, чтобы создавалось ощущение физического рисования, когда текст зачеркивается от одного конца до другого. Сначала это казалось простой функцией, но при попытке реализовать её правильно всё оказалось гораздо сложнее.

Определение ограничений для поиска решения

Прежде чем сразу же приступать к поиску решения, я определила три ограничения, которым должен удовлетворять мой подход:

Первый раунд экспериментов

В моей первой попытке использовался Path в качестве оверлея для представления Text. Недостатком было то, что он не учитывал многострочный текст или динамический тип текста, поскольку у меня не было доступа к точной информации о ширине отображаемого текста.

Затем я попробовала наложение Canvas. Это показалось многообещающим — графический контекст позволял мне свободно рисовать, — но мне по-прежнему не хватало точной информации о отображаемом тексте, такой как высота строки, отступы и размер шрифта. Это работало для однострочного текста, но полностью ломалось, когда текст переносился на несколько строк.

В ходе поиска я вышла на Text.Layout, который предоставляет доступ к внутренней структуре уже отрендеренного Text-представления — с разбивкой на строки, текстовые отрезки и отдельные глифы. Также я использовала протокол TextRenderer, появившийся в iOS 17, который предоставляет и layout, и графический контекст через один обязательный метод.

func draw(layout: Text.Layout, in ctx: inout GraphicsContext)

Реализация проходит по каждой строке, вычисляет, какая часть зачёркивающей линии должна быть видна на основе значения _progress в диапазоне от 0 до 1, и отрисовывает её соответствующим образом. Прогресс накапливается между строками, благодаря чему линия рисуется непрерывно — переходя с одной строки на другую — вместо того чтобы анимироваться отдельно для каждой строки.

func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
    let totalLength = layout.reduce(0) { $0 + $1.typographicBounds.width }
    var accumulated: CGFloat = 0

    for line in layout {
        let bounds = line.typographicBounds
        let strikeWidth = min(max(totalLength * _progress - accumulated, 0), bounds.width)
         
        ctx.draw(line)

        if strikeWidth > 0 {
            let midY = bounds.rect.midY
            let startX = bounds.rect.minX
            ctx.stroke(
                Path { path in
                    path.move(to: CGPoint(x: startX, y: midY))
                    path.addLine(to: CGPoint(x: startX + strikeWidth, y: midY))
                },
                with: .color(color),
                lineWidth: lineWidth
            )
        }
        accumulated += bounds.width
    }
}

Соответствие Animatable позволяет SwiftUI интерполировать значение рендерера между состояниями, а не сразу переходить к конечному значению:

var animatableData: CGFloat {
    get { _progress }
    set { _progress = newValue }
}

После применения через модификатор textRenderer зачёркивание отлично анимировалось в представлениях Text. Но как только я попробовала использовать это с TextField, ничего не отрисовалось. Установка брейкпоинта показала, что метод draw вообще не вызывается — TextField не использует Text для рендеринга своего содержимого.

Неприятно, но не критично

Подход с TextRenderer по-прежнему удовлетворял всем требованиям для Text. Оставалось лишь придумать, как применить его к TextField.

Но что если я наложу Text поверх TextField? Text будет отображать анимированное зачёркивание, а его цвет можно сделать прозрачным (.foregroundColor(.clear)), чтобы текст из TextField оставался видимым под ним, а анимация — видимой сверху.

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

Чтобы синхронизировать переносы, я создала кастомный Layout, который принудительно задаёт одинаковую ширину для обоих представлений. Это гарантирует, что и TextField, и Text будут переносить строки в одном и том же месте, и зачёркивание будет точно совпадать с отображаемым текстом.

Компромисс — немного более узкий TextField, но для нашего сценария это оказалось приемлемо.

struct TextFieldOverlayLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        guard let primary = subviews.first else { return .zero }
        if primary.sizeThatFits(proposal).width.isZero {
            let secondIndex = subviews.index(after: 0)
            return subviews[secondIndex].sizeThatFits(proposal)
        } else {
            return primary.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil))
        }
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        guard subviews.count >= 2 else { return }
        for subview in subviews {
            subview.place(at: bounds.origin, proposal: ProposedViewSize(bounds.size))
        }
    }
}

В представлении разметка выглядела следующим образом:

TextFieldOverlayLayout {
    Text(store.description)
       .foregroundStyle(Color.clear)
        .font(.system(.body, design: .rounded, weight: .medium))
        .lineSpacing(-1)
        .allowsHitTesting(false)
        .accessibilityHidden(true)
        .textRenderer(StrikethroughRenderer(store.isComplete, color: .primary))
                       
   Textfield()
       .foregroundStyle(Color.primary)
       .font(.system(.body, design: .rounded, weight: .medium))
       .lineSpacing(-1)
}

Конечный результат

Финальная реализация потребовала исследований, нескольких тупиковых попыток и, в итоге, комбинации TextRenderer, прозрачного Text-оверлея и кастомного Layout, чтобы получить решение, удовлетворяющее всем ранее обозначенным требованиям. Может показаться, что это слишком много усилий ради небольшой детали, но именно такие детали, на мой взгляд, и заслуживают дополнительного времени. Ощущения от использования приложения не менее важны, чем его функциональность.

Источник

Exit mobile version