Разработка
Использование 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
.
Представление с выделением
Заложив основы, мы теперь можем объединить логику выделения текста в специальное представление, чтобы сделать ее более удобной для повторного использования. Представление 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 { } }
Если задуматься о том, что означает выделение текста, то можно обобщить следующие два утверждения:
- Получить все диапазоны символов
highlightedText
в пределахtext
- Получить оставшиеся диапазоны, которые не покрыты в пункте 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.