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:
textisString, 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:
selectionhas typeAttributedTextSelection.selection.indices(in: text)returnsAttributedTextSelection.Indices, an enum whose second case stores aRangeSet.- Using that return value to subscript
textdirectly 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 anAttributedSubstringthat 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:
CodableAttributedStringKeymakes the attribute Codable-serializable.ValueisIngredient.ID, which links the text to entries in the ingredient table.AttributeScopebrings the custom attribute into SwiftUI’s attribute system.- The
AttributeDynamicLookupsubscript lets you writetext[range].ingredient = iddirectly.
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
RangeSetand feed it to the subscript at once, instead of mutatingtextrepeatedly 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:
Scopelists 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:
AttributedTextValueConstraintrewrites attributes before SwiftUI renders.constraintakes aninoutcontainer: check whether the current run has theingredientattribute; if so, force the color to green.- Put
IngredientsAreGreen()insidebodyand 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 = .paragraphto 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.
Related Sessions
- Embracing Swift concurrency — the core ideas of Swift’s concurrency model; useful for reasoning about the async behavior of selection alongside SwiftUI state updates.
- Explore Swift and Java interoperability — how to mix Swift and Java in the same codebase.
- Improve memory usage and performance with Swift — performance and memory tips for Swift code, worth a look for large AttributedString documents.
- Optimize SwiftUI performance with Instruments — the new SwiftUI Instrument for diagnosing rendering bottlenecks in TextEditor’s rich-text scenarios.
Comments
GitHub Issues · utterances