TextEditor finally supports AttributedString with the iOS 26 SDK!

Inspired by the following WWDC25, I decided it was time to finally try it for my IcySky app!

Apple also released a very cool sample here:

As it's a social network app for BlueSky, it's a perfect candidate, as I need to highlight text like mentions, hashtags, and URLs. Until now, we had to wrap a UITextView in a SwiftUI representable in order to do that. Well, not anymore!

Here's the story of how I built automatic pattern detection that actually works, why you can't just follow Apple's documentation blindly, and how the new constraint-based formatting APIs are genuinely brilliant (once you figure out how to use them).

The New iOS 26 APIs: A Quick Primer

Before diving into the chaos, let's understand what iOS 26 brings to the table:

  1. AttributedTextFormattingDefinition: A protocol that defines how text can be styled in editors
  2. AttributedTextValueConstraint: Constraints that automatically transform attributes into visual styling
  3. AttributedTextSelection: Enhanced selection handling with support for typing attributes

The promise? Automatic, constraint-based text formatting. The reality? Well, let's see.

The goal was simple: as users type in IcySky's composer, automatically detect and highlight:

  • @mentions in purple
  • #hashtags in indigo
  • URLs with blue underlines

No manual selection. No buttons. Just type @dimillian and watch it turn purple in real-time. Simple, right?

Me: It was not simple.

The Problem: TextEditor Has Trust Issues

Here's what was hard to understand, a TextEditor with AttributedString creates a new run for every. single. character. you. type.

Let me show you in practice:

// What you think happens when you type "@john":
AttributedString: [@john] // One nice, clean run

// What actually happens:
AttributedString: [@][j][o][h][n] // Five separate runs!

Each character becomes its own isolated island. Try to apply attributes to this fragmented mess, and you'll only highlight the last character. I spent a bit of time (and Claude Code tokens and time) wondering why only the 'n' in @john was purple. While the session is clear on that, and Apple's sample code is good, it's working fine for them because they do it statically. Not as the user is typing.

None
Our goal

The Failed Attempts

Attempt 1: The Naive Approach

// DON'T DO THIS
.onChange(of: text) { _, _ in
    // Find patterns and apply attributes directly
    for range in text.ranges(of: /@\w+/) {
        text[range].foregroundColor = .purple
    }
}

Result: Only the last typed character gets colored. The rest stays black.

Attempt 2: The "Clear Everything" Approach

// ALSO DON'T DO THIS
text.removeAttribute(\.foregroundColor)
for range in patternRanges {
    text[range].foregroundColor = .purple
}

Result: Flickering mess. Attributes fight each other. Users think your app is broken.

The Solution: Nuclear Option

After much gnashing of teeth, I discovered the only reliable approach: burn it all down and rebuild.

func processText(_ text: inout AttributedString) {
    // Step 1: Extract plain text (goodbye, attributes!)
    let plainString = String(text.characters)
    
    // Step 2: Create fresh AttributedString
    var freshText = AttributedString(plainString)
    
    // Step 3: Find all patterns in plain text
    for match in plainString.matches(of: combinedRegex) {
        // Apply attributes to fresh text
        freshText[matchRange][TextPatternAttribute.self] = patternType
    }
    
    // Step 4: Nuclear replace
    text = freshText
}

Every keystroke, we create a brand new AttributedString. It's like renovating your house by demolishing it and rebuilding from scratch. Extreme? Yes. Works? Also yes.

The iOS 26 Transform API Alternative

iOS 26 actually provides a transform(updating:body:) method that promises to track selection through mutations, here is the dump from the Swift files because the documentation is not online for it yet:

@available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, *)
extension AttributedString {

    /// Tracks the location of the selection throughout the mutation closure,
    /// updating the selection so it represents the same effective locations
    /// after the mutation.
    ///
    /// - Note: If the mutation performed does not allow for tracking to succeed
    /// (such as replacing the provided inout variable with an entirely
    /// different `AttributedString`), the selection is reset to the fallback
    /// location at the end of the text.
    ///
    /// - Parameters:
    ///   - selection: The selection to track throughout the `body` closure.
    ///   - body: A mutating operation, or set of operations, to perform on the
    ///   value of `self`. The value of `self` is provided to the closure as an
    ///   `inout AttributedString` that the closure should mutate directly. Do
    ///   not capture the value of `self` in the provided closure - the closure
    ///   should mutate the provided `inout` copy.
    public mutating func transform<E>(updating selection: inout AttributedTextSelection, body: (inout AttributedString) throws(E) -> Void) throws(E) where E : Error
}
@State var selection = AttributedTextSelection()

text.transform(updating: &selection) { mutableText in
    // Mutate the text while preserving selection
    processor.processText(&mutableText)
}

In theory, this should maintain cursor position while we rebuild the AttributedString. In practice? For our use case of complete replacement, the selection tracking can't follow such drastic changes and falls back to placing the cursor at the end.

The verdict: For real-time pattern detection where we're rebuilding the entire string, managing selection manually works more reliably than the transform API. However, if you're making smaller, localized changes to the AttributedString, transform(updating:) could be invaluable.

The Architecture That Emerged

1. Pattern Definition

enum ComposerTextPattern: String, CaseIterable {
    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
        }
    }
}

2. Custom Attribute (The Secret Sauce)swift

struct TextPatternAttribute: CodableAttributedStringKey {
    typealias Value = ComposerTextPattern
    static let name = "IcySky.TextPatternAttribute"
    static let inheritedByAddedText: Bool = false // Critical!
}

extension AttributeScopes {
  struct ComposerAttributes: AttributeScope {
    let textPattern: TextPatternAttribute
    let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
    let underlineStyle: AttributeScopes.SwiftUIAttributes.UnderlineStyleAttribute
  }
}

extension AttributeDynamicLookup {
  subscript<T: AttributedStringKey>(
    dynamicMember keyPath: KeyPath<AttributeScopes.ComposerAttributes, T>
  ) -> T {
    self[T.self]
  }
}

Setting inheritedByAddedText to false prevents new characters from inheriting attributes. Trust me, you want this.

3. Visual Styling with Constraints

Here's where iOS 26 shines. Instead of manually setting colors, use AttributedTextFormattingDefinition:

/// The formatting definition for composer text
struct ComposerFormattingDefinition: AttributedTextFormattingDefinition {
  typealias Scope = AttributeScopes.ComposerAttributes
  
  var body: some AttributedTextFormattingDefinition<Scope> {
    PatternColorConstraint()
    URLUnderlineConstraint()
  }
}

/// Constraint that applies colors based on text patterns
struct PatternColorConstraint: AttributedTextValueConstraint {
  typealias Scope = AttributeScopes.ComposerAttributes
  typealias AttributeKey = AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
  
  func constrain(_ container: inout Attributes) {
    if let pattern = container.textPattern {
      container.foregroundColor = pattern.color
    } else {
      container.foregroundColor = nil
    }
  }
}

/// Constraint that underlines URLs
struct URLUnderlineConstraint: AttributedTextValueConstraint {
  typealias Scope = AttributeScopes.ComposerAttributes
  typealias AttributeKey = AttributeScopes.SwiftUIAttributes.UnderlineStyleAttribute
  
  func constrain(_ container: inout Attributes) {
    if container.textPattern == .url {
      container.underlineStyle = .single
    } else {
      container.underlineStyle = nil
    }
  }
}

Apply it at the parent level or the TextEditor directly:

NavigationStack {
    TextEditor(...)
     .attributedTextFormattingDefinition(ComposerFormattingDefinition())
}

The beauty of this approach? The constraints automatically apply to visible text, transforming your custom attributes into visual styling without manual intervention.

The Flow: A Play-by-Play

Let's trace what happens when someone types @john:

Keystroke 1: "@"

  1. TextEditor creates: [@]
  2. onChange fires
  3. We create fresh: "@"
  4. Regex matches @
  5. Apply mention attribute
  6. User sees purple @

Keystroke 2: "j" (total: "@j")

  1. TextEditor now has: [@][j] (fragmented!)
  2. onChange fires
  3. We create fresh: "@j"
  4. Regex matches @j
  5. Apply mention attribute to entire string
  6. User sees purple @j (both characters!)

Without the fresh AttributedString approach, only the 'j' would be purple.

Performance Considerations

Creating a new AttributedString on every keystroke sounds expensive, right? In practice:

  • For typical composer text (< 1000 characters), it's imperceptible
  • Modern devices handle it without breaking a sweat
  • The alternative (fighting fragmentation) is actually slower

For very large documents, you might want to debounce:

.onChange(of: text) { _, _ in
    debounceTimer?.invalidate()
    debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.3) { _ in
        processor.processText(&text)
    }
}

Why This Works (And Why It's Brilliant)

The combination of fresh AttributedString creation and constraint-based styling is powerful:

  1. Separation of Concerns: Pattern detection logic is separate from visual styling
  2. Automatic Application: Constraints apply only to visible text (performance win!)
  3. Composable: Add new patterns or styling rules without touching existing code
  4. Platform Consistent: Same constraints work across iOS, iPadOS, and macOS

The AttributedTextFormattingDefinition system is genuinely well-designed. It's the TextEditor fragmentation that forces the nuclear approach.

Advanced Tips

Selection Handling

iOS 26's new AttributedTextSelection lets you track typing attributes:

@State var selection = AttributedTextSelection()

TextEditor(text: $text, selection: $selection)
    .onChange(of: selection) { _, newSelection in
        // Get typing attributes at cursor
        let typingAttrs = newSelection.typingAttributes(in: text)
        // Useful for continuing styles when typing
    }

Performance Optimization

For large texts, consider processing only the visible range:

// Get selection indices to focus processing
let indices = selection.indices(in: text)
// Process only around current editing location

Multiple Constraint Application

You can compose complex formatting definitions:

var body: some AttributedTextFormattingDefinition<Scope> {
    PatternColorConstraint()
    URLUnderlineConstraint()
    // Constraints apply in order
    NoBlackOnBlackConstraint()
    EmojiSizeConstraint()
}

Lessons Learned

  1. TextEditor fragmentation is real β€” Every character typed creates a new run
  2. Don't fight the framework β€” Rebuilding is cleaner than patching
  3. Custom attributes are powerful β€” Use them instead of built-in attributes
  4. Constraints are your friend β€” Let the system handle visual styling
  5. Test with fast typers β€” They'll expose fragmentation issues quickly

The Code

Want to see the full implementation? Check out IcySky's ComposerUI module.

The Bottom Line

iOS 26's AttributedString support in TextEditor is powerful but has quirks. The fragmentation issue isn't documented anywhere (you're welcome), and the solution β€” while counterintuitive β€” works reliably.

Is rebuilding the entire AttributedString on every keystroke elegant? No. Does it work? Absolutely. Sometimes in software development, the brute force solution is the right solution.

Now if you'll excuse me, I need to go add support for :emoji: detection. Pray for me.

Happy Coding! πŸš€