WWDC Quick Look đź’“ By SwiftGGTeam
Code-along: Cook up a rich text experience in SwiftUI with AttributedString

Code-along: Cook up a rich text experience in SwiftUI with AttributedString

Watch original video

Highlight

A full walk-through of a recipe editor app, in three stages.


Core Content

Max is building a recipe editor app. The recipe list sits on the left, the TextEditor in the middle, the ingredient table on the right. The pain point is concrete: he wants the keywords (ingredient names) in the recipe to stand out, but TextEditor’s state type is String, which only stores plain text. Bold, font size, color, Genmoji — none of it works.

WWDC25 adds AttributedString support to SwiftUI’s TextEditor. Change the type of @Binding var text from String to AttributedString, and the editor instantly gains bold, italic, underline, strikethrough, custom fonts, font size, foreground color, background color, kerning, line spacing, baseline offset, Genmoji, line height, alignment, and writing direction. All attributes match SwiftUI’s non-editable Text — content edited inside TextEditor can be displayed directly with Text, no conversion needed.

The session is a code-along in three parts: part one upgrades String to AttributedString; part two builds a custom control so the user can select “butter,” tap a plus button, and register it as an ingredient; part three defines a custom text formatting rule that limits which attributes the TextEditor accepts.


Detailed Content

Stage 1: Upgrade the TextEditor

The starting point is the simplest TextEditor (01:15).

import SwiftUI

struct RecipeEditor: View {
    @Binding var text: String

    var body: some View {
        TextEditor(text: $text)
    }
}

Key points:

  • text is String, so it can only hold plain text.
  • TextEditor’s default toolbar shows no rich-text controls.

Change one type (01:45).

@Binding var text: AttributedString

Key points:

  • Once the type becomes AttributedString, the system UI shows bold, italic, and other formatting buttons automatically.
  • Font and Color are semantic attributes and support Dark Mode and Dynamic Type out of the box.
  • Genmoji from the keyboard works directly.

Stage 2: Build a custom control

The goal is to select text and pass a suggestion to the inspector through a PreferenceKey. The first attempt fails to compile (05:36).

struct RecipeEditor: View {
    @Binding var text: AttributedString
    @State private var selection = AttributedTextSelection()

    var body: some View {
        TextEditor(text: $text, selection: $selection)
            .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
    }

    private var newIngredientSuggestion: IngredientSuggestion {
        let name = text[selection.indices(in: text)] // build error
        return IngredientSuggestion(suggestedName: AttributedString())
    }
}

Key points:

  • selection has type AttributedTextSelection.
  • selection.indices(in: text) returns AttributedTextSelection.Indices, an enum whose second case stores a RangeSet.
  • Using that return value to subscript text directly does not type-check, so it does not compile.

Why a RangeSet rather than a single Range? Jeremy uses a Hebrew-with-English example (09:24) to explain: in bidirectional text, what the user sees as one continuous selection may span several non-contiguous ranges in storage. So SwiftUI represents the selection with a RangeSet.

The fixed version subscripts directly with the selection (11:40).

private var newIngredientSuggestion: IngredientSuggestion {
    let name = text[selection]
    return IngredientSuggestion(suggestedName: AttributedString(name))
}

Key points:

  • SwiftUI provides a subscript that slices directly with a selection.
  • text[selection] returns an AttributedSubstring that may be non-contiguous.
  • AttributedString(name) materializes it into a new AttributedString to pass to the inspector.

Next, define the custom attribute IngredientAttribute (12:32).

struct IngredientAttribute: CodableAttributedStringKey {
    typealias Value = Ingredient.ID
    static let name = "SampleRecipeEditor.IngredientAttribute"
}

extension AttributeScopes {
    struct CustomAttributes: AttributeScope {
        let ingredient: IngredientAttribute
    }
}

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

Key points:

  • CodableAttributedStringKey makes the attribute Codable-serializable.
  • Value is Ingredient.ID, which links the text to entries in the ingredient table.
  • AttributeScope brings the custom attribute into SwiftUI’s attribute system.
  • The AttributeDynamicLookup subscript lets you write text[range].ingredient = id directly.

When the plus button marks every “butter” as an ingredient, the text must change without disturbing the cursor (20:22).

onApply: { ingredientId in
    let ranges = RangeSet(text.characters.ranges(of: name.characters))
    text.transform(updating: &selection) { text in
        text[ranges].ingredient = ingredientId
    }
}

Key points:

  • text.characters.ranges(of:) finds every match.
  • Wrap all the ranges into one RangeSet and feed it to the subscript at once, instead of mutating text repeatedly inside a for loop.
  • transform(updating:) mutates the text inside the closure while letting the framework update the selection’s indices automatically — mutating the text without updating the selection resets the cursor.

Stage 3: Define the text format

The AttributedTextFormattingDefinition protocol declares which attributes the TextEditor accepts (22:03).

struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
    struct Scope: AttributeScope {
        let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
        let adaptiveImageGlyph: AttributeScopes.SwiftUIAttributes.AdaptiveImageGlyphAttribute
        let ingredient: IngredientAttribute
    }

    var body: some AttributedTextFormattingDefinition<Scope> {

    }
}

Key points:

  • Scope lists the set of attributes the TextEditor accepts; everything else is dropped.
  • Attach it to the TextEditor with the .attributedTextFormattingDefinition(RecipeFormattingDefinition()) modifier.

Add a value constraint: “only text marked as an ingredient may be green” (23:50).

struct IngredientsAreGreen: AttributedTextValueConstraint {
    typealias Scope = RecipeFormattingDefinition.Scope
    typealias AttributeKey = AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute

    func constrain(_ container: inout Attributes) {
        if container.ingredient != nil {
            container.foregroundColor = .green
        } else {
            container.foregroundColor = nil
        }
    }
}

Key points:

  • AttributedTextValueConstraint rewrites attributes before SwiftUI renders.
  • constrain takes an inout container: check whether the current run has the ingredient attribute; if so, force the color to green.
  • Put IngredientsAreGreen() inside body and the constraint takes effect.

Finally, add constraints to the custom attribute itself (32:46).

struct IngredientAttribute: CodableAttributedStringKey {
    typealias Value = Ingredient.ID
    static let name = "SampleRecipeEditor.IngredientAttribute"
    static let inheritedByAddedText: Bool = false
    static let invalidationConditions: Set<AttributedString.AttributeInvalidationCondition>? = [.textChanged]
}

Key points:

  • inheritedByAddedText = false: typing new characters after an ingredient marker does not extend the marker to the new characters.
  • invalidationConditions = [.textChanged]: when the underlying text changes, the attribute is cleared automatically, so the marker cannot drift out of sync with the text.
  • You can also set runBoundaries = .paragraph to align the attribute on paragraph boundaries.

Key Takeaways

1. Upgrade String fields to AttributedString

Why it matters: a one-line change to TextEditor brings bold, font size, color, Genmoji, Dark Mode, and Dynamic Type for free. How to start: find every TextEditor(text: $text) where text is a String, weigh the storage and serialization impact of switching to AttributedString (AttributedString itself is Codable), then replace them.

2. Use custom attributes for semantic markers

Why it matters: tags in a notes app, links in a rich-text editor, code blocks in a Markdown renderer, @-mentions in CRM notes — these are all “embed an object reference inside text” scenarios. They used to rely on regex or string concatenation, which is fragile and hard to maintain. Custom attributes on AttributedString are the native answer. How to start: define a CodableAttributedStringKey and set its Value to the ID of the object you want to reference. Always set inheritedByAddedText = false and invalidationConditions = [.textChanged], so newly typed characters do not inherit the marker and the attribute does not drift out of sync after the text changes.

3. Use AttributedTextFormattingDefinition to lock down editor style

Why it matters: the default TextEditor accepts every rich-text attribute, so a user can crank the font size to 200 or set the background to red. If your app needs visual consistency — for example, notes that have to look right when exported to email — you must restrict which attributes are allowed. How to start: declare an AttributeScope that lists only the attributes you want to support; then use AttributedTextValueConstraint to express cross-attribute rules (such as “title font size must be ≥18” or “code blocks must use a monospaced font”).

4. Prefer transform(updating:) when mutating AttributedString

Why it matters: mutating the text directly invalidates existing Range and selection indices, which makes the cursor jump and the selection break. Computing offsets by hand will almost always break with bidirectional text and emoji. How to start: wrap every text[range] = ... inside text.transform(updating: &selection) { text in ... } and let the framework track index drift. When making many edits at once, collect them into a RangeSet and assign once.


Comments

GitHub Issues · utterances