How to implement a rich text editor in SwiftUI
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
- UIViewRepresentable bridge —
RichTextEditorViewwrapsUITextViewbecause SwiftUI's nativeTextEditoronly accepts a plainStringbinding and cannot carry font traits or underline attributes. The bridge gives full access toNSAttributedString. - @Observable view model as shared state —
RichEditorModelis decorated with@Observable(iOS 17 Observation framework). Both theCoordinator(UIKit side) and the SwiftUI toolbar hold a reference to the same instance, so formatting-state booleans likeisBoldpropagate to the toolbar without manualobjectWillChangecalls. - Trait-toggling via enumerate+mutate —
applyTrait(_:)usesenumerateAttribute(_:in:)to walk every font run inside the selection, checksUIFontDescriptor.SymbolicTraitsfor the existing trait, and flips it. This correctly handles selections that span multiple font sizes or styles. - Selection-driven toolbar state — The
UITextViewDelegatemethodtextViewDidChangeSelectionfires after every cursor move. It callsrefreshTraitState, which re-scans the selected range so the bold/italic/underline buttons accurately reflect what's under the cursor — no stale state. - Re-entrancy guard in updateUIView — The identity check
if tv.attributedText != model.attributedTextprevents 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
- iOS version:
@Observablerequires iOS 17+. If you need to support iOS 16, replace it withObservableObject+@Published, and annotate theUIViewRepresentablewith@ObservedObject. TheNSAttributedStringbridge itself is fine back to iOS 15. - SwiftUI-specific: Never write back to
model.attributedTextinsideupdateUIView— this triggers another SwiftUI render, which callsupdateUIViewagain, which triggers another render (infinite loop). Only update the model from theCoordinatordelegate methods. - Accessibility:
UITextViewis VoiceOver-accessible by default, but your custom toolbar buttons need explicit.accessibilityLabeland.accessibilityAddTraits(.isSelected)when toggled on. Without.isSelected, VoiceOver users can't tell whether Bold is currently active. - Performance: Calling
enumerateAttributeon very large documents on every keystroke adds up. For documents over ~100 KB of attributed text, debouncetextViewDidChangeby 200 ms before syncing to the model, or only enumerate the visible paragraph range.
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.