Skip to content

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.

Workflow Skills

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.

  • 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.
  • 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
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+)
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
}
}
}
}
  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.

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()
}
}
}
}
AspectUIViewRepresentableNSViewRepresentable
NSViewTypeUITextView directlyNSScrollView (NSTextView inside)
ScrollingBuilt-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])
DelegateUITextViewDelegateNSTextViewDelegate
Text changetextViewDidChange(_:)textDidChange(_:) (Notification)
intrinsicContentSize❌ Invalidation ignored (FB8499811)✅ Re-queried correctly
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))
}
@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
}
}
}

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

@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.

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
  • .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

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 }
}
}
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
}
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 textStorage

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.

  1. No @FocusState bridging — must manually manage becomeFirstResponder/resignFirstResponder
  2. No SwiftUI selection UI — selection handles are UIKit’s, not SwiftUI’s
  3. No animated text reflow — SwiftUI can animate the frame, but text inside won’t animate its reflow
  4. No SwiftUI.FontUIFont conversion — accept UIFont in your wrapper API
  5. No SwiftUI.ColorUIColor conversion (public API) — accept UIColor
  6. 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
  • 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.
LibraryPlatformTextKitRich TextLicenseBest For
STTextViewmacOS (+ iOS)TextKit 2YesGPL/CommercialCode editors, custom text engines
RichTextKitiOS + macOSTextKit 1YesMITCross-platform rich text editing in SwiftUI
TextualiOS + macOSN/ADisplay onlyMITMarkdown/rich text DISPLAY (not editing)
HighlightedTextEditoriOS + macOSTextKit 1Regex-basedMITSimple syntax highlighting
CodeEditoriOS + macOSHighlight.jsCode onlyMITCode display with 180+ languages
  • 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)
  1. Not updating context.coordinator.parent — stale bindings cause wrong values in delegate callbacks
  2. Setting text without equality check — infinite update loop
  3. Synchronous state updates in delegates — “Modifying state during view update” crash
  4. Mixing async and sync updates — ordering bugs. If one update is async, make them all async.
  5. Forgetting .backgroundColor = .clear — UITextView paints over SwiftUI backgrounds
  6. Using ScrollView around representable with keyboard — double-offset. Use .ignoresSafeArea(.keyboard).
  7. Not setting isScrollEnabled = false for auto-sizing — UITextView reports wrong intrinsic size

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.

  • 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
SKILL.md
---
name: apple-text-representable
description: "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
```swift
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
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
```swift
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
| 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)
```swift
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
```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:
```swift
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
### 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+)
```swift
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
```swift
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
```swift
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 textStorage
```
## Environment Value Bridging
SwiftUI tracks which environment values you access in `updateUIView` and re-calls it when they change:
```swift
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
### What You Cannot Do
1. **No `@FocusState` bridging** — must manually manage becomeFirstResponder/resignFirstResponder
2. **No SwiftUI selection UI** — selection handles are UIKit's, not SwiftUI's
3. **No animated text reflow** — SwiftUI can animate the frame, but text inside won't animate its reflow
4. **No `SwiftUI.Font``UIFont` conversion** — accept UIFont in your wrapper API
5. **No `SwiftUI.Color``UIColor` conversion** (public API) — accept UIColor
6. **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 callbacks
2. **Setting text without equality check** — infinite update loop
3. **Synchronous state updates in delegates** — "Modifying state during view update" crash
4. **Mixing async and sync updates** — ordering bugs. If one update is async, make them all async.
5. **Forgetting `.backgroundColor = .clear`** — UITextView paints over SwiftUI backgrounds
6. **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.