Dunfey · Hotel WWDC as data, est. 1983
Front desk everything
Years
Topics

2025 SwiftSwiftUI & UI Frameworks

WWDC25 · 35 min · Swift / SwiftUI & UI Frameworks

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

Learn how to build a rich text experience with SwiftUI’s TextEditor API and AttributedString. Discover how you can enable rich text editing, build custom controls that manipulate the contents of your editor, and customize the formatting options available. Explore advanced capabilities of AttributedString that help you craft the best text editing experiences.

Watch at developer.apple.com ↗

Transcript all transcripts

Chapters

Code shown on screen · 22 snippets

TextEditor and String swift · at 1:15 ↗
import SwiftUI

struct RecipeEditor: View {
    @Binding var text: String

    var body: some View {
        TextEditor(text: $text)
    }
}
TextEditor and AttributedString swift · at 1:45 ↗
import SwiftUI

struct RecipeEditor: View {
    @Binding var text: AttributedString

    var body: some View {
        TextEditor(text: $text)
    }
}
AttributedString Basics swift · at 4:43 ↗
var text = AttributedString(
  "Hello 👋🏻! Who's ready to get "
)

var cooking = AttributedString("cooking")
cooking.foregroundColor = .orange
text += cooking

text += AttributedString("?")

text.font = .largeTitle
Build custom controls: Basics (initial attempt) swift · at 5:36 ↗
import SwiftUI

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())
    }
}
Slicing AttributedString with a Range swift · at 8:53 ↗
var text = AttributedString(
  "Hello 👋🏻! Who's ready to get cooking?"
)

guard let cookingRange = text.range(of: "cooking") else {
  fatalError("Unable to find range of cooking")
}

text[cookingRange].foregroundColor = .orange
Slicing AttributedString with a RangeSet swift · at 10:50 ↗
var text = AttributedString(
  "Hello 👋🏻! Who's ready to get cooking?"
)

let uppercaseRanges = text.characters
  .indices(where: \.isUppercase)

text[uppercaseRanges].foregroundColor = .blue
Build custom controls: Basics (fixed) swift · at 11:40 ↗
import SwiftUI

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]

        return IngredientSuggestion(
            suggestedName: AttributedString(name))
    }
}
Build custom controls: Recipe attribute swift · at 12:32 ↗
import SwiftUI

struct IngredientAttribute: CodableAttributedStringKey {
    typealias Value = Ingredient.ID

    static let name = "SampleRecipeEditor.IngredientAttribute"
}

extension AttributeScopes {
    /// An attribute scope for custom attributes defined by this app.
    struct CustomAttributes: AttributeScope {
        /// An attribute for marking text as a reference to an recipe's ingredient.
        let ingredient: IngredientAttribute
    }
}

extension AttributeDynamicLookup {
    /// The subscript for pulling custom attributes into the dynamic attribute lookup.
    ///
    /// This makes them available throughout the code using the name they have in the
    /// `AttributeScopes.CustomAttributes` scope.
    subscript<T: AttributedStringKey>(
        dynamicMember keyPath: KeyPath<AttributeScopes.CustomAttributes, T>
    ) -> T {
        self[T.self]
    }
}
Build custom controls: Modifying text (initial attempt) swift · at 12:56 ↗
import SwiftUI

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]

        return IngredientSuggestion(
            suggestedName: AttributedString(name),
            onApply: { ingredientId in
                let ranges = text.characters.ranges(of: name.characters)

                for range in ranges {
                    // modifying `text` without updating `selection` is invalid and resets the cursor 
                    text[range].ingredient = ingredientId
                }
            })
    }
}
AttributedString Character View swift · at 17:40 ↗
text.characters[index] // "👋🏻"
AttributedString Unicode Scalar View swift · at 17:44 ↗
text.unicodeScalars[index] // "👋"
AttributedString Runs View swift · at 17:49 ↗
text.runs[index] // "Hello 👋🏻! ..."
AttributedString UTF-8 View swift · at 18:13 ↗
text.utf8[index] // "240"
AttributedString UTF-16 View swift · at 18:17 ↗
text.utf16[index] // "55357"
Updating Indices during AttributedString Mutations swift · at 18:59 ↗
var text = AttributedString(
  "Hello 👋🏻! Who's ready to get cooking?"
)

guard var cookingRange = text.range(of: "cooking") else {
  fatalError("Unable to find range of cooking")
}

let originalRange = cookingRange
text.transform(updating: &cookingRange) { text in
  text[originalRange].foregroundColor = .orange
  
  let insertionPoint = text
    .index(text.startIndex, offsetByCharacters: 6)
  
  text.characters
    .insert(contentsOf: "chef ", at: insertionPoint)
}

print(text[cookingRange])
Build custom controls: Modifying text (fixed) swift · at 20:22 ↗
import SwiftUI

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]

        return IngredientSuggestion(
            suggestedName: AttributedString(name),
            onApply: { ingredientId in
                let ranges = RangeSet(text.characters.ranges(of: name.characters))

                text.transform(updating: &selection) { text in
                    text[ranges].ingredient = ingredientId
                }
            })
    }
}
Define your text format: RecipeFormattingDefinition Scope swift · at 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> {

    }
}

// pass the custom formatting definition to the TextEditor in the updated `RecipeEditor.body`:

        TextEditor(text: $text, selection: $selection)
            .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
            .attributedTextFormattingDefinition(RecipeFormattingDefinition())
Define your text format: AttributedTextValueConstraints swift · at 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
        }
    }
}

// list the value constraint in the recipe formatting definition's body:
    var body: some AttributedTextFormattingDefinition<Scope> {
        IngredientsAreGreen()
    }
AttributedStringKey Constraint: Inherited by Added Text swift · at 29:28 ↗
static let inheritedByAddedText = false
AttributedStringKey Constraint: Invalidation Conditions swift · at 30:12 ↗
static let invalidationConditions:
  Set<AttributedString.AttributeInvalidationCondition>? =
  [.textChanged]
AttributedStringKey Constraint: Run Boundaries swift · at 31:25 ↗
static let runBoundaries:
  AttributedString.AttributeRunBoundaries? =
  .paragraph
Define your text format: AttributedStringKey Constraints swift · at 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]
}

Resources