This document explains how IcySky implements real-time pattern detection (for @mentions, #hashtags, and URLs) in a TextEditor using iOS 26's AttributedString APIs. If you're trying to build similar functionality, this guide will help you understand the approach and avoid common pitfalls.
When users type in a composer, we want to:
- Automatically detect and highlight @mentions (e.g.,
@johnin purple) - Automatically detect and highlight #hashtags (e.g.,
#swiftin indigo) - Automatically detect and underline URLs (e.g.,
https://example.comin blue) - Do this in real-time as the user types, without any manual intervention
First, we define what patterns we're looking for:
enum ComposerTextPattern: String, CaseIterable, Codable {
case hashtag
case mention
case url
var pattern: String {
switch self {
case .hashtag: return "#\\w+"
case .mention: return "@[\\w.-]+"
case .url: return "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)"
}
}
var color: Color {
switch self {
case .hashtag: return .purple
case .mention: return .indigo
case .url: return .blue
}
}
}We create a custom attribute to mark text with its pattern type:
struct TextPatternAttribute: CodableAttributedStringKey {
typealias Value = ComposerTextPattern
static let name = "IcySky.TextPatternAttribute"
static let inheritedByAddedText: Bool = false
}Here's where the magic happens - and where we solve a critical problem:
func processText(_ text: inout AttributedString) {
// CRITICAL: Create a fresh AttributedString from plain text
let plainString = String(text.characters)
var freshText = AttributedString(plainString)
// Find all pattern matches
for match in plainString.matches(of: combinedRegex) {
// Apply pattern attribute to fresh text
freshText[matchStart..<matchEnd][TextPatternAttribute.self] = patternType
}
// Replace entire text with fresh version
text = freshText
}We use iOS 26's AttributedTextFormattingDefinition to automatically apply colors:
struct PatternColorConstraint: AttributedTextValueConstraint {
func constrain(_ container: inout Attributes) {
if let pattern = container.textPattern {
container.foregroundColor = pattern.color
}
}
}Let's trace through what happens when a user types @john:
- TextEditor triggers
onChange - Processor creates fresh AttributedString:
"@" - Regex matches
@as a mention pattern - Applies mention attribute to entire string
- Formatting constraint colors it indigo
- User sees: @
- TextEditor triggers
onChange - Problem: TextEditor has created fragmented runs:
[@][j] - Solution: Processor creates fresh AttributedString:
"@j" - Regex matches
@jas a mention pattern - Applies mention attribute to entire string
- User sees: @j (all indigo)
- Each keystroke repeats the process
- Fresh AttributedString prevents fragmentation
- Pattern stays highlighted consistently
- Text is now
@john - Regex still matches
@john(space isn't part of pattern) - Only
@johnis highlighted, space remains normal
When you type in a TextEditor with AttributedString:
- TextEditor creates individual runs for each character as you type
- If you try to modify attributes in-place, you get fragmented styling
- This causes only the last character to be highlighted
Example of fragmentation:
Text: "@john"
Runs: [@] [j] [o] [h] [n] // Each character is a separate run
By creating a fresh AttributedString:
- We start with a clean slate each time
- All characters in a pattern get the same attribute
- No fragmentation issues
Result:
Text: "@john"
Runs: [@john] // Single run with consistent attributes
.onChange(of: text) { oldValue, newValue in
processor.processText(&text)
}NavigationStack {
ComposerTextEditorView(...)
}
.attributedTextFormattingDefinition(ComposerFormattingDefinition())- Patterns include single characters (
@,#) for immediate feedback - Use regex alternation to check all patterns at once
- Match patterns using simple prefix checks
- Don't try to modify attributes in-place - TextEditor's fragmentation will cause issues
- Don't clear individual runs - Create a fresh AttributedString instead
- Don't forget in-progress patterns - Include
@and#alone in your regex - Don't apply visual styling directly - Use AttributedTextFormattingDefinition constraints
- Debouncing: For large texts, consider debouncing the processing
- Incremental Updates: For very large documents, process only changed portions
- Regex Optimization: Compile regex once and reuse
Apple's recipe editor sample uses a different approach because:
- They mark text manually (user selects and marks as ingredient)
- Attributes persist naturally without automatic detection
- No need to rebuild AttributedString
Our use case requires automatic detection, which necessitates the fresh AttributedString approach.
The key insight is that automatic pattern detection during typing requires working against TextEditor's natural behavior of creating fragmented runs. By creating a fresh AttributedString on each update, we ensure consistent highlighting of patterns as users type.
This approach enables a smooth, real-time highlighting experience for @mentions, #hashtags, and URLs in a SwiftUI TextEditor.