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 TextEditor to both text and AttributedTextSelection.
  • Use transformAttributes(in: &selection) for formatting controls.
  • Avoid holding old AttributedString.Index values across mutations.

That is the difference between “I need rich text, time to wrap UIKit” and “this can stay in SwiftUI.”

Further reading: