skills/txt-swiftui-interop/SKILL.md
Determine which AttributedString attributes survive the SwiftUI/TextKit boundary, what SwiftUI Text actually renders vs silently ignores, and how AttributedString converts to and from NSAttributedString. Covers attribute scopes (FoundationAttributes, SwiftUIAttributes, UIKitAttributes, AppKitAttributes), the SwiftUI.Font vs UIFont mismatch, presentationIntent rendering gaps, scope-aware conversion, and inline image strategies. Use when an attribute set on AttributedString doesn't render in SwiftUI Text, when conversion to UITextView loses styling, when Markdown headings or lists don't appear, or when bridging shared text content between SwiftUI and a wrapped UITextView. Do NOT use for wrapping UITextView mechanics (txt-wrap-textview) or for the iOS 26 SwiftUI TextEditor APIs (txt-swiftui-texteditor).
npx skillsauth add sitapix/apple-text txt-swiftui-interopInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Authored against iOS 26.x / Swift 6.x / Xcode 26.x.
The boundary between SwiftUI's text rendering and TextKit's is opinionated and often quiet about what it drops. The behaviors described here are the recurring rendering gaps; before concluding that an attribute is "broken," verify the actual AttributedString content (print it, or inspect the runs) and confirm the attribute is set in the scope you expect. Apple's autocomplete shows many attributes that look like they ought to work in SwiftUI Text but in fact do nothing at all — the question is always "which scope is this attribute in," not "is the API right."
A bridging bug usually traces to one of three things: the attribute is in UIKitAttributes and won't render in SwiftUI, the conversion to NSAttributedString was called without an explicit scope so custom attributes were dropped, or the content uses presentationIntent for block-level Markdown which neither SwiftUI Text nor UITextView renders automatically. Check the scope before changing the rendering layer.
These attributes on AttributedString render correctly in SwiftUI Text:
| Attribute | Notes |
|-----------|-------|
| font | SwiftUI.Font, not UIFont |
| foregroundColor | SwiftUI.Color |
| backgroundColor | SwiftUI.Color |
| strikethroughStyle | Style enum |
| underlineStyle | Style enum |
| kern | Letter spacing |
| tracking | Tracking |
| baselineOffset | Vertical offset |
| link | Tappable, uses accent color |
The set is intentionally small. SwiftUI Text is a layout primitive, not a rich-text renderer; complex paragraph-level features are not in scope.
These attributes exist on AttributedString (and Xcode autocompletes them on a SwiftUI binding), but Text does nothing with them:
| Attribute | Where it works |
|-----------|----------------|
| paragraphStyle | UITextView / NSTextView |
| shadow | UIKit/AppKit labels, TextKit |
| strokeColor / strokeWidth | UIKit/AppKit |
| textEffect | UIKit/AppKit |
| attachment (NSTextAttachment) | TextKit only |
| writingDirection | TextKit |
| ligature | TextKit |
| obliqueness | UIKit/AppKit |
| expansion | UIKit/AppKit |
| presentationIntent | Must be interpreted manually |
The compile passes. The runtime silently drops. The fix is almost always either to switch to a TextKit-backed view or to set the attribute in a scope SwiftUI does render (which usually means picking a SwiftUI-scope equivalent or restructuring the content into a SwiftUI view tree).
Apple defines four attribute scopes:
| Scope | Contents | Used by |
|-------|----------|---------|
| FoundationAttributes | link, presentationIntent, morphology, inlinePresentationIntent, languageIdentifier | Both SwiftUI and UIKit/AppKit |
| SwiftUIAttributes | SwiftUI styling (Font, Color) plus Foundation | SwiftUI Text |
| UIKitAttributes | UIKit-specific (UIFont, paragraph styles, shadow) plus Foundation | UITextView, UILabel |
| AppKitAttributes | AppKit-specific (NSFont, NSParagraphStyle) plus Foundation | NSTextView |
A practical rule:
FoundationAttributes work everywhere, but the renderer decides whether to honor them.SwiftUIAttributes are primarily for Text. They do not survive bridging to UITextView without translation.UIKitAttributes / AppKitAttributes are for TextKit views and don't render in SwiftUI Text.Attribute scope selection is more important than which attribute name you set. Set str.foregroundColor = .red and the type is inferred — SwiftUI scope by default. Set str.uiKit.foregroundColor = .red and the type is UIColor. Same name, different scope, different rendering paths. The scope is what determines whether your styling survives the boundary.
The single most common bridging bug is treating .font as if it were UIFont:
var str = AttributedString("Hello")
str.font = .body // SwiftUI.Font
str.uiKit.font = .systemFont(ofSize: 16) // UIFont — separate attribute
// Text(str) renders the SwiftUI font.
// UITextView reading NSAttributedString(str) sees the .uiKit.font.
// The two are independent.
When content is shared between a SwiftUI Text and a wrapped UITextView, set both scopes — or pick one renderer and stop trying to bridge. The runtime never converts between them.
For Dynamic Type, the SwiftUI side uses .body, .title, etc. The UIKit side uses UIFont.preferredFont(forTextStyle: .body). Both update on Content Size Category change; they are not the same attribute.
The AttributedString → NSAttributedString conversion is scope-aware and lossy by default. Use the explicit form:
// Best: pick the scope explicitly so custom attributes survive
let nsStr = try NSAttributedString(attrStr, including: \.uiKit)
// Default scope — drops custom attributes, may silently drop SwiftUI-only attributes
let nsStr = NSAttributedString(attrStr)
For content destined for a UITextView, set values in the .uiKit scope and convert with including: \.uiKit. For content destined for an NSTextView, use .appKit. For shared model layers, keep values in the Foundation scope and convert per renderer.
Inline-presentation Markdown (bold, italic, code, strikethrough, links) translates correctly:
**bold** → bold font trait*italic* → italic font trait`code` → monospaced font~~strike~~ → strikethrough[label](url) → link attributeBlock-level Markdown (headings, lists, blockquotes) goes into presentationIntent and is not auto-translated to paragraph styles. See the next section.
The biggest hidden cost is custom attributes. If you defined a custom AttributedString key without declaring a scope, it gets silently dropped during conversion. Always declare a scope on custom keys, and always pass including: matching that scope.
AttributedString(markdown:options:) with .full syntax parses block-level structure into presentationIntent runs:
let str = try AttributedString(
markdown: "# Heading\n\n- Item 1\n\n> Quote",
options: .init(interpretedSyntax: .full)
)
for run in str.runs {
if let intent = run.presentationIntent {
// intent.components: .header(level: 1), .unorderedList,
// .listItem(ordinal:), .blockQuote, .paragraph, etc.
}
}
Neither SwiftUI Text nor UITextView renders presentationIntent automatically. Three real options:
Text views per heading/paragraph/list-item with the right font and spacing. A working but tedious path; gets the result fully native to SwiftUI.UITextView, walk the runs, translate each presentationIntent component to paragraph-style attributes (heading font, list indent, blockquote indent), and apply via addAttribute(.paragraphStyle, …).For inline-only Markdown, none of this is needed — Text(try AttributedString(markdown: "**bold**")) works. The block-level gap matters only when content includes headings, lists, or quotes.
SwiftUI Text ignores NSTextAttachment. SwiftUI TextEditor (pre-iOS 26) only takes String. The iOS 26 rich-text TextEditor accepts AttributedString but still has no inline-image attribute. Two real workarounds.
Display-only on iOS 18+: insert transparent Image(size:) placeholders into the Text, tag each with a custom TextAttribute, and read the resolved layout via the Text.LayoutKey preference. Draw the real images at the resolved positions in a Canvas overlay. This stays inside SwiftUI — works with native layout, accessibility, and animations — but is read-only.
struct InlineImageAttribute: TextAttribute {
let image: UIImage
}
// Build the text with placeholders that take the right space
var t = Text("")
for piece in pieces {
switch piece {
case .text(let s): t = t + Text(s)
case .image(let img):
let placeholder = Text(Image(size: img.size) { _ in })
.customAttribute(InlineImageAttribute(image: img))
t = t + placeholder
}
}
// Read the resolved layout and draw real images on top via Canvas
content.overlayPreferenceValue(Text.LayoutKey.self) { layouts in
Canvas { ctx, _ in
for line in layouts {
for run in line {
guard let attr = run[InlineImageAttribute.self] else { continue }
if let symbol = ctx.resolveSymbol(id: attr.image) {
ctx.draw(symbol, in: run.typographicBounds.rect)
}
}
}
} symbols: {
ForEach(images, id: \.self) { Image(uiImage: $0).tag($0) }
}
}
Editable: wrap a UITextView and use NSTextAttachment. This is the only path for editing around inline images on any iOS version. The wrapper takes care of the editing mechanics; the attachment carries the image and bounds.
func insertImage(_ image: UIImage, in tv: UITextView) {
let att = NSTextAttachment()
att.image = image
let lineHeight = tv.font?.lineHeight ?? 20
let ratio = image.size.width / max(image.size.height, 1)
att.bounds = CGRect(x: 0, y: -4, width: lineHeight * ratio, height: lineHeight)
let mutable = NSMutableAttributedString(attributedString: tv.attributedText)
mutable.insert(NSAttributedString(attachment: att), at: tv.selectedRange.location)
tv.attributedText = mutable
}
If editing isn't needed and iOS 18+ is acceptable, the placeholder-overlay technique is cleaner. Otherwise the wrapped UITextView is the path.
Assuming SwiftUI Text renders all AttributedString attributes. It renders about ten. The rest exist on the type, autocomplete in Xcode, and silently do nothing. If a paragraphStyle, shadow, or strokeColor doesn't appear, the code isn't broken — Text just doesn't honor those attributes. Either set the SwiftUI-equivalent modifier on Text (line spacing via .lineSpacing, etc.) or move the rendering to a TextKit-backed view.
Setting .font to a SwiftUI.Font when the content goes to UITextView. The SwiftUI scope's font is SwiftUI.Font, not UIFont. UITextView reads NSAttributedString.Key.font, which is UIFont. The two attributes coexist on AttributedString but never convert. Set str.uiKit.font = UIFont.preferredFont(forTextStyle: .body) for content destined for a UIKit view.
// WRONG — lost in conversion to NSAttributedString
str.font = .body
textView.attributedText = NSAttributedString(str)
// CORRECT — UIKit scope
str.uiKit.font = .preferredFont(forTextStyle: .body)
textView.attributedText = try NSAttributedString(str, including: \.uiKit)
Expecting presentationIntent to render. Block-level Markdown (headings, lists, blockquotes) lands in presentationIntent runs. Neither SwiftUI Text nor UITextView renders that automatically. Either parse the runs into a SwiftUI view tree, translate to paragraph styles for TextKit, or use a third-party Markdown renderer.
Calling NSAttributedString(attrStr) without including:. The default-scope conversion drops custom attributes that were declared in a non-default scope. Custom domain attributes — link styles, semantic markers, app-specific metadata — disappear. Always pass including: \.myScope (or \.uiKit / \.appKit for the framework scopes), and declare scopes on every custom attribute key.
Using .full Markdown syntax and expecting visual rendering. .full parses block-level structure but neither view auto-renders it. Either use .inlineOnly (the default) and don't expect blocks, or accept that you're parsing for structural data, not for ready-to-render output.
Treating the attribute scope as decorative. Scope determines which renderer honors the attribute. Setting foregroundColor on the SwiftUI scope and reading it in a UITextView produces invisible text. Pick the scope based on where the rendering happens, and when content is shared, set both scopes (or use the Foundation scope for renderer-agnostic content).
/skill txt-wrap-textview — wrapper mechanics around UITextView and NSTextView/skill txt-attributed-string — AttributedString vs NSAttributedString decision and conversion strategy/skill txt-swiftui-texteditor — iOS 26 SwiftUI TextEditor rich-text APIs/skill txt-markdown — Markdown rendering specificstools
Integrate Writing Tools into UITextView, NSTextView, custom UITextInput views, or fully custom editors via UIWritingToolsCoordinator. Configure writingToolsBehavior and allowedWritingToolsResultOptions, declare protected ranges via writingToolsIgnoredRangesInEnclosingRange, gate edits with isWritingToolsActive, and pause syncing in willBegin/didEnd. Trigger on 'Apple Intelligence rewrite', 'AI summarize selection', 'compose with AI', 'why won't Writing Tools appear', or 'rewrite is breaking my code blocks' even without UIWritingToolsCoordinator named. Use when Writing Tools is missing from the menu, only the panel mode appears, rewrites corrupt code blocks, the inline animation isn't running, or a custom text engine needs to adopt UIWritingToolsCoordinator. Do NOT use for diagnosing general TextKit 1 fallback symptoms — see txt-fallback-triggers.
tools
Wrap UITextView (UIViewRepresentable) and NSTextView (NSViewRepresentable) inside SwiftUI without breaking editing. Covers binding sync, infinite-update-loop guards, cursor preservation across programmatic mutations, focus / first-responder bridging, auto-sizing strategies, environment value propagation, toolbar integration, and the iOS vs macOS scroll-view differences. Use when building or debugging a SwiftUI text-view wrapper, when cursor jumps after typing, when binding updates don't propagate, when @FocusState seems ignored, or when a wrapped editor won't size to its content. Do NOT use for picking which view class (txt-view-picker) or for which AttributedString attributes survive the SwiftUI boundary (txt-swiftui-interop).
tools
Configure TextKit 2 viewport-driven layout, NSTextLayoutFragment / NSTextLineFragment geometry, and rendering attributes vs storage attributes. Covers NSTextViewportLayoutController callbacks, layoutFragmentFrame vs renderingSurfaceBounds, line-fragment local coordinates, the extra trailing line fragment, exclusion paths that split a visual line, lineFragmentPadding vs container insets, font substitution via fixAttributes, and visible/overscroll/estimated regions. Use when working with custom layout fragments, debugging clipped diacritics or descenders, computing document coordinates from a line fragment, integrating with a custom scroll view, or when scroll-bar behavior under estimated heights is the visible problem. Do NOT use for symptom-driven debugging (txt-textkit-debug), the invalidation model (txt-layout-invalidation), or the TextKit 2 API surface in general (txt-textkit2).
development
Choose between SwiftUI Text/TextField/TextEditor, UIKit UITextView, and AppKit NSTextView. Capability comparison, tradeoffs, and decision criteria for read-only display vs single-line input vs multi-line editing vs rich attributed editing vs TextKit access. Use when the user asks "which text view should I use," "should I use TextField or TextEditor," "do I need UITextView for this," or describes a feature without naming a view class. Do NOT use for wrapping UITextView in SwiftUI — see txt-wrap-textview. Do NOT use for SwiftUI/TextKit attribute compatibility rules — see txt-swiftui-interop. Do NOT use for the iOS 26 SwiftUI TextEditor rich-text APIs themselves — see txt-swiftui-texteditor.