```html SwiftUI: How to Build Rich Text Editor (iOS 17+, 2026)

How to implement a rich text editor in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: TextEditor / AttributedString Updated: May 11, 2026
TL;DR

SwiftUI's built-in TextEditor only accepts a plain String binding. For real rich text — bold, italic, colors — wrap a UITextView in UIViewRepresentable, use AttributedString as your model, and drive a SwiftUI toolbar that applies traits to the selected range.

struct RichTextEditor: UIViewRepresentable {
    @Binding var attributedText: NSAttributedString

    func makeUIView(context: Context) -> UITextView {
        let tv = UITextView()
        tv.delegate = context.coordinator
        tv.allowsEditingTextAttributes = true
        return tv
    }

    func updateUIView(_ tv: UITextView, context: Context) {
        if tv.attributedText != attributedText {
            tv.attributedText = attributedText
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator($attributedText)
    }

    class Coordinator: NSObject, UITextViewDelegate {
        @Binding var attributedText: NSAttributedString
        init(_ binding: Binding<NSAttributedString>) { _attributedText = binding }
        func textViewDidChange(_ tv: UITextView) {
            attributedText = tv.attributedText
        }
    }
}

Full implementation

The full editor adds a FormattingToolbar that reads the current selection from the UITextView, applies or removes NSAttributedString traits (bold, italic, underline), and publishes selection state back up to SwiftUI so toolbar buttons can toggle on and off. A @StateObject view model acts as the shared source of truth between the representable coordinator and the SwiftUI layer — avoiding the re-entrancy issues you get when trying to close the loop purely through bindings.

import SwiftUI
import UIKit

// MARK: - View Model

@Observable
final class RichEditorModel {
    var attributedText: NSAttributedString = NSAttributedString(
        string: "Start typing here…",
        attributes: [.font: UIFont.preferredFont(forTextStyle: .body)]
    )
    var isBold      = false
    var isItalic    = false
    var isUnderline = false

    // Updated by Coordinator whenever selection moves
    var selectedRange: NSRange = NSRange(location: 0, length: 0)
    weak var textView: UITextView?

    func applyTrait(_ trait: UIFontDescriptor.SymbolicTraits) {
        guard let tv = textView, tv.selectedRange.length > 0 else { return }
        let range = tv.selectedRange
        let mutable = NSMutableAttributedString(attributedString: tv.attributedText)
        mutable.enumerateAttribute(.font, in: range) { value, subrange, _ in
            guard let font = value as? UIFont else { return }
            let desc = font.fontDescriptor
            let newTraits: UIFontDescriptor.SymbolicTraits
            if desc.symbolicTraits.contains(trait) {
                newTraits = desc.symbolicTraits.subtracting(trait)
            } else {
                newTraits = desc.symbolicTraits.union(trait)
            }
            if let newDesc = desc.withSymbolicTraits(newTraits) {
                let newFont = UIFont(descriptor: newDesc, size: 0)
                mutable.addAttribute(.font, value: newFont, range: subrange)
            }
        }
        tv.attributedText = mutable
        tv.selectedRange  = range
        attributedText    = mutable
        refreshTraitState(for: range, in: tv)
    }

    func toggleUnderline() {
        guard let tv = textView, tv.selectedRange.length > 0 else { return }
        let range   = tv.selectedRange
        let mutable = NSMutableAttributedString(attributedString: tv.attributedText)
        var hasUnderline = false
        mutable.enumerateAttribute(.underlineStyle, in: range) { val, _, _ in
            if (val as? Int) != nil { hasUnderline = true }
        }
        let style: Any = hasUnderline ? 0 : NSUnderlineStyle.single.rawValue
        mutable.addAttribute(.underlineStyle, value: style, range: range)
        tv.attributedText = mutable
        tv.selectedRange  = range
        attributedText    = mutable
        refreshTraitState(for: range, in: tv)
    }

    func refreshTraitState(for range: NSRange, in tv: UITextView) {
        guard range.length > 0,
              range.location + range.length <= tv.attributedText.length else {
            isBold = false; isItalic = false; isUnderline = false
            return
        }
        var bold = false, italic = false, underline = false
        tv.attributedText.enumerateAttribute(.font, in: range) { val, _, _ in
            guard let font = val as? UIFont else { return }
            let traits = font.fontDescriptor.symbolicTraits
            if traits.contains(.traitBold)   { bold   = true }
            if traits.contains(.traitItalic) { italic = true }
        }
        tv.attributedText.enumerateAttribute(.underlineStyle, in: range) { val, _, _ in
            if let v = val as? Int, v != 0 { underline = true }
        }
        isBold = bold; isItalic = italic; isUnderline = underline
    }
}

// MARK: - UIViewRepresentable

struct RichTextEditorView: UIViewRepresentable {
    @Bindable var model: RichEditorModel

    func makeUIView(context: Context) -> UITextView {
        let tv = UITextView()
        tv.font                       = UIFont.preferredFont(forTextStyle: .body)
        tv.delegate                   = context.coordinator
        tv.allowsEditingTextAttributes = true
        tv.textContainerInset         = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
        tv.attributedText             = model.attributedText
        model.textView                = tv
        return tv
    }

    func updateUIView(_ tv: UITextView, context: Context) {
        if tv.attributedText != model.attributedText {
            let sel = tv.selectedRange
            tv.attributedText = model.attributedText
            tv.selectedRange  = sel
        }
        model.textView = tv
    }

    func makeCoordinator() -> Coordinator { Coordinator(model) }

    class Coordinator: NSObject, UITextViewDelegate {
        let model: RichEditorModel
        init(_ model: RichEditorModel) { self.model = model }

        func textViewDidChange(_ tv: UITextView) {
            model.attributedText = tv.attributedText
        }
        func textViewDidChangeSelection(_ tv: UITextView) {
            model.selectedRange = tv.selectedRange
            model.refreshTraitState(for: tv.selectedRange, in: tv)
        }
    }
}

// MARK: - Toolbar

struct FormattingToolbar: View {
    @Bindable var model: RichEditorModel

    var body: some View {
        HStack(spacing: 0) {
            toolButton("bold",      active: model.isBold)      { model.applyTrait(.traitBold) }
            toolButton("italic",    active: model.isItalic)    { model.applyTrait(.traitItalic) }
            toolButton("underline", active: model.isUnderline) { model.toggleUnderline() }
            Spacer()
        }
        .padding(.horizontal, 8)
        .padding(.vertical, 6)
        .background(.bar)
    }

    private func toolButton(_ icon: String, active: Bool, action: @escaping () -> Void) -> some View {
        Button(action: action) {
            Image(systemName: icon)
                .frame(width: 40, height: 36)
                .background(active ? Color.accentColor.opacity(0.15) : Color.clear)
                .clipShape(RoundedRectangle(cornerRadius: 6))
                .foregroundStyle(active ? Color.accentColor : Color.primary)
        }
        .accessibilityLabel(icon.capitalized)
        .accessibilityAddTraits(active ? .isSelected : [])
    }
}

// MARK: - Root view

struct RichEditorScreen: View {
    @State private var model = RichEditorModel()

    var body: some View {
        VStack(spacing: 0) {
            FormattingToolbar(model: model)
            Divider()
            RichTextEditorView(model: model)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .navigationTitle("Document")
        .navigationBarTitleDisplayMode(.inline)
    }
}

#Preview {
    NavigationStack {
        RichEditorScreen()
    }
}

How it works

  1. UIViewRepresentable bridgeRichTextEditorView wraps UITextView because SwiftUI's native TextEditor only accepts a plain String binding and cannot carry font traits or underline attributes. The bridge gives full access to NSAttributedString.
  2. @Observable view model as shared stateRichEditorModel is decorated with @Observable (iOS 17 Observation framework). Both the Coordinator (UIKit side) and the SwiftUI toolbar hold a reference to the same instance, so formatting-state booleans like isBold propagate to the toolbar without manual objectWillChange calls.
  3. Trait-toggling via enumerate+mutateapplyTrait(_:) uses enumerateAttribute(_:in:) to walk every font run inside the selection, checks UIFontDescriptor.SymbolicTraits for the existing trait, and flips it. This correctly handles selections that span multiple font sizes or styles.
  4. Selection-driven toolbar state — The UITextViewDelegate method textViewDidChangeSelection fires after every cursor move. It calls refreshTraitState, which re-scans the selected range so the bold/italic/underline buttons accurately reflect what's under the cursor — no stale state.
  5. Re-entrancy guard in updateUIView — The identity check if tv.attributedText != model.attributedText prevents the view from resetting the cursor position on every SwiftUI render pass, which would make typing feel broken.

Variants

Export content as AttributedString (for SwiftData storage)

// Convert NSAttributedString ↔ AttributedString for Codable storage
extension RichEditorModel {

    // Produce a Swift-native AttributedString for persistence
    var swiftAttributedString: AttributedString {
        (try? AttributedString(model.attributedText, including: \.uiKit)) ?? AttributedString()
    }

    // Restore from an AttributedString saved in SwiftData
    func load(from attributed: AttributedString) {
        attributedText = NSAttributedString(attributed)
    }
}

// Usage in a SwiftData model:
@Model class Document {
    var rtfData: Data = Data()   // persist as RTF

    var nsAttributedString: NSAttributedString? {
        get {
            try? NSAttributedString(
                data: rtfData,
                options: [.documentType: NSAttributedString.DocumentType.rtf],
                documentAttributes: nil
            )
        }
        set {
            rtfData = (try? newValue?.data(
                from: NSRange(location: 0, length: newValue?.length ?? 0),
                documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]
            )) ?? Data()
        }
    }
}

Add a font-size picker to the toolbar

Insert a Menu button in FormattingToolbar that calls a applyFontSize(_ size: CGFloat) method on the model. That method enumerates the selected range, extracts each run's existing font, and rebuilds it with UIFont(descriptor: existingDescriptor, size: size) — preserving bold/italic traits while changing only the point size. Pair it with a binding to a currentFontSize: CGFloat property on the model, refreshed in refreshTraitState, so the picker reflects the size at the cursor.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a rich text editor in SwiftUI for iOS 17+.
Use TextEditor/AttributedString (UIViewRepresentable wrapping UITextView).
Add a formatting toolbar with bold, italic, and underline toggles.
Persist content via NSAttributedString RTF data in a SwiftData @Model.
Make it accessible (VoiceOver labels, .isSelected trait on active buttons).
Add a #Preview with realistic sample data (multi-paragraph, mixed formatting).

In Soarias's Build phase, paste this into the implementation prompt after your screen map is confirmed — Claude Code will wire the editor directly into your existing @Model document type, keeping persistence and UI in one pass.

Related

FAQ

Does this work on iOS 16?

The UIViewRepresentable pattern and NSAttributedString work on iOS 15+. The @Observable macro is iOS 17-only — swap it for ObservableObject / @Published to support iOS 16. The #Preview macro also requires Xcode 15+ but degrades gracefully with a PreviewProvider fallback.

How do I support markdown import/export alongside rich text?

Use AttributedString(markdown:) to parse a Markdown string into an AttributedString, then convert to NSAttributedString for the editor. For export, iterate the attributed string's runs and emit Markdown syntax based on AttributeScopes.SwiftUI.FontWeight and .underlineStyle. This round-trip isn't lossless for complex attributes (e.g., custom fonts), but covers the common bold/italic/code cases well.

What's the UIKit equivalent?

Pure UIKit uses UITextView directly with typingAttributes to apply formatting at the cursor, and a UIToolbar above the keyboard. The SwiftUI approach here essentially re-creates that pattern — wrapping UITextView gives you the same NSTextStorage / NSLayoutManager pipeline under the hood. If you need advanced features like custom text containers or paragraph styling, reach into the text view's textStorage directly from the coordinator.

Last reviewed: 2026-05-11 by the Soarias team.

```