Разработка
Использование 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.
-
Аналитика магазинов4 недели назад
Мобильный рынок Ближнего Востока: исследование Bidease и Sensor Tower выявляет драйверы роста
-
Видео и подкасты для разработчиков3 недели назад
Разбор кода: iOS-приложение для управления личными финансами на Swift. Часть 1
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.47
-
Разработка4 недели назад
100 уроков о том, как я довёл своё приложение до продажи за семизначную сумму


