Build A Rich Text Editor With SwiftUI And AttributedString
For a long time, the moment a SwiftUI app needed rich text editing, we had to leave the cozy part of SwiftUI and wrap UITextView or NSTextView. That still works, but it also means coordinators, delegate plumbing, selection bookkeeping, and a lot of little platform differences.
The newer SwiftUI TextEditor API can bind directly to an AttributedString. Even better, it can bind to an AttributedTextSelection, which gives your toolbar enough context to format the current selection without guessing where the cursor is.
The AttributedString TextEditor initializer is available on iOS 26, iPadOS 26, macOS 26, and visionOS 26. It is unavailable on tvOS and watchOS. If your app supports older OS versions, put this editor in a 26-only view and choose it from an outer if #available fallback.
A basic attributed editor
Start with an AttributedString instead of a String:
import SwiftUI
@available(iOS 26.0, macOS 26.0, visionOS 26.0, *)
struct RichNoteEditor: View {
@State private var note = AttributedString("Select some text and make it bold.")
@State private var selection = AttributedTextSelection()
var body: some View {
VStack(spacing: 12) {
TextEditor(text: $note, selection: $selection)
.frame(minHeight: 320)
.textEditorStyle(.plain)
HStack {
Button("Bold") {
applyBold()
}
Button("Highlight") {
applyHighlight()
}
}
}
.padding()
}
private func applyBold() {
note.transformAttributes(in: &selection) { attributes in
let font = attributes.swiftUI.font ?? .body
attributes.swiftUI.font = font.bold()
}
}
private func applyHighlight() {
note.transformAttributes(in: &selection) { attributes in
attributes.swiftUI.backgroundColor = .yellow.opacity(0.35)
}
}
}
That is already a real rich text editor. The user can type, select text, use the system editing UI, and press your custom buttons to apply attributes to the selected range. The applyBold() example keeps the current SwiftUI font when one exists, then applies bold to it.
Why the selection matters
This is the important line:
TextEditor(text: $note, selection: $selection)
The editor keeps selection in sync with the current insertion point or selected text. Then transformAttributes(in: &selection) applies changes to that selection and updates the selection as the text changes.
That second part is easy to underrate. AttributedString indices are tied to the specific storage tree of a specific attributed string. If you save an index, mutate the text, and then use the old index later, you can get surprising behavior. Letting SwiftUI update the AttributedTextSelection for the mutation is the safer path.
Reading the selected text
Selection is not always a single range. Bidirectional text and other layout realities can produce multiple ranges. That is why the selection APIs do not just hand you one Range<AttributedString.Index>.
If you want to show a preview of the selected text, subscript the attributed string with the selection:
private var selectedText: AttributedString {
AttributedString(note[selection])
}
That gives you an AttributedString that represents the selected characters, including discontiguous selections.
Add one reusable formatter
Once you have the basic pattern, it is nicer to make each control describe the attribute change it wants:
private func applyToSelection(
_ update: (inout AttributeContainer) -> Void
) {
note.transformAttributes(in: &selection) { attributes in
update(&attributes)
}
}
Then your buttons can stay small:
Button("Orange") {
applyToSelection { attributes in
attributes.swiftUI.foregroundColor = .orange
}
}
Button("Title") {
applyToSelection { attributes in
attributes.swiftUI.font = .title2
}
}
For a real editor, this is where you can add font size, foreground color, background color, underline, strikethrough, and paragraph controls. You can also define custom attributes if your editor needs domain-specific meaning, like marking an ingredient, a token, or a mention.
Storing the content
AttributedString is a value type and supports Codable, which makes it much easier to persist than a platform view. For example, a tiny document model can keep the rich text directly:
struct NoteDocument: Codable, Identifiable {
var id: UUID
var title: String
var body: AttributedString
}
That does not mean every attribute will make sense forever across every renderer, but it is a much better starting point than trying to archive a text view. You get a Swift data model first, and the editor becomes the UI for changing it.
If you add custom attributes, make their keys conform to CodableAttributedStringKey when you expect them to round-trip through encoding and decoding. Then test persistence with the attribute scope your app uses.
The pattern
The core pattern is small:
- Store the text as
AttributedString. - Bind
TextEditorto both text andAttributedTextSelection. - Use
transformAttributes(in: &selection)for formatting controls. - Avoid holding old
AttributedString.Indexvalues across mutations.
That is the difference between “I need rich text, time to wrap UIKit” and “this can stay in SwiftUI.”
Further reading: