Before iOS 26, building a rich text editor in SwiftUI was, frankly, an exercise in frustration. You'd get tantalizingly close with TextEditor, then slam into a wall the moment you needed bold text, custom colors, or any kind of inline formatting. The workaround? Drop into UIKit, wrap UITextView with UIViewRepresentable, juggle NSAttributedString, wire up delegates, and hope nothing broke when SwiftUI decided to redraw.
That era is over.
With iOS 26, Apple gave TextEditor first-class support for AttributedString, along with a powerful trio of new APIs: AttributedTextSelection, FontResolutionContext, and transformAttributes(in:). Together, they let you build a fully featured rich text editor — complete with a formatting toolbar, toolbar state synchronization, and rich text persistence — entirely in SwiftUI. No UIKit. No bridging. Just Swift.
In this guide, you'll build a production-ready rich text editor from scratch. Every code example compiles and runs. By the end, you'll have a reusable component that handles bold, italic, underline, strikethrough, text color, font size, Markdown parsing, and saving to disk.
Prerequisites
- Xcode 26 or later
- iOS 26, macOS 26, or a compatible simulator
- Basic familiarity with SwiftUI views and state management
Understanding the Core APIs
Before writing any code, let's look at the four building blocks that make this possible.
AttributedString: The Foundation
AttributedString is a Swift-native, value-type string that carries rich formatting metadata — font, color, underline, background, links, and more. Unlike its Objective-C ancestor NSAttributedString, it's fully Codable, uses copy-on-write for performance, and integrates seamlessly with SwiftUI views.
When you bind an AttributedString to a TextEditor, the editor automatically supports system formatting shortcuts (Cmd+B for bold, Cmd+I for italic) and system menu controls — with zero additional code from you. Honestly, it just works.
AttributedTextSelection: Tracking the Cursor and Selection
AttributedTextSelection tracks where the user's cursor sits or what text range they've highlighted. Instead of exposing raw String.Index ranges that can drift out of sync when text changes, SwiftUI wraps selection state in a safe, always-consistent abstraction.
This type is essential for two things: applying formatting to exactly the text the user selected, and reading the current formatting at the cursor position to keep your toolbar in sync.
FontResolutionContext: Adaptive Font Intelligence
This one's a bit more subtle. FontResolutionContext is an environment value that resolves abstract SwiftUI fonts into their concrete traits — bold, italic, weight, size, and design. Fonts in SwiftUI are adaptive resources, not fixed values. They respond to Dynamic Type, accessibility settings, and platform-specific typography. Without FontResolutionContext, a bold toggle could behave inconsistently across devices and font configurations.
transformAttributes(in:): The Formatting Engine
transformAttributes(in:) modifies attributes on the selected text safely and efficiently. It takes an inout reference to the selection because the transformation might cause adjacent attribute runs to merge, requiring the selection boundaries to adjust. SwiftUI handles this automatically — you never worry about stale indices.
Step 1: A Minimal Rich Text Editor in Five Lines
The simplest possible rich text editor requires almost no code:
import SwiftUI
struct MinimalEditorView: View {
@State private var text = AttributedString("Start typing here...")
var body: some View {
TextEditor(text: $text)
.padding()
}
}
That's it. Seriously. By switching from String to AttributedString, you unlock built-in formatting support. Users can select text and use keyboard shortcuts (Cmd+B, Cmd+I, Cmd+U) or the system Edit menu to apply bold, italic, and underline. The attributes get captured directly in your AttributedString state.
But most apps need custom toolbar buttons, not just keyboard shortcuts. Let's build that next.
Step 2: Adding Selection Tracking
To build custom formatting controls, you need to know what text the user has selected. Add an AttributedTextSelection binding:
struct EditorWithSelectionView: View {
@State private var text = AttributedString("Select some text to format it.")
@State private var selection = AttributedTextSelection()
var body: some View {
TextEditor(text: $text, selection: $selection)
.padding()
}
}
The selection property now tracks the cursor position and any highlighted range in real time. You can inspect it to figure out what formatting is active and use it as the target when applying new formatting.
Step 3: Building the Formatting Toolbar
Here's where the real power shows up. You'll build a toolbar with bold, italic, underline, and strikethrough toggles that apply formatting to selected text and reflect the current state at the cursor position.
Bold and Italic Toggles
Both bold and italic require resolving the current font to check its traits, then flipping the relevant trait:
struct RichTextEditorView: View {
@Environment(\.fontResolutionContext) private var fontContext
@State private var text = AttributedString("Welcome to your rich text editor!")
@State private var selection = AttributedTextSelection()
var body: some View {
VStack(spacing: 0) {
toolbar
TextEditor(text: $text, selection: $selection)
.padding()
}
}
private var toolbar: some View {
HStack(spacing: 12) {
Button {
toggleBold()
} label: {
Image(systemName: "bold")
.fontWeight(isBold ? .bold : .regular)
.foregroundStyle(isBold ? .primary : .secondary)
}
Button {
toggleItalic()
} label: {
Image(systemName: "italic")
.fontWeight(isItalic ? .bold : .regular)
.foregroundStyle(isItalic ? .primary : .secondary)
}
Divider().frame(height: 20)
Button {
toggleUnderline()
} label: {
Image(systemName: "underline")
.foregroundStyle(isUnderlined ? .primary : .secondary)
}
Button {
toggleStrikethrough()
} label: {
Image(systemName: "strikethrough")
.foregroundStyle(isStrikethrough ? .primary : .secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
}
The toolbar uses computed properties to reflect the current state. Let's implement the toggle methods and state properties next.
Toggle Methods
Each toggle follows the same pattern: call transformAttributes(in:) with an inout reference to the selection, read the current attribute, and flip it:
// MARK: - Formatting Actions
extension RichTextEditorView {
private func toggleBold() {
text.transformAttributes(in: &selection) { container in
let font = container.font ?? .body
let resolved = font.resolve(in: fontContext)
container.font = font.bold(!resolved.isBold)
}
}
private func toggleItalic() {
text.transformAttributes(in: &selection) { container in
let font = container.font ?? .body
let resolved = font.resolve(in: fontContext)
container.font = font.italic(!resolved.isItalic)
}
}
private func toggleUnderline() {
text.transformAttributes(in: &selection) { container in
container.underlineStyle = container.underlineStyle == nil
? .single
: nil
}
}
private func toggleStrikethrough() {
text.transformAttributes(in: &selection) { container in
container.strikethroughStyle = container.strikethroughStyle == nil
? .single
: nil
}
}
}
Notice how underline and strikethrough are simpler than bold and italic. They don't require font resolution — they're standalone attributes on the container. Bold and italic, however, are traits of the font, which is why you need to resolve the font first.
Step 4: Keeping the Toolbar in Sync with typingAttributes
A formatting toolbar that doesn't reflect the current state is confusing (we've all used editors where the bold button lies to us). When the user moves their cursor into bold text, the Bold button should appear active. SwiftUI provides typingAttributes(in:) on AttributedTextSelection for exactly this.
Typing attributes represent the attributes that will be applied to any newly inserted text at the current cursor position. They update automatically as the user moves the cursor or changes the selection.
// MARK: - Toolbar State
extension RichTextEditorView {
private var isBold: Bool {
let attrs = selection.typingAttributes(in: text)
guard let font = attrs.font else { return false }
return font.resolve(in: fontContext).isBold
}
private var isItalic: Bool {
let attrs = selection.typingAttributes(in: text)
guard let font = attrs.font else { return false }
return font.resolve(in: fontContext).isItalic
}
private var isUnderlined: Bool {
selection.typingAttributes(in: text).underlineStyle != nil
}
private var isStrikethrough: Bool {
selection.typingAttributes(in: text).strikethroughStyle != nil
}
}
These computed properties re-evaluate whenever selection or text changes, so the toolbar always reflects reality. No manual state juggling. No NotificationCenter observers. Just pure SwiftUI reactivity.
Step 5: Adding Text Color and Font Size Controls
A rich text editor isn't complete without color and font size controls. Let's add both.
Foreground Color Picker
struct ColorPickerButton: View {
let text: Binding<AttributedString>
let selection: Binding<AttributedTextSelection>
@State private var selectedColor: Color = .primary
@State private var showingPicker = false
var body: some View {
Button {
showingPicker.toggle()
} label: {
Image(systemName: "paintbrush")
}
.popover(isPresented: $showingPicker) {
ColorPicker("Text Color", selection: $selectedColor)
.padding()
.onChange(of: selectedColor) {
applyColor()
}
}
}
private func applyColor() {
var sel = selection.wrappedValue
text.wrappedValue.transformAttributes(in: &sel) { container in
container.foregroundColor = selectedColor
}
selection.wrappedValue = sel
}
}
Font Size Stepper
struct FontSizeControl: View {
let text: Binding<AttributedString>
let selection: Binding<AttributedTextSelection>
let fontContext: FontResolutionContext
@State private var fontSize: Double = 17
var body: some View {
HStack(spacing: 4) {
Button {
adjustSize(by: -1)
} label: {
Image(systemName: "minus")
}
Text("\(Int(fontSize))")
.monospacedDigit()
.frame(width: 30)
Button {
adjustSize(by: 1)
} label: {
Image(systemName: "plus")
}
}
}
private func adjustSize(by delta: Double) {
fontSize = max(9, min(96, fontSize + delta))
var sel = selection.wrappedValue
text.wrappedValue.transformAttributes(in: &sel) { container in
let currentFont = container.font ?? .body
let resolved = currentFont.resolve(in: fontContext)
container.font = .system(size: fontSize, weight: resolved.isBold ? .bold : .regular)
}
selection.wrappedValue = sel
}
}
The font size control reads the current resolved size and adjusts by one point in either direction, clamped between 9 and 96 points. Same transformAttributes pattern — safe, inout-based attribute mutation that keeps everything in sync.
Step 6: Loading Markdown Content
AttributedString has built-in Markdown parsing, which makes it trivial to initialize your editor with pre-formatted content:
struct MarkdownEditorView: View {
@State private var text: AttributedString
@State private var selection = AttributedTextSelection()
init() {
let markdown = """
# Meeting Notes
**Date:** February 25, 2026
## Action Items
- *Review* the new API design
- ~~Cancel the old sprint~~ — already done
- Check the [documentation](https://developer.apple.com)
> This is a blockquote with **bold** and *italic* text.
"""
do {
_text = State(initialValue: try AttributedString(markdown: markdown))
} catch {
_text = State(initialValue: AttributedString(markdown))
}
}
var body: some View {
TextEditor(text: $text, selection: $selection)
.padding()
}
}
The Markdown parser supports bold, italic, strikethrough, links, inline code, and blockquotes. This is particularly useful for note-taking apps, documentation editors, or really any interface that accepts Markdown input from users or APIs.
Step 7: Persisting Rich Text
A rich text editor is only useful if users can save their work. Since AttributedString conforms to Codable, you've got a straightforward path to persistence.
JSON Encoding and Decoding
The simplest approach encodes the entire AttributedString as JSON data:
struct DocumentStorage {
private static let encoder = JSONEncoder()
private static let decoder = JSONDecoder()
static func save(_ text: AttributedString, to url: URL) throws {
let data = try encoder.encode(text)
try data.write(to: url, options: .atomic)
}
static func load(from url: URL) throws -> AttributedString {
let data = try Data(contentsOf: url)
return try decoder.decode(AttributedString.self, from: data)
}
}
This works well for local documents, draft storage, or syncing with CloudKit. The JSON representation preserves all formatting attributes including fonts, colors, underline styles, and links.
Integrating with SwiftData
For apps that use SwiftData for persistence, store the encoded data as a Data property on your model:
import SwiftData
@Model
class Document {
var title: String
var richTextData: Data?
var createdAt: Date
var updatedAt: Date
init(title: String, richTextData: Data? = nil) {
self.title = title
self.richTextData = richTextData
self.createdAt = .now
self.updatedAt = .now
}
var richText: AttributedString {
get {
guard let data = richTextData else {
return AttributedString()
}
return (try? JSONDecoder().decode(AttributedString.self, from: data))
?? AttributedString()
}
set {
richTextData = try? JSONEncoder().encode(newValue)
updatedAt = .now
}
}
}
The computed richText property gives you a clean API for reading and writing AttributedString while storing the raw Data in SwiftData. Much nicer than the old NSKeyedArchiver approach, if you remember that era.
Step 8: Building the Complete Reusable Component
So, let's bring everything together. Here's a single, reusable RichTextEditor component you can drop into any project:
struct RichTextEditor: View {
@Binding var text: AttributedString
@Environment(\.fontResolutionContext) private var fontContext
@State private var selection = AttributedTextSelection()
@State private var textColor: Color = .primary
@State private var showColorPicker = false
var body: some View {
VStack(spacing: 0) {
formattingToolbar
Divider()
TextEditor(text: $text, selection: $selection)
}
}
// MARK: - Toolbar
private var formattingToolbar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
formatButton("bold", isActive: isBold, action: toggleBold)
formatButton("italic", isActive: isItalic, action: toggleItalic)
formatButton("underline", isActive: isUnderlined, action: toggleUnderline)
formatButton("strikethrough", isActive: isStrikethrough, action: toggleStrikethrough)
Divider().frame(height: 20)
fontSizeControls
Divider().frame(height: 20)
Button {
showColorPicker.toggle()
} label: {
Image(systemName: "paintbrush.fill")
.foregroundStyle(textColor)
}
.popover(isPresented: $showColorPicker) {
ColorPicker("Text Color", selection: $textColor, supportsOpacity: false)
.padding()
.frame(minWidth: 200)
.onChange(of: textColor) { applyTextColor() }
}
Divider().frame(height: 20)
Button("Clear", systemImage: "eraser") {
clearFormatting()
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.background(.bar)
}
private func formatButton(
_ systemImage: String,
isActive: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.fontWeight(isActive ? .bold : .regular)
.foregroundStyle(isActive ? .accent : .secondary)
}
}
private var fontSizeControls: some View {
HStack(spacing: 4) {
Button {
adjustFontSize(by: -1)
} label: {
Image(systemName: "textformat.size.smaller")
}
Button {
adjustFontSize(by: 1)
} label: {
Image(systemName: "textformat.size.larger")
}
}
}
// MARK: - Toolbar State
private var isBold: Bool {
guard let font = selection.typingAttributes(in: text).font else { return false }
return font.resolve(in: fontContext).isBold
}
private var isItalic: Bool {
guard let font = selection.typingAttributes(in: text).font else { return false }
return font.resolve(in: fontContext).isItalic
}
private var isUnderlined: Bool {
selection.typingAttributes(in: text).underlineStyle != nil
}
private var isStrikethrough: Bool {
selection.typingAttributes(in: text).strikethroughStyle != nil
}
// MARK: - Formatting Actions
private func toggleBold() {
text.transformAttributes(in: &selection) { container in
let font = container.font ?? .body
let resolved = font.resolve(in: fontContext)
container.font = font.bold(!resolved.isBold)
}
}
private func toggleItalic() {
text.transformAttributes(in: &selection) { container in
let font = container.font ?? .body
let resolved = font.resolve(in: fontContext)
container.font = font.italic(!resolved.isItalic)
}
}
private func toggleUnderline() {
text.transformAttributes(in: &selection) { container in
container.underlineStyle = container.underlineStyle == nil ? .single : nil
}
}
private func toggleStrikethrough() {
text.transformAttributes(in: &selection) { container in
container.strikethroughStyle = container.strikethroughStyle == nil ? .single : nil
}
}
private func applyTextColor() {
text.transformAttributes(in: &selection) { container in
container.foregroundColor = textColor
}
}
private func adjustFontSize(by delta: CGFloat) {
text.transformAttributes(in: &selection) { container in
let currentFont = container.font ?? .body
let resolved = currentFont.resolve(in: fontContext)
let newSize = max(9, min(96, resolved.pointSize + delta))
container.font = .system(size: newSize)
}
}
private func clearFormatting() {
text.transformAttributes(in: &selection) { container in
container.font = .body
container.foregroundColor = nil
container.backgroundColor = nil
container.underlineStyle = nil
container.strikethroughStyle = nil
}
}
}
Use this component anywhere in your app by passing an AttributedString binding:
struct NoteDetailView: View {
@State private var noteText = AttributedString("Start writing your note...")
var body: some View {
RichTextEditor(text: $noteText)
.navigationTitle("New Note")
}
}
Supported Formatting Attributes Reference
Here's the complete list of attributes that TextEditor supports with AttributedString in iOS 26:
- Font traits: bold, italic, and bold-italic via
.fontwith.bold()and.italic() - Font size and design:
.system(size:weight:design:)for custom sizes, weights, and designs (rounded, serif, monospaced) - Decoration:
.underlineStyleand.strikethroughStylewith patterns like.single,.double,.thick - Colors:
.foregroundColorand.backgroundColor - Links:
.linkwith aURLvalue - Typography:
.kern,.tracking,.baselineOffset - Paragraph styling: line height, text alignment, and base writing direction
- Special: Genmoji support
Performance Considerations
AttributedString is a value type, but it uses copy-on-write internally. Only the ranges you actually modify get copied, so even large documents perform smoothly. SwiftUI's observation system further optimizes things — the TextEditor only re-renders the affected text regions, not the entire document.
For documents exceeding several thousand words, keep these in mind:
- Debounce auto-save: Don't encode and write to disk on every keystroke. Use a
Taskwith a short delay to batch saves. - Skip manual undo history: SwiftUI's
TextEditormanages its own undo stack. You don't need to duplicate it. - Profile with Instruments: Use the SwiftUI Instruments template to verify that your toolbar state computations aren't causing excessive view updates.
Frequently Asked Questions
How do I build a rich text editor in SwiftUI without UIKit?
Starting with iOS 26, you can build a fully native rich text editor using SwiftUI's TextEditor with an AttributedString binding. Just change your state property from String to AttributedString and the editor gains built-in support for bold, italic, underline, and other formatting. Add custom toolbar buttons using transformAttributes(in:) to apply formatting to the user's selection. No UIKit wrapping or UIViewRepresentable needed.
What is the difference between AttributedString and NSAttributedString in SwiftUI?
AttributedString is a Swift-native value type introduced in iOS 15 that's Codable, works with copy-on-write, and integrates directly with SwiftUI. NSAttributedString is an Objective-C reference type from Foundation that requires bridging. For new SwiftUI code targeting iOS 26, always use AttributedString — it's safer, more performant, and has direct TextEditor support.
How do I save AttributedString to SwiftData?
Since AttributedString conforms to Codable, encode it to JSON Data using JSONEncoder and store the Data property in your SwiftData model. Create a computed property on your model that encodes on set and decodes on get — it gives you a clean API for reading and writing rich text while SwiftData handles the raw bytes.
Can I use Markdown with SwiftUI TextEditor?
Yes. AttributedString has a built-in Markdown initializer: try AttributedString(markdown: yourString). It parses bold, italic, strikethrough, links, inline code, and blockquotes. You can initialize your TextEditor with Markdown content and the user can continue editing with full rich text support.
Does the new TextEditor rich text support work on macOS?
Yes. The TextEditor with AttributedString support is available on macOS 26, iOS 26, iPadOS 26, visionOS 26, and all other Apple platforms that ship with the 2025 SDK cycle. The API is identical across platforms, so your rich text editor component works everywhere without any platform-specific code.