Connect with us

Разработка

Использование TextRenderer для создания выделенного текста

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

Опубликовано

/

     
     

TextRenderer — это новый протокол, представленный на WWDC 2024, который позволяет нам улучшить отображение текста в SwiftUI. В этой небольшой заметке я хочу показать, как создать представление, позволяющее выделять определенные части заданной строки. Раньше для этого в основном использовалась NSAttributedString, но с TextRenderer появилась возможность делать то же самое в чистом SwiftUI.

Настройка TextRenderer

Прежде чем приступить к реализации собственно TextRenderer, нам нужно указать рендеру, какие части текста должны быть выделены. Для этого мы используем тип TextAttribute. Его можно рассматривать как простой маркер, который можно прикрепить к экземплярам представления Text. Он будет считываться во время рендеринга, чтобы сделать определенные изменения в тексте. Ему не нужна реальная реализация, просто тип, соответствующий протоколу.

struct HighlightAttribute: TextAttribute {}

Далее мы можем создать тип HighlightTextRenderer, который будет заниматься рендерингом текста. Для изменения стиля выделенного текста рендерер будет содержать ссылку на ShapeStyle, который можно использовать для изменения вида выделения.

struct HighlightTextRenderer: TextRenderer {
 
    // MARK: - Private Properties
    private let style: any ShapeStyle
 
    // MARK: - Initializer
    init(style: any ShapeStyle = .yellow) {
        self.style = style
    }
 
    // MARK : - TextRenderer
    func draw(layout: Text.Layout, in context: inout GraphicsContext) { }
}

Для более эффективного доступа к разметке текста и отдельным строкам, прогонам и фрагментам прогонов мы добавили два расширения Text.Layout, которые вы также можете найти в примере кода Apples здесь:

extension Text.Layout {
    /// A helper function for easier access to all runs in a layout.
    var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> {
        self.flatMap { line in
            line
        }
    }
}

Сделав это, мы приступаем к реализации метода draw(layout:in:) нашего рендерера.

func draw(layout: Text.Layout, in context: inout GraphicsContext) {
    for run in layout.flattenedRuns {
        if run[HighlightAttribute.self] != nil {
 
            // The rect of the current run
            let rect = run.typographicBounds.rect
 
            // Make a copy of the context so that individual slices
            // don't affect each other.
            let copy = context
 
            // Shape of the highlight, can be customised
            let shape = RoundedRectangle(cornerRadius: 4, style: .continuous).path(in: rect)
 
            // Style the shape
            copy.fill(shape, with: .style(style))
 
            // Draw
            copy.draw(run)
        } else {
            let copy = context
            copy.draw(run)
        }
    }
}

Давайте подробнее рассмотрим, что здесь происходит. Во-первых, мы проверяем, подключен ли к текущему прогону наш пользовательский TextAttribute. Если нет, то мы просто отрисовываем текст без каких-либо изменений. Если атрибут TextAttribute подключен, мы переходим к изменению рендеринга. Мы получаем rect, в котором будет нарисован текст. Затем мы определяем форму выделения. Наконец, мы заполняем форму указанным стилем и рисуем ее на экране.

Теперь мы подготовили наш рендерер текста — давайте посмотрим, как его использовать.

Использование

struct TextRendererTest: View {
    var body: some View {
        let highlight = Text("World")
            .customAttribute(HighlightAttribute())
 
        Text("Hello \(highlight)").textRenderer(HighlightTextRenderer())
    }
}

Нам нужны только два модификатора: customAttribute(_:), чтобы прикрепить атрибут highlight, и textRenderer(_:), чтобы использовать HighlightTextRenderer.

Использование TextRenderer для создания выделенного текста

Представление с выделением

Заложив основы, мы теперь можем объединить логику выделения текста в специальное представление, чтобы сделать ее более удобной для повторного использования. Представление HighlightedText будет состоять из основного текста и той части текста, которая должна быть выделена. Если текст не выделяется, это свойство будет равно nil.

struct HighlightedText: View {
 
    // MARK: - Private Properties
    private let text: String
    private let highlightedText: String?
    private let shapeStyle: (any ShapeStyle)?
 
    // MARK: - Initializer
    init(text: String, highlightedText: String? = nil, shapeStyle: (any ShapeStyle)? = nil) {
        self.text = text
        self.highlightedText = highlightedText
        self.shapeStyle = shapeStyle
 }
 
    var body: some View { }
}

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

  1. Получить все диапазоны символов highlightedText в пределах text
  2. Получить оставшиеся диапазоны, которые не покрыты в пункте 1

Чтобы выполнить эти требования, давайте реализуем два расширения String.

extension String {
    /// Find all ranges of the given substring
    ///
    /// - Parameters:
    ///   - substring: The substring to find ranges for
    ///   - options: Compare options
    ///   - locale: Locale used for finding
    /// - Returns: Array of all ranges of the substring
    func ranges(of substring: String, options: CompareOptions = [], locale: Locale? = nil) -> [Range<Index>] {
        var ranges: [Range<Index>] = []
        while let range = range(of: substring, options: options, range: (ranges.last?.upperBound ?? self.startIndex)..<self.endIndex, locale: locale) {
            ranges.append(range)
        }
        return ranges
    }
 
 
    /// Find all remaining ranges given `ranges`
    ///
    /// - Parameters:
    ///   - ranges: A set of ranges
    /// - Returns: All the ranges that are not part of `ranges`
    func remainingRanges(from ranges: [Range<Index>]) -> [Range<Index>] {
        var result = [Range<Index>]()
 
        // Sort the input ranges to process them in order
        let sortedRanges = ranges.sorted { $0.lowerBound < $1.lowerBound }
 
        // Start from the beginning of the string
        var currentIndex = self.startIndex
 
        for range in sortedRanges {
            if currentIndex < range.lowerBound {
                // Add the range from currentIndex to the start of the current range
                result.append(currentIndex..<range.lowerBound)
            }
 
            // Move currentIndex to the end of the current range
            currentIndex = range.upperBound
        }
 
        // If there's remaining text after the last range, add it as well
        if currentIndex < self.endIndex {
            result.append(currentIndex..<self.endIndex)
        }
 
        return result
    }
}

После определения двух вспомогательных методов мы можем реализовать строки в HighlightedText. Мы определим небольшую вспомогательную структуру, которая содержит экземпляр Text и свойство range, описывающее позицию текста в исходном тексте.

fileprivate struct HighlightedTextComponent {
    let text: Text
    let range: Range<String.Index>
}

Теперь мы добавим метод, который будет по порядку извлекать массив HighlightedTextComponent из свойства text в HighlightedText.

struct HighlightedText: View {
    /// Extract the highlighted text components
    ///
    /// - Parameters
    ///     - highlight: The part to highlight
    /// - Returns: Array of highlighted text components
    private func highlightedTextComponent(from highlight: String) -> [HighlightedTextComponent] {
        let highlightRanges = text.ranges(of: highlight, options: .caseInsensitive)
        let remainingRanges = text.remainingRanges(from: highlightRanges)
 
        let highlightComponents = highlightRanges.map {
            HighlightedTextComponent(text: Text(text[$0]).customAttribute(HighlightAttribute()), range: $0)
        }
 
        let remainingComponents: [HighlightedTextComponent] = remainingRanges.map {
            HighlightedTextComponent(text: Text(text[$0]), range: $0)
        }
 
        return (highlightComponents + remainingComponents).sorted(by: { $0.range.lowerBound < $1.range.lowerBound  } )
    }
}

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

Последний шаг — реализация body. Это можно сделать простым итерационным перебором всех текстовых компонентов и связать HighlightTextRenderer с экземпляром Text, если необходимо отобразить выделение.

struct HighlightedText: View {
    var body: some View {
        if let highlightedText, !highlightedText.isEmpty {
            let text = highlightedTextComponent(from: highlightedText).reduce(Text("")) { partialResult, component in
                return partialResult + component.text
            }
            text.textRenderer(HighlightTextRenderer(style: shapeStyle ?? .yellow))
        } else {
            Text(text)
        }
    }
}

Итоговый файл HighlightedText.swift выглядит следующим образом:

struct HighlightedText: View {
 
    // MARK: - Private Properties
    private let text: String
    private let highlightedText: String?
    private let shapeStyle: (any ShapeStyle)?
 
    // MARK: - Initializer
    init(text: String, highlightedText: String? = nil, shapeStyle: (any ShapeStyle)? = nil) {
        self.text = text
        self.highlightedText = highlightedText
        self.shapeStyle = shapeStyle
    }
 
    // MARK: - Body
    var body: some View {
        if let highlightedText, !highlightedText.isEmpty {
            let text = highlightedTextComponent(from: highlightedText).reduce(Text("")) { partialResult, component in
                return partialResult + component.text
            }
            text.textRenderer(HighlightTextRenderer(style: shapeStyle ?? .yellow))
        } else {
            Text(text)
        }
    }
 
    /// Extract the highlighted text components
    ///
    /// - Parameters
    ///     - highlight: The part to highlight
    /// - Returns: Array of highlighted text components
    private func highlightedTextComponent(from highlight: String) -> [HighlightedTextComponent] {
        let highlightRanges: [HighlightedTextComponent] = text
            .ranges(of: highlight, options: .caseInsensitive)
            .map { HighlightedTextComponent(text: Text(text[$0]).customAttribute(HighlightAttribute()), range: $0)  }
 
        let remainingRanges = text
            .remainingRanges(from: highlightRanges.map(\.range))
            .map { HighlightedTextComponent(text: Text(text[$0]), range: $0)  }
 
        return (highlightRanges + remainingRanges).sorted(by: { $0.range.lowerBound < $1.range.lowerBound  } )
    }
}
 
fileprivate struct HighlightedTextComponent {
    let text: Text
    let range: Range<String.Index>
}

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

Заключение

В этой небольшой заметке я показал, как использовать новый протокол TextRenderer для реализации рендеринга текста. Позже мы увидели, как это можно использовать для создания нативного представления подсветки текста в SwiftUI.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.

Наши партнеры:

LEGALBET

Мобильные приложения для ставок на спорт
Хорошие новости

Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: