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 ↗Chapters
Code shown on screen · 22 snippets
TextEditor and String
import SwiftUI
struct RecipeEditor: View {
var text: String
var body: some View {
TextEditor(text: $text)
}
} TextEditor and AttributedString
import SwiftUI
struct RecipeEditor: View {
var text: AttributedString
var body: some View {
TextEditor(text: $text)
}
} AttributedString Basics
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)
import SwiftUI
struct RecipeEditor: View {
var text: AttributedString
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
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
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)
import SwiftUI
struct RecipeEditor: View {
var text: AttributedString
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
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)
import SwiftUI
struct RecipeEditor: View {
var text: AttributedString
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
text.characters[index] // "👋🏻" AttributedString Unicode Scalar View
text.unicodeScalars[index] // "👋" AttributedString Runs View
text.runs[index] // "Hello 👋🏻! ..." AttributedString UTF-8 View
text.utf8[index] // "240" AttributedString UTF-16 View
text.utf16[index] // "55357" Updating Indices during AttributedString Mutations
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)
import SwiftUI
struct RecipeEditor: View {
var text: AttributedString
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
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
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
static let inheritedByAddedText = false AttributedStringKey Constraint: Invalidation Conditions
static let invalidationConditions:
Set<AttributedString.AttributeInvalidationCondition>? =
[.textChanged] AttributedStringKey Constraint: Run Boundaries
static let runBoundaries:
AttributedString.AttributeRunBoundaries? =
.paragraph Define your text format: AttributedStringKey Constraints
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
Related sessions
-
21 min -
38 min -
34 min