Rich Text Editing in SwiftUI with AttributedString and Selection
The Xcode 26 and iOS 26 SwiftUI betas expand TextEditor toward rich text built with AttributedString and selection state, while concrete signatures can still change. Before building a row of bold, italic, and color buttons, define what the document is allowed to represent, how a selection changes, how formatting becomes one undoable operation, and whether saved content remains readable across application versions.
The document is not toolbar state
A highlighted bold button is not the source of truth. The selected document attributes are: every selected run may be bold, none may be bold, or the selection may contain a mixture. A document model owns rich text, a selection model identifies an insertion point or range, and formatting commands read both to create one edit. Keyboard shortcuts, menus, and toolbar controls can then invoke the same intent.
struct NoteEditor: View {
@State private var text = AttributedString("Start writing…")
@State private var selection: TextSelection?
var body: some View {
TextEditor(text: $text, selection: $selection)
.toolbar {
ToolbarItemGroup {
Button("Bold", systemImage: "bold") {
applyBold(to: selection, in: &text)
}
Button("Italic", systemImage: "italic") {
applyItalic(to: selection, in: &text)
}
}
}
}
}
The sample illustrates the architecture, and its beta types and initializers must be checked against the installed SDK. applyBold should be a testable document operation rather than logic coupled to a SwiftUI view. With no range selected, a product might update typing attributes or format the current word. Either behavior can work, but it must be consistent and made clear through control state and help.
Admit only attributes the product supports
AttributedString can carry many attributes. That does not mean a file format should persist arbitrary fonts, colors, and attachments. A notes application may need headings, emphasis, links, and lists. A comment field may permit only emphasis and code. An allowlist makes rendering, export, search, synchronization, and future migrations tractable.
Normalize pasted and imported content. Preserve supported semantics, remove unknown attributes, and map absolute font sizes to product text styles. Validate link schemes. Check attachment type, size, and storage destination. Rich text from an external source is untrusted input; presentation must not execute behavior smuggled through a link or custom attribute.
Separate semantic roles from appearance when possible. A heading should be stored as a heading concept rather than only as a 24-point bold font. That decision lets Dynamic Type, themes, exports, and a future redesign produce an appropriate appearance without rewriting the document.
Respect Unicode ranges
Do not convert rich-text ranges from the number of characters a person sees or from arbitrary UTF-16 offsets. Emoji, combining marks, and different scripts make “character number” ambiguous. Attribute changes should use AttributedString’s own indices, and persistent annotations should anchor to stable document semantics. An edit can invalidate old indices, so do not cache a range across unrelated mutations.
Test with emoji, family sequences, accented characters assembled from combining marks, Chinese, Arabic, newlines, and bidirectional text. A command that styles half of a visible grapheme probably crossed the wrong indexing boundary. Also test replacement and paste around attribute runs; a collapsed selection at a boundary needs an intentional typing-style rule.
Undo, autosave, and collaboration are separate systems
One tap on Bold should usually create one undo step, not one step per affected attribute run. Typing coalescence, formatting, and paste need explicit transaction boundaries. Autosave persists accepted document snapshots. It should not make disk writes block every keystroke; debounce at the model layer and explicitly flush when the scene backgrounds or the document closes.
Collaborative editing needs operation identities, conflict policy, or a purpose-built collaborative structure. Binding an AttributedString does not solve concurrent edits. Establish deterministic local editing, undo, and serialization before adding collaboration, so an ephemeral UI range never becomes an accidental network protocol.
Version the stored representation. Decode old documents into the current allowlisted model, preserve unknown data only if the compatibility policy requires it, and test round trips. HTML, Markdown, RTF, and a native archive each lose or reinterpret different attributes; choose an export contract deliberately instead of assuming visual equivalence.
Make formatting controls accessible
Every format command needs a readable label, selected or mixed state, and an adequate hit region. Hardware-keyboard users need conventional shortcuts and predictable focus. Custom sizes should scale from semantic text styles under Dynamic Type; implementing a heading as one fixed large font is not enough. VoiceOver should identify links and meaningful structure, but announcing every attribute transition can make continuous reading unusable, so inspect realistic documents.
Test the editor below the UI
Unit tests should apply commands to known selections and compare semantic runs, including mixed selections, empty documents, Unicode boundaries, and imported unsupported attributes. Test undo and redo as sequences. UI tests can then focus on selection, shortcut routing, focus, and the platform editor integration. This balance keeps most tests stable if a beta control changes its layout.
A maintainable rich-text editor begins with a constrained document model. Selection is input, formatting is a testable command, attributes have an allowlist, Unicode indices remain native, and undo and persistence have explicit boundaries. Isolate the beta API in the editor module; even if the final SDK adjusts its surface, the document semantics and tests can remain intact.