UIViewRepresentable / NSViewRepresentable for Text Views
Use when embedding UITextView or NSTextView inside SwiftUI and the hard part is wrapper behavior: two-way binding, focus, sizing, cursor preservation, update loops, toolbars, or environment bridging. Reach for this when native SwiftUI text views are not enough, not when choosing between text stacks at a high level.
Use when embedding UITextView or NSTextView inside SwiftUI and the hard part is wrapper behavior: two-way binding, focus, sizing, cursor preservation, update loops, toolbars, or environment bridging. Reach for this when native SwiftUI text views are not enough, not when choosing between text stacks at a high level.
Family: SwiftUI And Wrapper Boundaries
Use this skill when the main question is how to wrap UIKit/AppKit text views inside SwiftUI without breaking editing behavior.
When to Use
Section titled “When to Use”- You are building
UIViewRepresentableorNSViewRepresentablewrappers around text views. - You need coordinator, focus, sizing, or cursor-preservation patterns.
- The problem is wrapper mechanics, not whether SwiftUI
Textrenders a type.
Quick Decision
Section titled “Quick Decision”- Plain SwiftUI editing is enough -> avoid wrapping and stay native
- Need TextKit APIs, rich text, syntax highlighting, or attachments -> wrap
UITextView/NSTextView - Need cross-framework type/rendering limits instead of wrapper mechanics ->
/skill apple-text-swiftui-bridging
Core Guidance
Section titled “Core Guidance”When You Need This
Section titled “When You Need This”Need rich text editing in SwiftUI? iOS 26+ → TextEditor with AttributedString (try this first) iOS 14-25 → UIViewRepresentable wrapping UITextView
Need syntax highlighting? → UIViewRepresentable wrapping UITextView with TextKit 2
Need TextKit API access (layout queries, custom rendering)? → UIViewRepresentable wrapping UITextView
Need paragraph styles, text attachments, inline images? → UIViewRepresentable wrapping UITextView
Just need plain multi-line text editing? → SwiftUI TextEditor (no bridge needed)
Just need an expanding text input? → TextField(axis: .vertical) with .lineLimit (iOS 16+)UIViewRepresentable Pattern (iOS)
Section titled “UIViewRepresentable Pattern (iOS)”Complete Working Example
Section titled “Complete Working Example”struct RichTextView: UIViewRepresentable { @Binding var text: NSAttributedString var uiFont: UIFont = .preferredFont(forTextStyle: .body) var textColor: UIColor = .label
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.delegate = context.coordinator textView.isEditable = true textView.isSelectable = true textView.font = uiFont textView.textColor = textColor textView.backgroundColor = .clear // Let SwiftUI backgrounds show textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4) return textView }
func updateUIView(_ uiView: UITextView, context: Context) { // CRITICAL: Update coordinator's parent reference for fresh bindings context.coordinator.parent = self
// Only update if text actually changed (prevents cursor jump + infinite loop) if uiView.attributedText != text { let savedRange = uiView.selectedRange uiView.attributedText = text // Restore selection if still valid let maxLoc = (uiView.text as NSString).length if savedRange.location <= maxLoc { uiView.selectedRange = NSRange( location: min(savedRange.location, maxLoc), length: min(savedRange.length, maxLoc - min(savedRange.location, maxLoc)) ) } }
// React to environment changes uiView.isEditable = context.environment.isEnabled }
// iOS 16+: Proper auto-sizing @available(iOS 16.0, *) func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { guard let width = proposal.width else { return nil } uiView.isScrollEnabled = false let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) return CGSize(width: width, height: size.height) }
class Coordinator: NSObject, UITextViewDelegate { var parent: RichTextView
init(_ parent: RichTextView) { self.parent = parent }
func textViewDidChange(_ textView: UITextView) { DispatchQueue.main.async { self.parent.text = textView.attributedText } } }}Key Rules
Section titled “Key Rules”-
Always update
context.coordinator.parent = selfat the top ofupdateUIView. The coordinator stores a copy of the struct — without this, delegate callbacks use stale bindings. -
Guard against unnecessary updates in
updateUIView. CheckuiView.text != textbefore setting. Otherwise: infinite loop (user types → binding updates → updateUIView sets text → triggers textViewDidChange → repeat). -
Use
DispatchQueue.main.asyncin delegate callbacks to avoid “Modifying state during view update” warnings. If you async one state update, async ALL related updates to maintain ordering. -
Save/restore
selectedRangewhen setting text programmatically — UIKit resets cursor to end. -
Accept
UIFont/UIColor, notFont/Color— SwiftUI types have no public conversion to UIKit types.
NSViewRepresentable Pattern (macOS)
Section titled “NSViewRepresentable Pattern (macOS)”Key Difference: NSScrollView Wrapping
Section titled “Key Difference: NSScrollView Wrapping”struct MacTextView: NSViewRepresentable { @Binding var text: NSAttributedString
func makeNSView(context: Context) -> NSScrollView { let scrollView = NSTextView.scrollableTextView() let textView = scrollView.documentView as! NSTextView
textView.delegate = context.coordinator textView.isEditable = true textView.isRichText = true textView.allowsUndo = true textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.autoresizingMask = [.width] textView.textContainer?.widthTracksTextView = true
return scrollView }
func updateNSView(_ nsView: NSScrollView, context: Context) { guard let textView = nsView.documentView as? NSTextView else { return } context.coordinator.parent = self
if textView.attributedString() != text { let savedRanges = textView.selectedRanges textView.textStorage?.setAttributedString(text) textView.selectedRanges = savedRanges } }
class Coordinator: NSObject, NSTextViewDelegate { var parent: MacTextView init(_ parent: MacTextView) { self.parent = parent }
func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } DispatchQueue.main.async { self.parent.text = textView.attributedString() } } }}iOS vs macOS Differences
Section titled “iOS vs macOS Differences”| Aspect | UIViewRepresentable | NSViewRepresentable |
|---|---|---|
| NSViewType | UITextView directly | NSScrollView (NSTextView inside) |
| Scrolling | Built-in (UITextView IS UIScrollView) | Must wrap in NSScrollView |
| Attributed text | .attributedText property | .attributedString() method |
| Set text | .attributedText = x | .textStorage?.setAttributedString(x) |
| Selection | .selectedRange (NSRange) | .selectedRanges ([NSValue]) |
| Delegate | UITextViewDelegate | NSTextViewDelegate |
| Text change | textViewDidChange(_:) | textDidChange(_:) (Notification) |
| intrinsicContentSize | ❌ Invalidation ignored (FB8499811) | ✅ Re-queried correctly |
Auto-Sizing (Expanding Text View)
Section titled “Auto-Sizing (Expanding Text View)”iOS 16+: sizeThatFits (Recommended)
Section titled “iOS 16+: sizeThatFits (Recommended)”func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { guard let width = proposal.width else { return nil } uiView.isScrollEnabled = false return uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))}iOS 13-15: Height Tracking
Section titled “iOS 13-15: Height Tracking”@State private var height: CGFloat = 40
WrappedTextView(text: $text, height: $height) .frame(height: height)
// In Coordinator:func textViewDidChange(_ textView: UITextView) { DispatchQueue.main.async { let newHeight = max(textView.contentSize.height, 40) if self.parent.height != newHeight { self.parent.height = newHeight } }}The isScrollEnabled = false Problem
Section titled “The isScrollEnabled = false Problem”Setting isScrollEnabled = false should make UITextView report intrinsicContentSize. However:
UIViewRepresentableignoresinvalidateIntrinsicContentSize()(Apple-confirmed bug: FB8499811)- The intrinsic size may not account for line wrapping
- Use
sizeThatFits(iOS 16+) or explicit height tracking instead
Focus / First Responder Bridging
Section titled “Focus / First Responder Bridging”@FocusState does not bridge to UIViewRepresentable. Manual bridging required:
struct FocusableTextView: UIViewRepresentable { @Binding var isFocused: Bool
func updateUIView(_ uiView: UITextView, context: Context) { if isFocused && !uiView.isFirstResponder { DispatchQueue.main.async { uiView.becomeFirstResponder() } } else if !isFocused && uiView.isFirstResponder { DispatchQueue.main.async { uiView.resignFirstResponder() } } }
// In Coordinator: func textViewDidBeginEditing(_ textView: UITextView) { DispatchQueue.main.async { self.parent.isFocused = true } } func textViewDidEndEditing(_ textView: UITextView) { DispatchQueue.main.async { self.parent.isFocused = false } }}Use DispatchQueue.main.async for becomeFirstResponder() — calling synchronously in updateUIView can fail if the view isn’t in the window hierarchy yet.
Rendering Layer
Section titled “Rendering Layer”Where UITextView Renders
Section titled “Where UITextView Renders”SwiftUI render tree → _UIHostingView (root UIView) → ... (SwiftUI internal views) → Container UIView (created by UIViewRepresentable) → UITextView (your view) → CALayer (backed by Core Animation) → TextKit renders glyphs into layer- No extra compositing layer for the bridge — UITextView’s CALayer is in the normal layer tree
- Minimal overhead from UIViewRepresentable — main cost is
updateUIViewcalls on state changes - TextKit renders through Core Text → Core Graphics → CALayer backing store
SwiftUI Integration
Section titled “SwiftUI Integration”.overlay()and.background()work normally on the representable- Set
textView.backgroundColor = .clearfor SwiftUI backgrounds to show through - Z-ordering follows normal SwiftUI rules (declaration order,
.zIndex()) .clipped()prevents UIKit content from bleeding outside the SwiftUI frame
Toolbar Integration
Section titled “Toolbar Integration”Pattern A: SwiftUI Keyboard Toolbar (iOS 15+)
Section titled “Pattern A: SwiftUI Keyboard Toolbar (iOS 15+)”WrappedTextView(text: $text) .toolbar { ToolbarItemGroup(placement: .keyboard) { Button(action: toggleBold) { Image(systemName: "bold") } Button(action: toggleItalic) { Image(systemName: "italic") } Spacer() Button("Done") { focusedField = nil } } }Pattern B: UIKit inputAccessoryView
Section titled “Pattern B: UIKit inputAccessoryView”func makeUIView(context: Context) -> UITextView { let tv = UITextView() let toolbar = UIToolbar() toolbar.items = [ UIBarButtonItem(image: UIImage(systemName: "bold"), style: .plain, target: context.coordinator, action: #selector(Coordinator.toggleBold)), ] toolbar.sizeToFit() tv.inputAccessoryView = toolbar return tv}Pattern C: ObservableObject Shared State
Section titled “Pattern C: ObservableObject Shared State”class TextFormatContext: ObservableObject { @Published var isBold = false @Published var isItalic = false}
// SwiftUI toolbar reads/writes to context// Coordinator observes context via Combine and applies to textStorageEnvironment Value Bridging
Section titled “Environment Value Bridging”SwiftUI tracks which environment values you access in updateUIView and re-calls it when they change:
func updateUIView(_ uiView: UITextView, context: Context) { // Auto-reactive to Dark Mode changes let scheme = context.environment.colorScheme
// Auto-reactive to Dynamic Type uiView.font = UIFont.preferredFont(forTextStyle: .body)
// Auto-reactive to .disabled() modifier uiView.isEditable = context.environment.isEnabled}Only access values you need — unused accesses trigger unnecessary updateUIView calls.
Limitations
Section titled “Limitations”What You Cannot Do
Section titled “What You Cannot Do”- No
@FocusStatebridging — must manually manage becomeFirstResponder/resignFirstResponder - No SwiftUI selection UI — selection handles are UIKit’s, not SwiftUI’s
- No animated text reflow — SwiftUI can animate the frame, but text inside won’t animate its reflow
- No
SwiftUI.Font→UIFontconversion — accept UIFont in your wrapper API - No
SwiftUI.Color→UIColorconversion (public API) — accept UIColor - Delegate is locked — the Coordinator owns the delegate. External code cannot set
textView.delegate - No preference system — UITextView can’t propagate values up through SwiftUI preferences naturally
Known Bugs
Section titled “Known Bugs”intrinsicContentSizeinvalidation ignored (FB8499811) — usesizeThatFitsor height tracking- Cursor jump — setting
attributedTextresets selection. Always save/restore. - “Modifying state during view update” — use
DispatchQueue.main.asyncin delegate callbacks - Keyboard double-offset — SwiftUI keyboard avoidance + UIScrollView contentInset can conflict. Use
.ignoresSafeArea(.keyboard)to fix.
Third-Party Alternatives
Section titled “Third-Party Alternatives”| Library | Platform | TextKit | Rich Text | License | Best For |
|---|---|---|---|---|---|
| STTextView | macOS (+ iOS) | TextKit 2 | Yes | GPL/Commercial | Code editors, custom text engines |
| RichTextKit | iOS + macOS | TextKit 1 | Yes | MIT | Cross-platform rich text editing in SwiftUI |
| Textual | iOS + macOS | N/A | Display only | MIT | Markdown/rich text DISPLAY (not editing) |
| HighlightedTextEditor | iOS + macOS | TextKit 1 | Regex-based | MIT | Simple syntax highlighting |
| CodeEditor | iOS + macOS | Highlight.js | Code only | MIT | Code display with 180+ languages |
When to Use a Library vs DIY
Section titled “When to Use a Library vs DIY”- Simple rich text editing → iOS 26+ TextEditor, or RichTextKit
- Code editor → STTextView or UITextView with custom TextKit 2 fragments
- Rich text display (read-only) → Textual or SwiftUI Text with AttributedString
- Full control needed → DIY UIViewRepresentable (this skill)
Common Pitfalls
Section titled “Common Pitfalls”- Not updating
context.coordinator.parent— stale bindings cause wrong values in delegate callbacks - Setting text without equality check — infinite update loop
- Synchronous state updates in delegates — “Modifying state during view update” crash
- Mixing async and sync updates — ordering bugs. If one update is async, make them all async.
- Forgetting
.backgroundColor = .clear— UITextView paints over SwiftUI backgrounds - Using ScrollView around representable with keyboard — double-offset. Use
.ignoresSafeArea(.keyboard). - Not setting
isScrollEnabled = falsefor auto-sizing — UITextView reports wrong intrinsic size
Documentation Scope
Section titled “Documentation Scope”This page documents the apple-text-representable workflow skill. Use it when the job is a guided review, implementation flow, or integration pass instead of a single API lookup.
Related
Section titled “Related”apple-text-views: Use when the main task is choosing the right Apple text view or deciding whether a problem belongs in SwiftUI text, UIKit/AppKit text views, or TextKit mode. Reach for this when comparing capabilities and tradeoffs, not when implementing a specific wrapper or low-level API.apple-text-swiftui-bridging: Use when deciding whether a text type or attribute model crosses the SwiftUI and TextKit boundary cleanly, such as AttributedString, NSAttributedString, UITextView, or SwiftUI Text. Reach for this when the main question is interoperability and support boundaries, not wrapper mechanics.apple-text-layout-manager-selection: Use when the main task is choosing between TextKit 1 and TextKit 2, especially NSLayoutManager versus NSTextLayoutManager for performance, migration risk, large documents, or feature fit. Reach for this when the stack choice is still open, not when the user already needs API-level mechanics.
Full SKILL.md source
---name: apple-text-representabledescription: "Use when embedding UITextView or NSTextView inside SwiftUI and the hard part is wrapper behavior: two-way binding, focus, sizing, cursor preservation, update loops, toolbars, or environment bridging. Reach for this when native SwiftUI text views are not enough, not when choosing between text stacks at a high level."license: MIT---
# UIViewRepresentable / NSViewRepresentable for Text Views
Use this skill when the main question is how to wrap UIKit/AppKit text views inside SwiftUI without breaking editing behavior.
## When to Use
- You are building `UIViewRepresentable` or `NSViewRepresentable` wrappers around text views.- You need coordinator, focus, sizing, or cursor-preservation patterns.- The problem is wrapper mechanics, not whether SwiftUI `Text` renders a type.
## Quick Decision
- Plain SwiftUI editing is enough -> avoid wrapping and stay native- Need TextKit APIs, rich text, syntax highlighting, or attachments -> wrap `UITextView` / `NSTextView`- Need cross-framework type/rendering limits instead of wrapper mechanics -> `/skill apple-text-swiftui-bridging`
## Core Guidance
## When You Need This
```Need rich text editing in SwiftUI? iOS 26+ → TextEditor with AttributedString (try this first) iOS 14-25 → UIViewRepresentable wrapping UITextView
Need syntax highlighting? → UIViewRepresentable wrapping UITextView with TextKit 2
Need TextKit API access (layout queries, custom rendering)? → UIViewRepresentable wrapping UITextView
Need paragraph styles, text attachments, inline images? → UIViewRepresentable wrapping UITextView
Just need plain multi-line text editing? → SwiftUI TextEditor (no bridge needed)
Just need an expanding text input? → TextField(axis: .vertical) with .lineLimit (iOS 16+)```
## UIViewRepresentable Pattern (iOS)
### Complete Working Example
```swiftstruct RichTextView: UIViewRepresentable { @Binding var text: NSAttributedString var uiFont: UIFont = .preferredFont(forTextStyle: .body) var textColor: UIColor = .label
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.delegate = context.coordinator textView.isEditable = true textView.isSelectable = true textView.font = uiFont textView.textColor = textColor textView.backgroundColor = .clear // Let SwiftUI backgrounds show textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4) return textView }
func updateUIView(_ uiView: UITextView, context: Context) { // CRITICAL: Update coordinator's parent reference for fresh bindings context.coordinator.parent = self
// Only update if text actually changed (prevents cursor jump + infinite loop) if uiView.attributedText != text { let savedRange = uiView.selectedRange uiView.attributedText = text // Restore selection if still valid let maxLoc = (uiView.text as NSString).length if savedRange.location <= maxLoc { uiView.selectedRange = NSRange( location: min(savedRange.location, maxLoc), length: min(savedRange.length, maxLoc - min(savedRange.location, maxLoc)) ) } }
// React to environment changes uiView.isEditable = context.environment.isEnabled }
// iOS 16+: Proper auto-sizing @available(iOS 16.0, *) func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { guard let width = proposal.width else { return nil } uiView.isScrollEnabled = false let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) return CGSize(width: width, height: size.height) }
class Coordinator: NSObject, UITextViewDelegate { var parent: RichTextView
init(_ parent: RichTextView) { self.parent = parent }
func textViewDidChange(_ textView: UITextView) { DispatchQueue.main.async { self.parent.text = textView.attributedText } } }}```
### Key Rules
1. **Always update `context.coordinator.parent = self`** at the top of `updateUIView`. The coordinator stores a copy of the struct — without this, delegate callbacks use stale bindings.
2. **Guard against unnecessary updates** in `updateUIView`. Check `uiView.text != text` before setting. Otherwise: infinite loop (user types → binding updates → updateUIView sets text → triggers textViewDidChange → repeat).
3. **Use `DispatchQueue.main.async`** in delegate callbacks to avoid "Modifying state during view update" warnings. If you async one state update, async ALL related updates to maintain ordering.
4. **Save/restore `selectedRange`** when setting text programmatically — UIKit resets cursor to end.
5. **Accept `UIFont`/`UIColor`, not `Font`/`Color`** — SwiftUI types have no public conversion to UIKit types.
## NSViewRepresentable Pattern (macOS)
### Key Difference: NSScrollView Wrapping
```swiftstruct MacTextView: NSViewRepresentable { @Binding var text: NSAttributedString
func makeNSView(context: Context) -> NSScrollView { let scrollView = NSTextView.scrollableTextView() let textView = scrollView.documentView as! NSTextView
textView.delegate = context.coordinator textView.isEditable = true textView.isRichText = true textView.allowsUndo = true textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.autoresizingMask = [.width] textView.textContainer?.widthTracksTextView = true
return scrollView }
func updateNSView(_ nsView: NSScrollView, context: Context) { guard let textView = nsView.documentView as? NSTextView else { return } context.coordinator.parent = self
if textView.attributedString() != text { let savedRanges = textView.selectedRanges textView.textStorage?.setAttributedString(text) textView.selectedRanges = savedRanges } }
class Coordinator: NSObject, NSTextViewDelegate { var parent: MacTextView init(_ parent: MacTextView) { self.parent = parent }
func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } DispatchQueue.main.async { self.parent.text = textView.attributedString() } } }}```
### iOS vs macOS Differences
| Aspect | UIViewRepresentable | NSViewRepresentable ||--------|-------------------|-------------------|| **NSViewType** | `UITextView` directly | `NSScrollView` (NSTextView inside) || **Scrolling** | Built-in (UITextView IS UIScrollView) | Must wrap in NSScrollView || **Attributed text** | `.attributedText` property | `.attributedString()` method || **Set text** | `.attributedText = x` | `.textStorage?.setAttributedString(x)` || **Selection** | `.selectedRange` (NSRange) | `.selectedRanges` ([NSValue]) || **Delegate** | `UITextViewDelegate` | `NSTextViewDelegate` || **Text change** | `textViewDidChange(_:)` | `textDidChange(_:)` (Notification) || **intrinsicContentSize** | ❌ Invalidation ignored (FB8499811) | ✅ Re-queried correctly |
## Auto-Sizing (Expanding Text View)
### iOS 16+: `sizeThatFits` (Recommended)
```swiftfunc sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { guard let width = proposal.width else { return nil } uiView.isScrollEnabled = false return uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))}```
### iOS 13-15: Height Tracking
```swift@State private var height: CGFloat = 40
WrappedTextView(text: $text, height: $height) .frame(height: height)
// In Coordinator:func textViewDidChange(_ textView: UITextView) { DispatchQueue.main.async { let newHeight = max(textView.contentSize.height, 40) if self.parent.height != newHeight { self.parent.height = newHeight } }}```
### The `isScrollEnabled = false` Problem
Setting `isScrollEnabled = false` should make UITextView report `intrinsicContentSize`. **However:**- `UIViewRepresentable` ignores `invalidateIntrinsicContentSize()` (Apple-confirmed bug: FB8499811)- The intrinsic size may not account for line wrapping- Use `sizeThatFits` (iOS 16+) or explicit height tracking instead
## Focus / First Responder Bridging
`@FocusState` does not bridge to `UIViewRepresentable`. Manual bridging required:
```swiftstruct FocusableTextView: UIViewRepresentable { @Binding var isFocused: Bool
func updateUIView(_ uiView: UITextView, context: Context) { if isFocused && !uiView.isFirstResponder { DispatchQueue.main.async { uiView.becomeFirstResponder() } } else if !isFocused && uiView.isFirstResponder { DispatchQueue.main.async { uiView.resignFirstResponder() } } }
// In Coordinator: func textViewDidBeginEditing(_ textView: UITextView) { DispatchQueue.main.async { self.parent.isFocused = true } } func textViewDidEndEditing(_ textView: UITextView) { DispatchQueue.main.async { self.parent.isFocused = false } }}```
**Use `DispatchQueue.main.async` for `becomeFirstResponder()`** — calling synchronously in `updateUIView` can fail if the view isn't in the window hierarchy yet.
## Rendering Layer
### Where UITextView Renders
```SwiftUI render tree → _UIHostingView (root UIView) → ... (SwiftUI internal views) → Container UIView (created by UIViewRepresentable) → UITextView (your view) → CALayer (backed by Core Animation) → TextKit renders glyphs into layer```
- **No extra compositing layer** for the bridge — UITextView's CALayer is in the normal layer tree- **Minimal overhead** from UIViewRepresentable — main cost is `updateUIView` calls on state changes- TextKit renders through Core Text → Core Graphics → CALayer backing store
### SwiftUI Integration
- `.overlay()` and `.background()` work normally on the representable- Set `textView.backgroundColor = .clear` for SwiftUI backgrounds to show through- Z-ordering follows normal SwiftUI rules (declaration order, `.zIndex()`)- `.clipped()` prevents UIKit content from bleeding outside the SwiftUI frame
## Toolbar Integration
### Pattern A: SwiftUI Keyboard Toolbar (iOS 15+)
```swiftWrappedTextView(text: $text) .toolbar { ToolbarItemGroup(placement: .keyboard) { Button(action: toggleBold) { Image(systemName: "bold") } Button(action: toggleItalic) { Image(systemName: "italic") } Spacer() Button("Done") { focusedField = nil } } }```
### Pattern B: UIKit inputAccessoryView
```swiftfunc makeUIView(context: Context) -> UITextView { let tv = UITextView() let toolbar = UIToolbar() toolbar.items = [ UIBarButtonItem(image: UIImage(systemName: "bold"), style: .plain, target: context.coordinator, action: #selector(Coordinator.toggleBold)), ] toolbar.sizeToFit() tv.inputAccessoryView = toolbar return tv}```
### Pattern C: ObservableObject Shared State
```swiftclass TextFormatContext: ObservableObject { @Published var isBold = false @Published var isItalic = false}
// SwiftUI toolbar reads/writes to context// Coordinator observes context via Combine and applies to textStorage```
## Environment Value Bridging
SwiftUI tracks which environment values you access in `updateUIView` and re-calls it when they change:
```swiftfunc updateUIView(_ uiView: UITextView, context: Context) { // Auto-reactive to Dark Mode changes let scheme = context.environment.colorScheme
// Auto-reactive to Dynamic Type uiView.font = UIFont.preferredFont(forTextStyle: .body)
// Auto-reactive to .disabled() modifier uiView.isEditable = context.environment.isEnabled}```
**Only access values you need** — unused accesses trigger unnecessary `updateUIView` calls.
## Limitations
### What You Cannot Do
1. **No `@FocusState` bridging** — must manually manage becomeFirstResponder/resignFirstResponder2. **No SwiftUI selection UI** — selection handles are UIKit's, not SwiftUI's3. **No animated text reflow** — SwiftUI can animate the frame, but text inside won't animate its reflow4. **No `SwiftUI.Font` → `UIFont` conversion** — accept UIFont in your wrapper API5. **No `SwiftUI.Color` → `UIColor` conversion** (public API) — accept UIColor6. **Delegate is locked** — the Coordinator owns the delegate. External code cannot set `textView.delegate`7. **No preference system** — UITextView can't propagate values up through SwiftUI preferences naturally
### Known Bugs
- **`intrinsicContentSize` invalidation ignored** (FB8499811) — use `sizeThatFits` or height tracking- **Cursor jump** — setting `attributedText` resets selection. Always save/restore.- **"Modifying state during view update"** — use `DispatchQueue.main.async` in delegate callbacks- **Keyboard double-offset** — SwiftUI keyboard avoidance + UIScrollView contentInset can conflict. Use `.ignoresSafeArea(.keyboard)` to fix.
## Third-Party Alternatives
| Library | Platform | TextKit | Rich Text | License | Best For ||---------|----------|---------|-----------|---------|----------|| **STTextView** | macOS (+ iOS) | TextKit 2 | Yes | GPL/Commercial | Code editors, custom text engines || **RichTextKit** | iOS + macOS | TextKit 1 | Yes | MIT | Cross-platform rich text editing in SwiftUI || **Textual** | iOS + macOS | N/A | Display only | MIT | Markdown/rich text DISPLAY (not editing) || **HighlightedTextEditor** | iOS + macOS | TextKit 1 | Regex-based | MIT | Simple syntax highlighting || **CodeEditor** | iOS + macOS | Highlight.js | Code only | MIT | Code display with 180+ languages |
### When to Use a Library vs DIY
- **Simple rich text editing** → iOS 26+ TextEditor, or RichTextKit- **Code editor** → STTextView or UITextView with custom TextKit 2 fragments- **Rich text display (read-only)** → Textual or SwiftUI Text with AttributedString- **Full control needed** → DIY UIViewRepresentable (this skill)
## Common Pitfalls
1. **Not updating `context.coordinator.parent`** — stale bindings cause wrong values in delegate callbacks2. **Setting text without equality check** — infinite update loop3. **Synchronous state updates in delegates** — "Modifying state during view update" crash4. **Mixing async and sync updates** — ordering bugs. If one update is async, make them all async.5. **Forgetting `.backgroundColor = .clear`** — UITextView paints over SwiftUI backgrounds6. **Using ScrollView around representable with keyboard** — double-offset. Use `.ignoresSafeArea(.keyboard)`.7. **Not setting `isScrollEnabled = false` for auto-sizing** — UITextView reports wrong intrinsic size
## Related Skills
- Use `/skill apple-text-views` when you still need to choose the view class.- Use `/skill apple-text-swiftui-bridging` for type-scope and rendering-boundary questions.- Use `/skill apple-text-layout-manager-selection` when wrapper behavior depends on TextKit 1 vs 2.