Разработка
Расширяем Text в SwiftUI с помощью динамической стилизации содержимого
Эти возможности и так хороши, но мне захотелось найти более простой способ добиться того же результата. И мне кажется, что я придумал кое-что довольно приятное.
В этой статье мы рассмотрим, как расширить SwiftUI Text
с помощью кастомизации любых частей его содержимого, как отдельных слов, так и более длинных сегментов.
Нативные возможности текста
Представление Text
в SwiftUI за прошедшие годы было расширено удивительными возможностями, и теперь оно позволяет нам делать многое другое, что изначально было невозможно, например, рендерить Markdown, как здесь:
Text("This is [Markdown](https://www.markdownguide.org) with *some* **formatting**") .foregroundStyle(.blue) .tint(.yellow) .font(.largeTitle)
Если мы просто передадим Markdown строку, Text правильно отобразит форматирование и ссылки с некоторыми параметрами стилизации.
Как видно из кода, вы можете применить foregroundStyle
для цвета текста, tint
для цвета ссылок, а также использовать синтаксис Markdown, чтобы сделать любую часть текста подчеркнутой, курсивной или жирной.
Несколько лет назад появилась возможность объединять несколько представлений Text
в одно представление, например, так:
Text("I") + Text(" **love** (❤️) ").foregroundStyle(.red) + Text("SwiftUI!")
В сочетании с Markdown это дает нам еще больше власти над тем, как будет отображаться наш текст, поскольку мы можем применять различные модификаторы вида к различным частям результирующего текста.
Эти возможности и так хороши, но мне захотелось найти более простой способ добиться того же результата. И мне кажется, что я придумал кое-что довольно приятное.
Замены текста
Давайте воспользуемся возможностью SwiftUI объединять несколько представлений текста, чтобы сделать одну или несколько замен при создании представления текста.
Я хочу иметь простой способ передачи одной замены и другой способ передачи нескольких замен.
Вот как я хочу, чтобы это выглядело:
public struct TextReplacementView: View { /// Creates a text view with a text and a single replacement. init( _ text: String, replace: String, with replacement: @escaping (String) -> Text ) { self.init(text, replacements: [replace: replacement]) } /// Creates a text view with a text and a multiple replacements. init( _ text: String, replacements: [String: (String) -> Text] ) { // Insert magic here } }
Затем я создал функцию processReplacements
, которая может найти одно или несколько совпадений в предоставленном тексте и заменить их на пользовательские представления Text
, используя подменные конструкторы текста:
private extension Text { /// Process the replacements in a deterministic way static func processReplacements( in text: String, with replacements: [String: (String) -> Text] ) -> Text { // Create a structure to track replacement positions struct Replacement { let range: Range<String.Index> let pattern: String let replacementFunc: (String) -> Text } // Find all occurrences of all patterns var allReplacements: [Replacement] = [] // Find text ranges for all specified replacements for (pattern, replacementFunc) in replacements { var searchRange = text.startIndex..<text.endIndex while let range = text.range(of: pattern, range: searchRange) { allReplacements.append(Replacement( range: range, pattern: pattern, replacementFunc: replacementFunc )) searchRange = range.upperBound..<text.endIndex } } // Sort replacements by position, then by length // Longer patterns are handled first to handle overlaps allReplacements.sort { first, second in if first.range.lowerBound != second.range.lowerBound { return first.range.lowerBound < second.range.lowerBound } return first.pattern.count > second.pattern.count } // Process the text with non-overlapping replacements var result = Text("") var currentIndex = text.startIndex // Remove overlapping replacements var validReplacements: [Replacement] = [] var lastEnd: String.Index? for replacement in allReplacements { if let lastEnd = lastEnd, replacement.range.lowerBound < lastEnd { continue // Skip overlapping replacement } validReplacements.append(replacement) lastEnd = replacement.range.upperBound } // Apply the valid replacements for replacement in validReplacements { // Add text before the replacement if currentIndex < replacement.range.lowerBound { let beforeText = text[currentIndex..<replacement.range.lowerBound] result = result + Text(String(beforeText)) } // Add the replacement result = result + replacement.replacementFunc(replacement.pattern) currentIndex = replacement.range.upperBound } // Add any remaining text if currentIndex < text.endIndex { let remainingText = text[currentIndex..<text.endIndex] result = result + Text(String(remainingText)) } return result } }
Функция перебирает все предоставленные замены, чтобы найти все совпадающие диапазоны в тексте, а затем выводит либо обычный Text
, либо Text
с заменами.
Результирующие инициализаторы Text
берут текст и одну или несколько замен (String) -> Text
, и теперь могут быть использованы очень просто как тут:
Text( "Some text", replace: "text", with: { Text($0).foregroundStyle(.red) } )
Или сложно, как тут:
Text( "TextReplacements is a SwiftUI library that extends the Text view with ways to customize any parts of its text. The result is a Text with customized segments that can flow nicely over multiple lines.", replacements: [ "TextReplacements": { Text($0) .font(.title) .fontWeight(.black) .fontDesign(.rounded) .foregroundColor(.green) }, "SwiftUI": { Text($0) .font(.headline) .fontWeight(.black) .fontDesign(.rounded) .foregroundColor(.blue) }, "Text": { Text($0) .fontWeight(.black) .fontDesign(.rounded) .foregroundColor(.black.opacity(0.6)) }, "customize": { Text($0) .italic() .underline() .font(.body) .fontWeight(.heavy) .fontDesign(.monospaced) .foregroundColor(.purple) }, "par": { Text($0) .font(.headline) .fontWeight(.black) .fontDesign(.rounded) .foregroundColor(.red) }, "can flow nicely over multiple lines": { Text($0) .foregroundColor(.orange) } ] ) .padding() #if os(visionOS) .frame(maxWidth: 350) .background(.ultraThickMaterial) .background(.white.opacity(0.5)) .clipShape(.rect(cornerRadius: 10)) .padding() .scaleEffect(2) #endif
В результате можно кастомизировать как одно слово, так и большой кусок текста, и он красиво перетекает из строи в строку:
Поскольку замены представляют собой билдеры (String) -> Text
, этот подход поддерживает только модификаторы представления, возвращающие Text
. Это означает, что модификаторы типа .backgroundStyle
не работают.
Если вы знаете способ обойти это или хотите исследовать его, просто дайте мне знать. Возможно, существуют модификаторы фона, генерирующие Text
, о которых я не знаю?
Заключение
Инициализаторы Text
, которые мы создали в этом посте, позволяют нам настраивать рендеринг любой части текста. Я создал проект с открытым исходным кодом, который позволит вам добавить эти функции в любое приложение.
Библиотека TextReplacements
работает на всех платформах, вплоть до iOS 13, а это значит, что она работает на iOS, macOS, tvOS, watchOS и visionOS.
Я надеюсь, что вам понравится использовать TextReplacements
. Хорошего стайлинга!
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.14
-
Разработка4 недели назад
«Давайте просто…»: системные идеи, которые звучат хорошо, но почти никогда не работают
-
Видео и подкасты для разработчиков4 недели назад
Исследуем мир фото и видео редакторов
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.13