
Axiom Uikit
- 664 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-uikit is a Claude Code reference skill that debugs and implements UIKit bridging, Auto Layout, Combine, TextKit, and UIKit animations for developers mixing SwiftUI and UIKit on iOS.
About
axiom-uikit is an MIT-licensed skill in charleswiltgen/axiom that agents must use for UIKit bridging, Auto Layout, Combine, TextKit, or UIKit animation tasks. Symptom-driven tables route issues such as UIViewRepresentable, UIViewControllerRepresentable, UIHostingController embedding, Coordinator lifecycle, and "Unable to simultaneously satisfy constraints" errors to focused guides like skills/uikit-bridging.md. Developers reach for axiom-uikit when SwiftUI wrappers misbehave, constraint logs flood Xcode, or TextKit and Combine integrations need proven patterns instead of trial-and-error. The skill is reference-oriented: match a symptom, open the mapped markdown guide, and apply UIKit-specific fixes during iOS app construction.
- Mandatory skill gate: use for ANY UIKit bridging, Auto Layout, Combine, TextKit, or UIKit animation work
- Symptom table routes constraints conflicts, missing views, and animation completion issues to dedicated references
- Covers UIViewRepresentable, UIHostingController embedding, and Coordinator/updateUIView lifecycle
- Combine publishers, AnyCancellable lifecycle, and Combine vs async/await guidance
- TextKit 2 / NSTextLayoutManager reference for rich text layout work
Axiom Uikit by the numbers
- 664 all-time installs (skills.sh)
- Ranked #269 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-uikitAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 664 |
|---|---|
| repo stars | ★ 1.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you fix UIKit Auto Layout constraint conflicts in SwiftUI?
Debug and implement UIKit bridging, Auto Layout, Combine, TextKit, and UIKit animations with symptom-driven reference guides.
Who is it for?
iOS developers bridging SwiftUI and UIKit who hit Auto Layout, Combine, TextKit, or animation errors during feature work.
Skip if: Android Jetpack Compose work, server-side Swift, or greenfield SwiftUI-only screens with no UIKit integration.
When should I use this skill?
The user reports UIKit bridging bugs, constraint satisfaction errors, TextKit issues, Combine wiring, or UIKit animation problems on iOS.
What you get
Resolved constraint setups, UIViewRepresentable implementations, and UIKit bridging patterns from axiom reference guides.
- constraint fixes
- bridging implementation patterns
By the numbers
- MIT-licensed axiom skill with skills/uikit-bridging.md reference
- Symptom table covers UIViewRepresentable and UIHostingController patterns
Files
UIKit & Bridging
You MUST use this skill for ANY UIKit bridging, Auto Layout, Combine, TextKit, or UIKit animation work.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| UIViewRepresentable, UIViewControllerRepresentable | See skills/uikit-bridging.md |
| Embedding SwiftUI in UIKit (UIHostingController) | See skills/uikit-bridging.md |
| Coordinator pattern, updateUIView lifecycle | See skills/uikit-bridging.md |
| "Unable to simultaneously satisfy constraints" | See skills/auto-layout-debugging.md |
| Constraint conflicts, ambiguous layout | See skills/auto-layout-debugging.md |
| Views not appearing, positioned incorrectly | See skills/auto-layout-debugging.md |
| CAAnimation completion handler not firing | See skills/uikit-animation-debugging.md |
| Spring physics wrong on device, duration mismatch | See skills/uikit-animation-debugging.md |
| Animation jank, CATransaction timing | See skills/uikit-animation-debugging.md |
| Combine publishers, AnyCancellable lifecycle | See skills/combine-patterns.md |
| @Published properties, Combine ↔ async/await | See skills/combine-patterns.md |
| When to use Combine vs async/await | See skills/combine-patterns.md |
UIScene lifecycle required, resizable apps, size classes, tab sidebar OS27 | See skills/uikit-modernization.md |
| TextKit 2 architecture, NSTextLayoutManager | See skills/textkit-ref.md |
| Writing Tools integration (iOS 26) | See skills/textkit-ref.md |
Viewport rendering surfaces, attachment reuse, collapsible text OS27 | See skills/textkit-ref.md |
| SwiftUI TextEditor, TextKit 1 migration | See skills/textkit-ref.md |
| PencilKit canvas, PKToolPicker, drawing persistence | See skills/pencilkit-paperkit.md |
| Apple Pencil Pro (squeeze, barrel roll, hover, haptics) | See skills/pencilkit-paperkit.md |
Handwriting recognition (PKStrokeRecognizer), stroke identity/slicing OS27 | See skills/pencilkit-paperkit-ref.md |
| PaperKit markup canvas (shapes, images, text + drawing) | See skills/pencilkit-paperkit-ref.md |
PaperKit programmatic markup model (subelements, adornments) OS27 | See skills/pencilkit-paperkit-ref.md |
Decision Tree
digraph uikit {
start [label="UIKit task" shape=ellipse];
what [label="What do you need?" shape=diamond];
start -> what;
what -> "skills/uikit-bridging.md" [label="wrap UIKit in SwiftUI\nor SwiftUI in UIKit"];
what -> "skills/auto-layout-debugging.md" [label="constraint errors,\nlayout issues"];
what -> "skills/uikit-animation-debugging.md" [label="CAAnimation bugs,\nspring physics,\ncompletion handlers"];
what -> "skills/combine-patterns.md" [label="publishers, sinks,\n@Published,\nasync/await bridge"];
what -> "skills/textkit-ref.md" [label="text layout,\nWriting Tools,\nTextKit migration"];
what -> "skills/pencilkit-paperkit.md" [label="drawing canvas,\nApple Pencil,\nPaperKit markup"];
what -> "skills/uikit-modernization.md" [label="scene lifecycle (required 27),\nresizable apps,\nsize classes"];
}0. Scene-lifecycle migration, "app won't launch on 27", resizability, size classes, tab sidebar? → skills/uikit-modernization.md 1. UIViewRepresentable / UIViewControllerRepresentable / UIHostingController? → skills/uikit-bridging.md 2. "Unable to simultaneously satisfy constraints" / layout bugs? → skills/auto-layout-debugging.md 3. CAAnimation completion missing / spring physics wrong / animation jank? → skills/uikit-animation-debugging.md 4. Combine publishers / AnyCancellable / @Published / Combine ↔ async bridge? → skills/combine-patterns.md 5. TextKit 2 / Writing Tools / TextEditor / TextKit 1 migration? → skills/textkit-ref.md 6. PencilKit canvas / Apple Pencil / PaperKit markup? → skills/pencilkit-paperkit.md 7. Pure SwiftUI view question (no UIKit bridging)? → /skill axiom-swiftui 8. Design decisions, HIG, Liquid Glass, SF Symbols, typography? → /skill axiom-design 9. Block retain cycles in UIKit callbacks? → See axiom-performance (skills/objc-block-retain-cycles.md) 10. Memory leaks from Combine subscriptions? → Start with skills/combine-patterns.md, then axiom-performance if leak persists
Conflict Resolution
uikit vs swiftui: When working with UI code:
- Use uikit when wrapping UIKit in SwiftUI or vice versa, or debugging UIKit-specific issues (Auto Layout, CAAnimation)
- Use swiftui for pure SwiftUI views, navigation, layout, animations
uikit vs concurrency: When Combine interacts with async/await:
- Use uikit (
skills/combine-patterns.md) for bridging Combine pipelines with async/await - Use concurrency for pure async/await patterns, actors, Sendable
uikit vs performance: When animations or layout cause performance issues: 1. Try uikit FIRST — Most animation jank is CATransaction timing or layer state, not a profiling issue 2. Only use performance if animation logic is correct but rendering is slow
uikit vs axiom-data: When @Published properties relate to data persistence:
- Use uikit for Combine publisher patterns and @Published lifecycle
- Use axiom-data for SwiftData/Core Data model layer concerns
Anti-Rationalization
| Thought | Reality |
|---|---|
| "I'll just use UIHostingController, it's simple" | Hosting has sizing, lifecycle, and navigation edge cases. skills/uikit-bridging.md covers the gotchas. |
| "Auto Layout error is just a warning, I'll ignore it" | Unsatisfied constraints cause unpredictable layout at runtime. Fix them now. |
| "I know how CAAnimation works" | 90% of CAAnimation bugs are CATransaction timing, not Core Animation. Check skills/uikit-animation-debugging.md. |
| "Combine is dead, just rewrite with async/await" | Combine has no deprecation notice. Rewriting working pipelines wastes time. skills/combine-patterns.md covers when to migrate vs maintain. |
| "TextKit 1 still works fine" | TextKit 1 misses Writing Tools integration and has known layout bugs Apple won't fix. See skills/textkit-ref.md. |
| "I'll store cancellables in a local variable" | Local AnyCancellable deallocates immediately, killing the subscription. |
| "I'll archive the PKCanvasView to save the drawing" | Archiving the view loses editability. Persist drawing.dataRepresentation(). See skills/pencilkit-paperkit.md. |
| "My tool picker won't show, the API must be broken" | The canvas must becomeFirstResponder() after setVisible(_:forFirstResponder:). See skills/pencilkit-paperkit.md. |
Example Invocations
User: "How do I wrap a UIKit view in SwiftUI?" → Read: skills/uikit-bridging.md
User: "I'm getting 'Unable to simultaneously satisfy constraints'" → Read: skills/auto-layout-debugging.md
User: "My CAAnimation completion handler never fires" → Read: skills/uikit-animation-debugging.md
User: "Should I use Combine or async/await for this?" → Read: skills/combine-patterns.md
User: "How do I integrate Writing Tools with my text editor?" → Read: skills/textkit-ref.md
User: "How do I add an Apple Pencil drawing canvas with the tool picker?" → Read: skills/pencilkit-paperkit.md
User: "How do I add a PaperKit markup canvas with shapes and text?" → Read: skills/pencilkit-paperkit-ref.md
User: "My SwiftUI view has a memory leak from a Combine subscription" → Read: skills/combine-patterns.md
User: "How do I embed SwiftUI in my UIKit app?" → Read: skills/uikit-bridging.md
Auto Layout Debugging
When to Use This Skill
Use when:
- Seeing "Unable to simultaneously satisfy constraints" errors in console
- Views positioned incorrectly or not appearing
- Constraint warnings during app launch or navigation
- Ambiguous layout errors
- Views appearing at unexpected sizes
- Debug View Hierarchy shows misaligned views
- Storyboard/XIB constraints behaving differently at runtime
Overview
Core Principle: Auto Layout constraint errors follow predictable patterns. Systematic debugging with proper tools identifies issues in minutes instead of hours.
Time Savings: Typical constraint debugging without this workflow: 30-60 minutes. With systematic approach: 5-10 minutes.
---
Quick Decision Tree
Constraint error in console?
├─ Can't identify which views?
│ └─ Use Symbolic Breakpoint + Memory Address Identification
├─ Constraint conflicts shown?
│ └─ Use Constraint Priority Resolution
├─ Ambiguous layout (multiple solutions)?
│ └─ Use _autolayoutTrace to find missing constraints
└─ Views positioned incorrectly but no errors?
└─ Use Debug View Hierarchy + Show Constraints---
Understanding Constraint Error Messages
Anatomy of Error Message
Unable to simultaneously satisfy constraints.
Probably at least one of the constraints in the following list you don't need.
(
"<NSLayoutConstraint:0x7f8b9c6... 'UIView-Encapsulated-Layout-Width' ... (active)>",
"<NSLayoutConstraint:0x7f8b9c5... UILabel:0x7f8b9c4... .width == 300 (active)>",
"<NSLayoutConstraint:0x7f8b9c3... UILabel:0x7f8b9c4... .leading == ... + 20 (active)>",
"<NSLayoutConstraint:0x7f8b9c2... ... .trailing == UILabel:0x7f8b9c4... .trailing + 20 (active)>"
)
Will attempt to recover by breaking constraint
<NSLayoutConstraint:0x7f8b9c5... UILabel:0x7f8b9c4... .width == 300 (active)>Key Components: 1. Memory addresses — 0x7f8b9c4... identifies views and constraints 2. Visual Format — Human-readable constraint description 3. `(active)` status — Constraint is currently enforced 4. Recovery action — Which constraint system will break (usually lowest priority)
System-Generated Constraints
UIView-Encapsulated-Layout-Width/Height:
- Created by UIKit for cells, system views
- Often source of conflicts
- Usually correct; your constraints are the problem
Autoresizing Mask Constraints:
- Format:
h=--&orv=&-- -= fixed dimension&= flexible dimension- Example:
h=--&= fixed left margin and width, flexible right margin
---
Debugging Workflow
Step 1: Set Up Symbolic Breakpoint (One-Time Setup)
Purpose: Break when constraint conflict occurs, before system breaks constraint.
Setup: 1. Open Breakpoint Navigator (⌘+7 or ⌘+8) 2. Click + → "Symbolic Breakpoint" 3. Symbol: UIViewAlertForUnsatisfiableConstraints 4. (Optional) Add Action → "Sound" → select sound 5. (Optional) Check "Automatically continue after evaluating actions"
Why this works: Pauses execution at exact moment of constraint conflict, giving you debugger access to all views and constraints.
---
Step 2: Identify Views from Memory Addresses
When breakpoint hits, console shows memory addresses like UILabel:0x7f8b9c4...
Technique 1: Use %rbx Register (When Breakpoint Hits)
# Print all involved views and constraints
po $arg1
# Or on older Xcode versions
po $rbxOutput: NSArray containing all conflicting constraints and affected views.
Technique 2: Set View Background Color
# Set background color on suspected view
expr ((UIView *)0x7f8b9c4...).backgroundColor = [UIColor redColor]
# Continue execution to see which view turned redResult: Visually identifies which view corresponds to memory address.
Technique 3: Print View Hierarchy
Objective-C projects:
po [[UIWindow keyWindow] _autolayoutTrace]Swift projects:
expr -l objc++ -O -- [[UIWindow keyWindow] _autolayoutTrace]Output: Entire view hierarchy with * marking ambiguous layouts.
Example:
*<UIView:0x7f8b9c4...>
| <UILabel:0x7f8b9c3...>The * indicates this UIView has ambiguous constraints.
Technique 4: Print Constraints for Specific View
# Horizontal constraints (axis: 0)
po [0x7f8b9c4... constraintsAffectingLayoutForAxis:0]
# Vertical constraints (axis: 1)
po [0x7f8b9c4... constraintsAffectingLayoutForAxis:1]Output: All constraints affecting that view's layout.
---
Step 3: Use Debug View Hierarchy
When to use: Views positioned incorrectly, constraints not visible in code.
Workflow: 1. Trigger the issue — Navigate to screen with constraint problems 2. Pause execution — Click "Debug View Hierarchy" button in debug bar (or Debug → View Debugging → Capture View Hierarchy) 3. Inspect 3D view — Rotate view hierarchy to see layering 4. Enable "Show Constraints" — Shows all constraints as lines 5. Select view — Right panel shows all constraints affecting selected view
Key Features:
- Show Clipped Content — Reveals views positioned off-screen
- Show Constraints — Visualizes constraint relationships
- Filter Bar — Search for specific views by class or memory address
Finding Issues:
- Purple constraints = satisfied
- Orange/red constraints = conflicts
- Select constraint → see both views it connects
---
Step 4: Name Your Constraints (Prevention)
Why: Makes error messages readable instead of cryptic memory addresses.
In Interface Builder (Storyboards/XIBs)
1. Select constraint in Document Outline 2. Open Attributes Inspector 3. Set Identifier field (e.g., "ProfileImageWidthConstraint")
Before:
<NSLayoutConstraint:0x7f8b9c5... UILabel:0x7f8b9c4... .width == 300 (active)>After:
<NSLayoutConstraint:0x7f8b9c5... 'ProfileImageWidthConstraint' UILabel:0x7f8b9c4... .width == 300 (active)>Programmatically
let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 100)
widthConstraint.identifier = "ProfileImageWidthConstraint"
widthConstraint.isActive = trueImpact: Instantly know which constraint is breaking without hunting through code.
---
Step 5: Name Your Views (Prevention)
Why: Error messages show view class AND your custom label.
In Interface Builder
1. Select view in Document Outline 2. Open Identity Inspector 3. Set Label field (e.g., "Profile Image View")
Before:
<UIImageView:0x7f8b9c4... (active)>After:
<UIImageView:0x7f8b9c4... 'Profile Image View' (active)>Programmatically
imageView.accessibilityIdentifier = "ProfileImageView"Note: Xcode automatically uses textual components (UILabel text, UIButton titles) as identifiers when available.
---
Common Constraint Conflict Patterns
Pattern 1: Conflicting Fixed Widths
Symptom:
Container width: 375
Child width: 300
Child leading: 20
Child trailing: 20
// 20 + 300 + 20 = 340 ≠ 375❌ WRONG:
// Conflicting constraints
imageView.widthAnchor.constraint(equalToConstant: 300).isActive = true
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20).isActive = true
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20).isActive = true
// Over-constrained: width + leading + trailing = 3 horizontal constraints (only need 2)✅ CORRECT Option 1 (Remove fixed width):
// Let width be calculated from leading + trailing
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20).isActive = true
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20).isActive = true
// Width will be container width - 40✅ CORRECT Option 2 (Use priorities):
let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 300)
widthConstraint.priority = .defaultHigh // 750 (can be broken if needed)
widthConstraint.isActive = true
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20).isActive = true
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20).isActive = true
// Required constraints (1000) will break lower-priority width constraint if needed---
Pattern 2: UIView-Encapsulated-Layout Conflicts
Symptom: Table cells or collection view cells conflicting with UIView-Encapsulated-Layout-Width.
Why it happens: System sets cell width based on table/collection view. Your constraints fight it.
❌ WRONG:
// In UITableViewCell
contentLabel.widthAnchor.constraint(equalToConstant: 320).isActive = true
// Conflicts with system-determined cell width✅ CORRECT:
// Use relative constraints, not fixed widths
contentLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16).isActive = true
contentLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16).isActive = true
// Width adapts to cell width automatically---
Pattern 3: Autoresizing Mask Conflicts
Symptom: Mixing Auto Layout with autoresizingMask or not setting translatesAutoresizingMaskIntoConstraints = false.
❌ WRONG:
let imageView = UIImageView()
view.addSubview(imageView)
// Forgot to disable autoresizing mask
imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true
// Conflicts with autoresizing mask constraints✅ CORRECT:
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false // ← CRITICAL
view.addSubview(imageView)
imageView.widthAnchor.constraint(equalToConstant: 100).isActive = trueWhy: translatesAutoresizingMaskIntoConstraints = true creates automatic constraints that conflict with your explicit constraints.
---
Pattern 4: Ambiguous Layout (Missing Constraints)
Symptom: View appears, but position shifts unexpectedly or _autolayoutTrace shows * (ambiguous).
Problem: Not enough constraints to determine unique position/size.
❌ WRONG (Ambiguous X position):
imageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true
imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true
imageView.heightAnchor.constraint(equalToConstant: 100).isActive = true
// Missing: horizontal position (leading/trailing/centerX)✅ CORRECT:
imageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true // ← Added
imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true
imageView.heightAnchor.constraint(equalToConstant: 100).isActive = trueRule: Every view needs:
- Horizontal: 2 constraints (e.g., leading + width, OR leading + trailing, OR centerX + width)
- Vertical: 2 constraints (e.g., top + height, OR top + bottom, OR centerY + height)
---
Pattern 5: Priority Conflicts
Symptom: Unexpected constraint breaks, but all constraints seem correct.
Problem: Multiple constraints at same priority competing.
❌ WRONG:
// Both required (priority 1000)
imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true
imageView.widthAnchor.constraint(greaterThanOrEqualToConstant: 150).isActive = true
// Impossible: width can't be 100 AND >= 150✅ CORRECT:
let preferredWidth = imageView.widthAnchor.constraint(equalToConstant: 100)
preferredWidth.priority = .defaultHigh // 750
preferredWidth.isActive = true
let minWidth = imageView.widthAnchor.constraint(greaterThanOrEqualToConstant: 150)
minWidth.priority = .required // 1000
minWidth.isActive = true
// Result: width will be 150 (required constraint wins)Priority levels (higher = stronger):
.required(1000) — Must be satisfied.defaultHigh(750) — Strong preference.defaultLow(250) — Weak preference- Custom: any value 1-999
---
Debugging Checklist
Before Debugging
- [ ] Read full error message in console (don't ignore it)
- [ ] Note which constraints are listed as conflicting
- [ ] Check if error is consistent or intermittent
During Debugging
- [ ] Set symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints
- [ ] Identify views using memory addresses (background color technique)
- [ ] Use Debug View Hierarchy to visualize constraints
- [ ] Check _autolayoutTrace for ambiguous layouts
- [ ] Verify translatesAutoresizingMaskIntoConstraints = false for programmatic views
After Fixing
- [ ] Test on multiple device sizes (iPhone SE, iPhone Pro Max)
- [ ] Test orientation changes (portrait/landscape)
- [ ] Test with Dynamic Type sizes
- [ ] Verify no console warnings during transitions
- [ ] Add constraint identifiers for future debugging
---
Advanced Techniques
Constraint Priority Strategy
Use case: View that should be certain size, but can shrink if needed.
// Preferred size: 200x200
let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 200)
widthConstraint.priority = .defaultHigh // 750
widthConstraint.isActive = true
let heightConstraint = imageView.heightAnchor.constraint(equalToConstant: 200)
heightConstraint.priority = .defaultHigh // 750
heightConstraint.isActive = true
// But never smaller than 100x100
imageView.widthAnchor.constraint(greaterThanOrEqualToConstant: 100).isActive = true
imageView.heightAnchor.constraint(greaterThanOrEqualToConstant: 100).isActive = true
// And never larger than container
imageView.widthAnchor.constraint(lessThanOrEqualTo: containerView.widthAnchor).isActive = true
imageView.heightAnchor.constraint(lessThanOrEqualTo: containerView.heightAnchor).isActive = trueResult: Image is 200x200 when space available, shrinks to fit container (min 100x100).
---
Content Hugging and Compression Resistance
Content Hugging (resist expanding):
// Label should not stretch beyond its text width
label.setContentHuggingPriority(.defaultHigh, for: .horizontal)Compression Resistance (resist shrinking):
// Label should not truncate if possible
label.setContentCompressionResistancePriority(.required, for: .horizontal)Common pattern:
// In horizontal stack: priorityLabel (hugs) + spacer + valueLabel (hugs)
priorityLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
valueLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
// Spacer fills remaining space (low hugging priority)
spacerView.setContentHuggingPriority(.defaultLow, for: .horizontal)---
Debugging Transformed Views
Problem: View transformations (rotate, scale) don't affect Auto Layout.
Gotcha:
imageView.transform = CGAffineTransform(rotationAngle: .pi / 4) // 45° rotation
// Auto Layout still uses original (un-rotated) frame for calculationsSolution: Auto Layout works correctly, but visual debugging can be confusing. Use original frame for constraint debugging.
---
Troubleshooting
Issue: Breakpoint Never Hits
Check: 1. Symbolic breakpoint symbol is exactly UIViewAlertForUnsatisfiableConstraints 2. Breakpoint is enabled (checkmark visible) 3. Constraint conflict actually exists (check console for error message)
---
Issue: Can't Identify View from Memory Address
Solution 1: Use background color technique
expr ((UIView *)0x7f8b9c4...).backgroundColor = [UIColor redColor]
continueSolution 2: Print recursive description
po [0x7f8b9c4... recursiveDescription]Solution 3: Check view's class
po [0x7f8b9c4... class]---
Issue: Debug View Hierarchy Shows No Constraints
Check: 1. Click "Show Constraints" button in debug bar (looks like constraint icon) 2. Select specific view to see its constraints in right panel 3. Constraints may be satisfied (purple) vs conflicting (orange/red)
---
Issue: Constraints Change at Runtime
Check: 1. UIKit system constraints (UIView-Encapsulated-Layout) added for cells/system views 2. Dynamic Type changes (font size changes = size invalidation) 3. Orientation changes triggering new constraints 4. View controller lifecycle (viewDidLoad vs viewWillLayoutSubviews)
---
Common Mistakes
❌ Ignoring Console Warnings
Wrong: Seeing constraint warning, continuing anyway.
Correct: Fix every constraint warning immediately. They compound and cause unpredictable layout later.
---
❌ Not Setting Identifiers
Wrong: Debugging constraints by memory address.
Correct: Always set constraint identifiers. 30 seconds now saves 30 minutes later.
---
❌ Over-Constraining
Wrong: Setting leading + trailing + width.
Correct: Use 2 of 3 (leading + trailing, OR leading + width, OR trailing + width).
---
❌ Mixing Auto Layout and Frames
Wrong:
imageView.frame = CGRect(x: 50, y: 50, width: 100, height: 100) // Manual frame
imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true // Auto LayoutCorrect: Choose one approach. If using Auto Layout, set translatesAutoresizingMaskIntoConstraints = false and let constraints determine position/size.
---
Real-World Impact
Before (no systematic approach):
- 30-60 minutes per constraint conflict
- Trial-and-error constraint changes
- Frustration from cryptic error messages
- Breaking working constraints to fix new ones
After (systematic debugging):
- 5-10 minutes per constraint conflict
- Targeted fixes with Debug View Hierarchy
- Named constraints = instant identification
- Symbolic breakpoint catches issues immediately
---
Related Skills
- For Xcode environment issues: See
axiom-build (skills/xcode-debugging.md)skill - For SwiftUI layout issues: See
axiom-swiftui(performance reference) - For testing UI: See
axiom-testing(ui-testing reference)
---
Resources
Docs: /library/archive/documentation/userexperience/conceptual/autolayoutpg/debuggingtricksandtips
---
Key Takeaways
1. Name everything — Constraints and views with identifiers save hours of debugging 2. Use symbolic breakpoint — Catch constraint conflicts at source, not after recovery 3. Debug View Hierarchy — Visualize constraints instead of guessing 4. Memory address → View — Background color technique instantly identifies mystery views 5. Two constraints per axis — Avoid over-constraining (leading + trailing + width = conflict) 6. Priorities matter — Use .required (1000) for must-haves, .defaultHigh (750) for preferences 7. Systematic wins — Following workflow saves 30-50 minutes per conflict
---
Last Updated: 2024 Minimum Requirements: Xcode 12+, iOS 11+ (symbolic breakpoints work on all versions)
Combine Patterns
Overview
Combine remains embedded in massive production codebases — UIKit delegates, NotificationCenter bridging, KVO observation, and @Published properties are everywhere. New code prefers async/await, but interop and maintenance of existing Combine pipelines is daily work. This skill covers the decisions and pitfalls that matter: when to use Combine vs async/await, how to avoid memory leaks, and how to bridge between the two paradigms.
Core principle: Combine is not dead — it's mature. The question isn't "should I use Combine?" but "is Combine the right tool for THIS specific data flow?"
When to Use This Skill
- Working with existing Combine pipelines
- Deciding between Combine and async/await for a new data flow
- Debugging AnyCancellable memory leaks or silent pipeline failures
- Using @Published or ObservableObject
- Bridging Combine publishers with async/await code
- Working with Subjects (PassthroughSubject, CurrentValueSubject)
When NOT to Use This Skill
- Timer.publish patterns → route via
axiom-concurrencyto timer-patterns skill (dedicated timer lifecycle coverage) - @Observable migration from ObservableObject → use
axiom-concurrency(modern observation) - UIKit ↔ SwiftUI bridging → route via
axiom-swiftui(view wrapping, not data flow) - General async/await patterns → use
axiom-concurrency
Example Prompts
- "Should I use Combine or async/await for this?"
- "My Combine pipeline silently stops producing values"
- "How do I convert a publisher to an async sequence?"
- "AnyCancellable is leaking — where do I store it?"
- "What's the difference between combineLatest and zip?"
- "How do I debounce a text field with Combine?"
- "My @Published property update isn't reaching the view"
- "How do I bridge a Combine publisher into async/await code?"
---
Part 1: Combine vs async/await Decision Tree
| Use Case | Combine | async/await | Why |
|---|---|---|---|
| One-shot network call | No | Yes | async/await is simpler, no cancellable management |
| Stream of values over time | Yes | AsyncStream | Combine's operators (debounce, combineLatest) are richer |
| Debounce/throttle user input | Yes | Awkward | Combine has built-in debounce/throttle; AsyncStream requires manual implementation |
| Merge multiple sources | Yes | TaskGroup | Combine's merge/combineLatest handle heterogeneous streams naturally |
| Existing UIKit KVO/Notification | Yes | Bridge | publisher(for:) and NotificationCenter.default.publisher are idiomatic Combine |
| New project iOS 17+ | No | Yes | @Observable + async/await is the modern pattern |
| Existing codebase with Combine | Maintain | Migrate incrementally | Don't rewrite working pipelines — bridge at boundaries |
Quick Decision
Is it a one-shot operation (network call, file read)?
├─ Yes → async/await (simpler, no cancellable management)
│
Does it need time-based operators (debounce, throttle, delay)?
├─ Yes → Combine (built-in operators, no manual implementation)
│
Are you combining multiple ongoing streams?
├─ Yes → Combine (combineLatest, merge, zip are purpose-built)
│
Is this new code on iOS 17+?
├─ Yes → async/await + @Observable (modern pattern)
│
Is it existing Combine code that works?
└─ Yes → Keep it. Bridge at boundaries when async/await code needs the data.---
Part 2: Publisher/Subscriber Lifecycle
AnyCancellable Storage Rules
AnyCancellable cancels its subscription when deallocated. If you don't store it, the pipeline is cancelled immediately after setup.
❌ Pipeline dies instantly
func setupPipeline() {
publisher
.sink { value in
self.handle(value) // Never called
}
// AnyCancellable returned by sink is discarded → subscription cancelled
}✅ Store in Set<AnyCancellable>
private var cancellables = Set<AnyCancellable>()
func setupPipeline() {
publisher
.sink { [weak self] value in
self?.handle(value)
}
.store(in: &cancellables)
}Why Set, Not Array
Set<AnyCancellable> is the idiomatic choice because:
store(in:)works with bothSetandRangeReplaceableCollection(includingArray), butSetis conventional- Order doesn't matter for subscriptions
- Prevents accidental duplicates if setup runs twice
4 Memory Leak Patterns
Leak 1: Strong self in sink
// ❌ LEAK: sink closure captures self strongly
publisher
.sink { value in
self.handle(value) // Strong capture → retain cycle
}
.store(in: &cancellables)
// ✅ FIX: weak self
publisher
.sink { [weak self] value in
self?.handle(value)
}
.store(in: &cancellables)Leak 2: Missing store(in:)
// ❌ LEAK: cancellable assigned to local var, not stored
let cancellable = publisher.sink { handle($0) }
// cancellable deallocated at end of scope → pipeline cancelled
// ✅ FIX: store in instance property
publisher.sink { [weak self] in self?.handle($0) }
.store(in: &cancellables)Leak 3: Over-retained cancellables
// ❌ LEAK: cancellables set never cleared, old pipelines accumulate
func refreshData() {
// Each call adds another subscription without removing the previous one
dataPublisher
.sink { [weak self] in self?.update($0) }
.store(in: &cancellables)
}
// ✅ FIX: clear before re-subscribing
func refreshData() {
cancellables.removeAll() // Cancel previous subscriptions
dataPublisher
.sink { [weak self] in self?.update($0) }
.store(in: &cancellables)
}Leak 4: assign(to:on:) strong capture
assign(to:on:) captures the on: parameter strongly. When the target is self, you get a retain cycle: self → cancellables → subscription → self.
// ❌ LEAK: assign(to:on:) retains self strongly — deinit never called
userPublisher
.map { $0.name }
.assign(to: \.displayName, on: self)
.store(in: &cancellables)
// ✅ FIX: use assign(to:) with @Published projected value (iOS 14+)
userPublisher
.map { $0.name }
.assign(to: &$displayName)
// No store(in:) needed — subscription tied to @Published property lifetimeKey difference: assign(to: &$prop) does NOT return an AnyCancellable — the subscription is managed internally and cancelled when the @Published property's owner deallocates. No retain cycle, no cancellable storage needed.
If you must support iOS 13, use sink with [weak self] instead.
---
Part 3: Essential Operators
One canonical example per group. These cover 90% of real-world usage.
Transform
// map: transform each value
publisher.map { $0.name }
// compactMap: transform + filter nil
publisher.compactMap { Int($0) }
// flatMap: one-to-many (each value produces a new publisher)
searchText
.flatMap { query in
api.search(query) // Returns a publisher
}flatMap gotcha: Without .switchToLatest() or maxPublishers: .max(1), flatMap creates a new inner publisher for every upstream value. For search-as-you-type, use map + switchToLatest instead:
searchText
.map { query in api.search(query) }
.switchToLatest() // Cancels previous search when new query arrivesCombine Multiple Sources
// combineLatest: latest value from each, fires when ANY changes
Publishers.CombineLatest(namePublisher, agePublisher)
.map { name, age in "\(name), \(age)" }
// merge: interleave values from same-type publishers
Publishers.Merge(localUpdates, remoteUpdates)
.sink { update in handle(update) }
// zip: pairs values 1:1 (waits for both to produce)
Publishers.Zip(requestA, requestB)
.sink { responseA, responseB in /* both complete */ }| Operator | Fires When | Use Case |
|---|---|---|
| combineLatest | Any input changes | Form validation (all fields) |
| merge | Any input produces | Combining event streams |
| zip | All inputs produce one value | Parallel requests that must complete together |
Time-Based
// debounce: wait until values stop arriving (search-as-you-type)
searchTextPublisher
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { [weak self] query in self?.search(query) }
.store(in: &cancellables)
// throttle: emit at most once per interval (scroll position)
scrollOffsetPublisher
.throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true)
.sink { [weak self] offset in self?.updateHeader(offset) }
.store(in: &cancellables)| Operator | Behavior | Use Case |
|---|---|---|
| debounce | Waits for silence, then emits last value | Search fields, auto-save |
| throttle(latest: true) | Emits latest value at fixed intervals | Scroll tracking, sensor data |
| throttle(latest: false) | Emits first value at fixed intervals | Rate-limiting button taps |
Error Handling
// tryMap: transform that can throw
publisher.tryMap { data in
try JSONDecoder().decode(Model.self, from: data)
}
// mapError: convert error types
publisher.mapError { error in
AppError.network(error)
}
// replaceError: provide fallback value (terminates error path)
publisher.replaceError(with: defaultValue)
// retry: re-subscribe on failure
publisher.retry(3) // Retry up to 3 times before propagating errorError handling order matters: retry should come before replaceError. Retry re-subscribes to the upstream publisher; replaceError terminates the error and makes the pipeline infallible.
api.fetchData()
.retry(3) // Try 3 more times on failure
.replaceError(with: cached) // If all retries fail, use cache
.sink { data in update(data) }
.store(in: &cancellables)replaceError after flatMap kills the outer pipeline: If replaceError is downstream of flatMap, a single inner publisher error terminates the entire pipeline — not just that one request. Move error handling inside flatMap so each inner publisher handles its own errors:
// ❌ One API error kills the entire pipeline
$searchText
.flatMap { query in api.search(query) }
.replaceError(with: []) // Pipeline completes on first error
.sink { ... }
// ✅ Each search handles its own errors independently
$searchText
.flatMap { query in
api.search(query)
.replaceError(with: []) // Only this search affected
}
.sink { ... }---
Part 4: @Published + ObservableObject
willSet Timing
@Published fires its publisher in willSet, not didSet. This means subscribers see the new value before the property has actually been set on the object.
class ViewModel: ObservableObject {
@Published var count = 0
init() {
$count.sink { newValue in
// 'newValue' is the incoming value
// BUT self.count is still the OLD value here
print("New: \(newValue), Current: \(self.count)")
// Prints "New: 1, Current: 0" when count is set to 1
}
.store(in: &cancellables)
}
}If you need to read the property's value after it's been set, don't subscribe to $count — use a didSet observer instead, or read self.count after a brief deferral. The $ publisher is designed for reacting to the incoming value, not for reading post-mutation state.
Nested ObservableObject Trap
SwiftUI does NOT observe nested ObservableObject changes. Only the top-level object's objectWillChange triggers view updates.
// ❌ View won't update when settings.theme changes
class AppState: ObservableObject {
@Published var settings = Settings() // Settings is also ObservableObject
}
class Settings: ObservableObject {
@Published var theme = "light" // Changes here don't propagate
}
// ✅ FIX: Forward objectWillChange manually
class AppState: ObservableObject {
@Published var settings = Settings()
private var cancellables = Set<AnyCancellable>()
init() {
settings.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
}Better fix for iOS 17+: Migrate to @Observable, which handles nested observation automatically. See axiom-concurrency (swift-concurrency reference) for migration patterns.
Thread Safety Warning
@Published is NOT thread-safe. Setting a @Published property from a background thread triggers objectWillChange off the main thread, which can crash SwiftUI views.
Related runtime crash class Inside an @MainActor class, .map/.filter/.sink closures silently inherit @MainActor isolation. When the publisher emits off-main, the Swift 6 runtime traps with _dispatch_assert_queue_fail or _swift_task_checkIsolatedSwift — even on warning-free builds. Place .receive(on:) before any isolated operator, or mark the closure @Sendable in. See axiom-concurrency (skills/isolation-inheritance-diag.md) for the full pattern catalog.
// ❌ CRASH: @Published set from background thread
class ViewModel: ObservableObject {
@Published var data: [Item] = []
func fetch() {
Task {
let items = await api.fetchItems()
data = items // Background thread → crash
}
}
}
// ✅ FIX: Ensure main thread
@MainActor
class ViewModel: ObservableObject {
@Published var data: [Item] = []
func fetch() {
Task {
let items = await api.fetchItems()
data = items // Safe — @MainActor ensures main thread
}
}
}---
Part 5: Bridging Combine and async/await
Publisher → AsyncSequence
Use .values to consume any publisher as an async sequence:
let cancellable = notificationPublisher
.sink { notification in handle(notification) }
// ✅ Modern equivalent using .values
for await notification in notificationPublisher.values {
handle(notification)
}Caveats with `.values`:
- The
for awaitloop runs indefinitely until the publisher completes or the Task is cancelled - Errors thrown by the publisher terminate the loop
- Only one consumer — if two
for awaitloops consume the same.values, behavior is undefined
async/await → Publisher
Wrap an async function in Future for Combine consumption:
func fetchUser(id: String) async throws -> User { ... }
// Wrap as a Combine publisher
let userPublisher = Future<User, Error> { promise in
Task {
do {
let user = try await fetchUser(id: "123")
promise(.success(user))
} catch {
promise(.failure(error))
}
}
}Future executes immediately — it runs its closure when created, not when subscribed. Wrap in Deferred if you need lazy execution:
let lazyPublisher = Deferred {
Future<User, Error> { promise in
Task {
do {
let user = try await fetchUser(id: "123")
promise(.success(user))
} catch {
promise(.failure(error))
}
}
}
}Gradual Migration Strategy
Don't rewrite working Combine code. Bridge at the boundary:
Combine pipeline → .values → async/await code
(bridge)
async function → Future → Combine pipeline
(bridge)Migration priority: 1. New code: write in async/await 2. Boundary: bridge with .values or Future 3. Existing Combine: leave working pipelines alone 4. Rewrite: only when the pipeline needs significant changes anyway
---
Part 6: Subjects
PassthroughSubject vs CurrentValueSubject
| Feature | PassthroughSubject | CurrentValueSubject |
|---|---|---|
| Initial value | None | Required |
| Late subscribers | Miss previous values | Get current value immediately |
.value property | No | Yes (read current value) |
| Use case | Events (button taps, notifications) | State (current selection, loading status) |
// Event-driven: no initial value, late subscribers miss past events
let taps = PassthroughSubject<Void, Never>()
taps.send()
// State-driven: always has a current value
let isLoading = CurrentValueSubject<Bool, Never>(false)
isLoading.value = true // Direct access
isLoading.send(false) // Also worksSend-After-Completion Pitfall
Once a Subject receives a completion event, all subsequent send() calls are silently ignored. No crash, no error — just silence.
let subject = PassthroughSubject<Int, Never>()
subject.send(1) // Delivered
subject.send(completion: .finished)
subject.send(2) // Silently ignored — no crash, no warning
// This is the most common cause of "my pipeline stopped working"Diagnosis: If a pipeline silently stops producing values, check whether anything upstream sent a .finished or .failure completion. Once complete, the pipeline is dead.
---
Part 7: Cold vs Hot Publishers (share/multicast)
Most Combine publishers are cold — they start work when subscribed and each subscriber gets its own independent execution. URLSession.dataTaskPublisher fires a new HTTP request per subscriber.
// ❌ Two subscribers = two network requests
let publisher = URLSession.shared
.dataTaskPublisher(for: url)
.map(\.data)
.eraseToAnyPublisher()
publisher.sink { cache.store($0) }.store(in: &cancellables) // Request 1
publisher.sink { display($0) }.store(in: &cancellables) // Request 2share()
.share() makes a cold publisher hot — the first subscriber triggers the work, subsequent subscribers share the output:
// ✅ One request, shared result
let publisher = URLSession.shared
.dataTaskPublisher(for: url)
.map(\.data)
.share()
.eraseToAnyPublisher()
publisher.sink { cache.store($0) }.store(in: &cancellables) // Triggers request
publisher.sink { display($0) }.store(in: &cancellables) // Shares resultshare() Gotchas
| Gotcha | Effect | Fix |
|---|---|---|
| Late subscribers miss values | share() uses PassthroughSubject — no replay | Attach all subscribers before the first value arrives, or use multicast with CurrentValueSubject |
| Upstream completed before subscriber attaches | Late subscriber immediately gets .finished with no values | Ensure subscription order, or cache the result outside Combine |
| All subscribers cancel → upstream cancels | New subscriber after that triggers a NEW upstream execution | Expected behavior, but surprising if you assumed the result was cached |
When to use share()
Multiple subscribers to the same expensive publisher?
├─ No → Don't use share() (unnecessary complexity)
│
├─ Yes, all subscribe at the same time?
│ └─ Yes → share() works
│
└─ Yes, subscribers attach at different times?
└─ Use multicast(subject:) with CurrentValueSubject, or cache the result in a property---
Anti-Rationalization
| Thought | Reality |
|---|---|
| "Combine is dead, just use async/await" | Combine has no deprecation notice. Thousands of production apps use it. Rewriting working pipelines wastes time and introduces bugs. Bridge incrementally instead. |
| "I'll just use .sink everywhere" | Without [weak self] and proper store(in:), every sink is a potential memory leak. The lifecycle rules in Part 2 prevent the top 4 leak patterns. |
| "assign(to:on:) is fine, it's the standard API" | It captures on: strongly — retain cycle if target is self. Use assign(to: &$prop) instead (Part 2, Leak 4). |
| "debounce and throttle are the same thing" | debounce waits for silence; throttle emits at intervals. Using the wrong one causes either delayed responses or missed events. Part 3 has the decision table. |
| "I know how @Published works" | @Published fires on willSet, not didSet. Nested ObservableObject doesn't propagate. Background thread access crashes. Part 4 covers all three traps. |
| "I'll migrate everything to async/await at once" | Full rewrites of working Combine code introduce bugs and waste time. Bridge at boundaries (Part 5). Rewrite only when the pipeline needs significant changes anyway. |
---
Pressure Scenarios
Scenario 1: "Let's migrate all Combine code to async/await"
Setup: Tech lead wants to modernize the codebase. "Combine is legacy, let's rip it out."
Pressure: Authority + scope creep. The entire data layer uses Combine publishers, @Published properties, and operator chains.
Expected with skill: Push back with the gradual migration strategy (Part 5). New code uses async/await. Boundaries use .values and Future. Existing working pipelines stay until they need changes. Full rewrite is the most expensive option with the least benefit.
Pushback template: "Combine isn't deprecated — Apple still ships it in every SDK. A full rewrite of working pipelines introduces bugs we don't have today. Let's bridge at boundaries: new code in async/await, .values to consume existing publishers, and we only rewrite a pipeline when we're already changing it significantly."
---
Scenario 2: "Pipeline silently stopped — just recreate it"
Setup: A Combine pipeline stopped producing values after a refactor. No crash, no error.
Pressure: Time pressure. "Just tear it down and rebuild."
Expected with skill: Diagnose before rebuilding. Check: (1) Was a completion sent upstream? (send-after-completion, Part 6). (2) Is the AnyCancellable still alive? (storage rules, Part 2). (3) Did the publisher error without handling? (replaceError / catch, Part 3). These three causes cover 90% of silent pipeline failures.
Diagnostic checklist: 1. Is the AnyCancellable still stored? (Set not cleared, not deallocated) 2. Did anything upstream send .finished or .failure? 3. Is there a tryMap or other throwing operator without error handling? 4. Was switchToLatest used where the outer publisher completed?
Pushback template: "Before rebuilding, let me check four things: cancellable lifecycle, upstream completions, unhandled errors, and switchToLatest completion. One of these is almost always the cause. It takes 5 minutes to diagnose vs 30 minutes to rebuild and test."
---
Scenario 3: "Settings changes aren't updating the UI"
Setup: A settings screen uses a nested ObservableObject. The parent AppState holds a Settings object. When the user changes settings.theme, the UI doesn't update.
Pressure: "The binding works in isolation, it must be a SwiftUI bug. Let me just force a refresh with objectWillChange.send()."
Expected with skill: Recognize the nested ObservableObject trap (Part 4). SwiftUI does NOT observe nested ObservableObject changes — only the top-level object's objectWillChange triggers view updates. The fix is either forwarding objectWillChange from the nested object, or migrating to @Observable (iOS 17+) which handles nesting automatically.
Anti-pattern without skill: Sprinkling objectWillChange.send() calls throughout the code, adding @Published to every nested property (which doesn't help), or restructuring the model to flatten everything into one object (losing separation of concerns).
Pushback template: "SwiftUI only observes the top-level ObservableObject. Nested objects need their objectWillChange forwarded to the parent. Part 4 has the exact pattern — it's a 5-line fix in the parent's init, not a SwiftUI bug."
---
Resources
WWDC: 2019-722, 2019-721, 2020-10034
Docs: /combine, /combine/anycancellable, /combine/published
Skills: swift-concurrency, memory-debugging, axiom-concurrency (skills/isolation-inheritance-diag.md)
PencilKit + PaperKit — API Reference
Comprehensive API reference for PencilKit (canvas, tool picker, stroke model, Apple Pencil) and PaperKit (markup canvas, data model, feature sets). For the discipline (setup order, persistence, gotchas, decision-making), see skills/pencilkit-paperkit.md.
Key Terminology
- PKCanvasView —
UIScrollViewsubclass that captures and renders drawing. Owns aPKDrawing. - PKDrawing — Vector stroke data. The persistable source of truth (
dataRepresentation()). - PKToolPicker — Floating palette of tools. Attached to a canvas via
addObserver(_:). - PKTool — A tool that draws on the canvas (
PKInkingTool,PKEraserTool,PKLassoTool). - PKToolPickerItem — An entry in the picker (inking, eraser, lasso, ruler, scribble, custom). iPadOS 18+.
- PaperMarkup — PaperKit data model holding markup elements + a PencilKit drawing.
- PaperMarkupViewController — PaperKit's interactive canvas. iOS 26+.
- FeatureSet — The set of PaperKit tools/elements exposed to a markup/insertion controller.
---
Part 1: PKCanvasView
import PencilKit
let canvas = PKCanvasView()
canvas.drawing = PKDrawing() // the vector data
canvas.tool = PKInkingTool(.pen, color: .black, width: 5)
canvas.drawingPolicy = .anyInput // .default | .anyInput | .pencilOnly
canvas.isRulerActive = false
canvas.delegate = coordinator
canvas.backgroundColor = .systemBackground
canvas.isOpaque = false // transparent canvas over contentPKCanvasViewDelegate
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) // strokes added/removed
func canvasViewDidBeginUsingTool(_ canvasView: PKCanvasView)
func canvasViewDidEndUsingTool(_ canvasView: PKCanvasView)
func canvasViewDidFinishRendering(_ canvasView: PKCanvasView)drawingPolicy: .default (pencil-only once a pencil is used; finger otherwise), .anyInput (finger or pencil — required in the Simulator), .pencilOnly.
---
Part 2: PKDrawing
let drawing = PKDrawing() // empty
let restored = try PKDrawing(data: savedData) // throwing init from blob
let composed = PKDrawing(strokes: [stroke1, stroke2])
let data = drawing.dataRepresentation() // versioned binary blob — persist THIS
let image = drawing.image(from: drawing.bounds, scale: 2.0) // one-way raster export
let bounds = drawing.bounds // CGRect of all strokes
let strokes = drawing.strokes // [PKStroke]
var moved = drawing
moved.transform(using: CGAffineTransform(translationX: 10, y: 0))
moved.append(otherDrawing)requiredContentVersion (PKContentVersion) reflects the newest feature used. Newer ink types (monoline, fountainPen, watercolor, crayon — iOS 17; reed — iOS 26) raise it, so a drawing made on a newer OS may not decode on an older one. Check it for forwards compatibility.
---
Part 3: Tools
// Inking
let pen = PKInkingTool(.pen, color: .systemBlue, width: 5)
pen.inkType // PKInkingTool.InkType
pen.color // UIColor
pen.width // CGFloat
// InkType cases: .pen .pencil .marker .monoline .fountainPen .watercolor .crayon .reed
// .pen/.pencil/.marker → iOS 13; .monoline/.fountainPen/.watercolor/.crayon → iOS 17; .reed → iOS 26
// Eraser
let eraser = PKEraserTool(.vector) // .vector (stroke) | .bitmap (pixel) | .fixedWidthBitmap (iOS 16.4+)
// Lasso (selection — no ink)
let lasso = PKLassoTool()---
Part 4: PKToolPicker
let picker = PKToolPicker() // default tool set (iOS 13+)
let custom = PKToolPicker(toolItems: [...]) // explicit set/order (iPadOS 18+)
picker.addObserver(canvas) // canvas mirrors picker selection into its `tool`
picker.setVisible(true, forFirstResponder: canvas)
canvas.becomeFirstResponder() // required for the picker to appear
picker.selectedToolItem // current item (use this — selectedTool is deprecated)
picker.selectedToolItemIdentifier
picker.colorUserInterfaceStyle = .dark
picker.overrideUserInterfaceStyle = .dark
picker.accessoryItem = UIBarButtonItem(...) // trailing button (iPadOS 18+); hidden when minimizedDeprecated: shared(for:) (per-window picker), selectedTool. Use an owned instance and selectedToolItem.
Tool picker items (iPadOS 18+, visionOS 2+)
PKToolPickerInkingItem(type: .pen) // has a matching PKTool; set on observing canvas automatically
PKToolPickerEraserItem(type: .vector)
PKToolPickerLassoItem()
PKToolPickerRulerItem() // toggles canvas.isRulerActive; no PKTool, not "selected"
PKToolPickerScribbleItem() // handwriting → text; auto-hides per Apple Pencil settings
PKToolPickerCustomItem(configuration:) // your own toolCustom tools
var config = PKToolPickerCustomItem.Configuration(identifier: "com.example.stamp", name: "Stamp")
config.allowsColorSelection = true
config.defaultColor = .systemRed
config.defaultWidth = 20
config.imageProvider = { item in renderThumbnail(width: item.width, color: item.color) }
let stamp = PKToolPickerCustomItem(configuration: config)
stamp.color // current color
stamp.width // current width
stamp.reloadImage() // call when a custom attribute changesWhen a custom item is selected, drawing on observing PKCanvasViews is disabled — your app renders the tool's effect.
---
Part 5: Stroke introspection (iOS 14+)
for stroke in drawing.strokes {
let ink: PKInk = stroke.ink // ink.inkType, ink.color
let path: PKStrokePath = stroke.path // interpolated points
let transform = stroke.transform // CGAffineTransform
let mask = stroke.mask // optional UIBezierPath
for point in path { // PKStrokePoint
point.location // CGPoint
point.timeOffset // TimeInterval since stroke start
point.size // CGSize (width/height of the contact)
point.opacity // CGFloat
point.force // CGFloat
point.azimuth // CGFloat (radians)
point.altitude // CGFloat (radians)
}
}PKStrokePath is sampled over time; index it (it conforms to RandomAccessCollection) or call interpolatedPoints(in:by:) (a range + a parametric stride) for even spacing.
Stroke identity, render state & slicing OS27
// PKStroke and PKStrokePath now conform to Identifiable — a stable UUID that
// survives transforms, edits, and undo (iOS27/macOS27/visionOS27).
let id: UUID = stroke.id
stroke.renderGroupID // UUID? — wet-ink compositing group (now controllable)
stroke.renderState // PKStroke.RenderState? (Sendable, Codable) — rendering control
let part = stroke.substroke(range: 0.2...0.8) // extract a sub-stroke by parametric range
// PKStrokePath <-> CGPath (Bézier) round-trips losslessly — build a PKDrawing
// from any Bézier canvas, then run recognition without a PKCanvasView.
let cg: CGPath = path.bezierRepresentation
let rebuilt = PKStrokePath(bezierPath: cg, creationDate: .now) { converted in
PKStrokePoint(/* size/opacity/force per control point */)
}Programmatic erasing slices one stroke into masked pieces (iOS27/visionOS27, not macOS):
var drawing = canvasView.drawing
let eraserPath: PKStrokePath = … // the path to slice along (a PKStrokePath, not a CGPath)
drawing.erasePath(eraserPath, mask: nil, transform: .identity) // mutating
let sliced = drawing.erasingPath(eraserPath) // non-mutating copySlicing is expensive on complex drawings — do it off the main thread.
---
Part 5b: Handwriting recognition — PKStrokeRecognizer OS27
PKStrokeRecognizer is a Swift actor (all access is await) bringing on-device handwriting recognition to PencilKit (iOS27/macOS27/visionOS27). The model is offline, bundled with the OS, and runs on every 27-capable device. Also available through PaperKit.
import PencilKit
let recognizer = PKStrokeRecognizer() // or init(preferredLanguages:)
await recognizer.updateDrawing(drawing) // feed/refresh the strokes
// 1. Best transcription (optionally scoped to a stroke subset):
let text = await recognizer.recognizedText() // String?
let part = await recognizer.recognizedText(strokeIDs: selectedIDs) // String?
// 2. All candidates concatenated — index this for Spotlight:
if let indexable = await recognizer.indexableContent { index(indexable) }
// 3. Search — returns the bounds of each match (drives highlight / UIFindInteraction):
for result in await recognizer.search("apple") {
highlight(result.bounds) // result.strokes: Set<UUID>, result.bounds: CGRect
}| Member | Notes |
|---|---|
init(preferredLanguages: [Locale.Language]? = nil) | 29 supported languages; Simulator does Latin-script only |
static var supportedLanguages: Set<Locale.Language> | query availability |
static var recognitionVersion: Int | store with indexed content; re-index when it changes |
updateDrawing(_:) async | feed/refresh the drawing before reading results |
recognizedText(strokeIDs:) async -> String? | best transcription; strokeIDs defaults to whole drawing |
indexableContent: String? | all candidates, for search indexing |
search(_:fullWordsOnly:caseMatchingOnly:) async -> [SearchResult] | SearchResult { strokes: Set<UUID>; bounds: CGRect } |
---
Part 6: Apple Pencil interactions
UIKit — UIPencilInteraction
let interaction = UIPencilInteraction()
interaction.delegate = self
view.addInteraction(interaction)
// iPadOS 12.1+ (Apple Pencil 2nd gen)
func pencilInteraction(_ i: UIPencilInteraction, didReceiveTap tap: UIPencilInteraction.Tap) {
// tap.hoverPose (UIPencilHoverPose?) on supported hardware
}
// iPadOS 17.5+ (Apple Pencil Pro)
func pencilInteraction(_ i: UIPencilInteraction, didReceiveSqueeze squeeze: UIPencilInteraction.Squeeze) {
guard squeeze.phase == .ended, let pose = squeeze.hoverPose else { return }
showPalette(at: pose.location) // pose: location, zOffset, azimuth, altitude, rollAngle
}If the device-global squeeze preference is set to run a system shortcut, the squeeze event is not delivered to your app.
SwiftUI
canvas
.onPencilDoubleTap { value in /* value.hoverPose */ }
.onPencilSqueeze { phase in
if case .ended(let value) = phase, let pose = value.hoverPose { show(at: pose.location) }
}Barrel roll (iOS 17.5+)
touch.rollAngle // CGFloat; 0 on pencils without the sensor
hoverGestureRecognizer.rollAngle // also on UIHoverGestureRecognizer
// Refine over Bluetooth:
override func touchesEstimatedPropertiesUpdated(_ touches: Set<UITouch>) { ... }Drawing haptics (iOS 17.5+)
// UIKit
let fb = UICanvasFeedbackGenerator(view: canvasView)
fb.alignmentOccurred(at: point)
fb.pathCompleted(at: point)
// SwiftUI — the .sensoryFeedback enum cases are iOS 17.0+ (UICanvasFeedbackGenerator is 17.5+)
canvas
.sensoryFeedback(.alignment, trigger: alignCount)
.sensoryFeedback(.pathComplete, trigger: snapCount)UIFeedbackGenerator and subclasses now take a view on init and a point when generating feedback — update existing uses.
---
Part 7: PaperKit data model (iOS 26+)
PaperMarkup is a struct — append and the insertNew… methods are mutating, so hold it in a var (a let won't compile) and reassign the VC's markup after editing.
import PaperKit
var markup = PaperMarkup(bounds: view.bounds) // var — mutating methods below
let loaded = try PaperMarkup(dataRepresentation: data)
markup.bounds
markup.featureSet
markup.indexableContent // text for Spotlight/search
markup.append(contentsOf: otherMarkup) // mutating
markup.append(contentsOf: pkDrawing) // mutating — drops a PKDrawing straight in
markup.insertNewImage(cgImage, frame: rect, rotation: 0) // takes a CGImage (uiImage.cgImage), not UIImage
markup.insertNewShape(configuration: shapeConfig, frame: rect, rotation: 0)
markup.insertNewTextbox(attributedText: text, frame: rect, rotation: 0)
markup.removeContentUnsupported(by: .version1) // forwards-compat: strip content newer than a FeatureSet
let data = try await markup.dataRepresentation() // async throws
await markup.draw(in: cgContext, frame: rect, options: .init()) // render thumbnail (async)---
Part 8: PaperKit controllers (iOS 26+)
// Interactive canvas
let markupVC = PaperMarkupViewController(markup: markup, supportedFeatureSet: .latest)
markupVC.delegate = self
markupVC.isEditable = true
markupVC.drawingTool = PKInkingTool(.pen, color: .black, width: 5)
markupVC.contentVisibleFrame // visible canvas region
toolPicker.addObserver(markupVC) // markup VC observes the PKToolPicker
// PaperMarkupViewController conforms to Observable — observe instead of delegating if preferred
// Insertion menu — iOS / iPadOS / visionOS
let insert = MarkupEditViewController(supportedFeatureSet: .latest, additionalActions: [])
insert.delegate = markupVC
// present as a popover anchored to the tool picker's accessoryItem
// Insertion toolbar — macOS
let toolbar = MarkupToolbarViewController(supportedFeatureSet: .latest)
toolbar.delegate = markupVC
toolbar.selectedDrawingToolPaperMarkupViewController.Delegate surfaces markup-change callbacks (use them to auto-save the model). Embed via standard UIViewController (iOS) / NSViewController (macOS) containment, or wrap in UIViewControllerRepresentable for SwiftUI.
---
Part 9: FeatureSet (iOS 26+)
var features = FeatureSet.latest // also: .empty, .version1
features.remove(.someFeature) // FeatureSet.Feature
features.insert(.someFeature)
features.colorMaximumLinearExposure = 4 // > 1 enables HDR inks; 1 = SDR
features.features // Set<FeatureSet.Feature>
markupVC.supportedFeatureSet = features
insert.supportedFeatureSet = features // keep markup + insertion controllers in sync
toolPicker.colorMaximumLinearExposure = 4 // set on the picker too for HDR inksUse FeatureSet.latest to track new framework features automatically. For HDR, tone-map down with the screen's headroom (UIScreen / NSScreen).
---
Part 10: PaperKit programmatic markup model OS27
At 27 PaperKit "opens up" (iOS27/macOS27/visionOS27): you read and mutate every element on the canvas directly, not just append. The entry point is PaperMarkup.subelements — a read/write MarkupOrderedSet.
var markup = PaperMarkup(bounds: CGRect(origin: .zero, size: pageSize))
var subelements = markup.subelements // MarkupOrderedSet — ordered collection of all elements
let shape = ShapeMarkup(configuration: configuration, frame: panelFrame) // init(configuration:frame:rotation:)
subelements.append(shape)
markup.subelements = subelements // write back (MarkupOrderedSet is a value type)
markup.backgroundColor = UIColor.systemBackground.cgColor // new: CGColor?Every element conforms to `Markup` — common frame, rotation, and allowedInteractions:
// Lock a template element so users can't move/resize/delete/style it:
guard var shape = element as? ShapeMarkup else { return }
shape.allowedInteractions = .readOnly // MarkupInteractions OptionSet
shape.strokeColor = .label
shape.fillColor = selectedColor.copy(alpha: 0.15)
subelements.updateOrAppend(shape) // replace in place by idMarkupInteractions (OptionSet): .move, .resize, .rotate, .delete, .style, .select, .all, .readOnly. Concrete markups: ShapeMarkup (init(configuration:frame:rotation:)), ImageMarkup (non-failable init(image: CGImage, frame:…), or a failable init?(image: UIImage, frame:…)), LinkMarkup (init(url:frame:…)), LoupeMarkup, and PKStroke (each Apple Pencil stroke is a markup element).
Adornments — interactive overlays (not persisted)
MarkupAdornments are visual overlays anchored to canvas coordinates that auto-track zoom and scroll. They are not part of the saved/printed/exported markup — use them for buttons, annotations, collaboration UI.
let adornment = MarkupAdornment(
anchor: .canvas(location: center),
imageConfiguration: .systemImage("photo.badge.plus") // .systemImage(_:tint:size:alignmentAnchor:)
)
paperMarkupViewController.adornments = [adornment]
// Route taps via the VC delegate:
func paperMarkupViewController(_ vc: PaperMarkupViewController, didTapAdornmentWithID id: UUID) {
// e.g. present ImagePlaygroundViewController; on completion insert an ImageMarkup
// into markup.subelements (Image Playground integration).
}---
Resources
WWDC: 2019-221, 2020-10107, 2024-10214, 2025-285, 2026-203, 2026-372
Docs: /pencilkit, /pencilkit/pkcanvasview, /pencilkit/pkdrawing, /pencilkit/pktoolpicker, /pencilkit/pktoolpickercustomitem, /pencilkit/pkstroke, /pencilkit/pkstrokerecognizer, /pencilkit/pkstrokepath, /pencilkit/pkinkingtool, /uikit/uipencilinteraction, /uikit/uitouch/rollangle, /paperkit, /paperkit/papermarkup, /paperkit/papermarkupviewcontroller, /paperkit/markup, /paperkit/markupadornment, /paperkit/featureset
Skills: skills/pencilkit-paperkit.md, skills/uikit-bridging.md, axiom-data (drawing persistence), axiom-swiftui (canvas wrapping)
PencilKit + PaperKit — Drawing & Markup
PencilKit gives you a system-quality drawing canvas (PKCanvasView) and the platform tool picker (PKToolPicker) with almost no code. PaperKit — new in the 26 SDKs — layers a full markup experience on top: shapes, images, text boxes, and PencilKit drawing in one canvas, powered by the same engine Notes, Markup, QuickLook, and the Journal app use.
Core mental model
PencilKit is UIKit. PKCanvasView is a UIScrollView subclass; there is no SwiftUI-native canvas, so every SwiftUI integration wraps it in UIViewRepresentable. The canvas owns a PKDrawing — the vector stroke data. You persist that drawing's dataRepresentation(), never the view. The tool picker is a separate object you attach to the canvas as an observer and show against a first responder.
PaperKit sits one level up. A PaperMarkupViewController renders an interactive canvas backed by a PaperMarkup data model that stores both the markup elements and a PencilKit drawing. PaperKit is 26.0+ only — gate every use.
When to Use This Skill
- Adding a drawing, handwriting, or annotation canvas with
PKCanvasView+PKToolPicker - Persisting, loading, or re-rendering
PKDrawingdata - Building custom tools into the tool picker (iPadOS 18+)
- Wiring Apple Pencil Pro features — double-tap, squeeze, barrel roll, hover pose, haptics
- Adding a rich markup canvas (shapes / images / text + drawing) with PaperKit (26.0+)
- Bridging any of the above into SwiftUI
For the full type/property surface, see skills/pencilkit-paperkit-ref.md. For wrapping UIKit in SwiftUI, see skills/uikit-bridging.md. For persisting the drawing blob in a store, see axiom-data.
System Requirements
| API | Availability |
|---|---|
PKCanvasView, PKToolPicker, setVisible(_:forFirstResponder:), addObserver(_:) | iOS 13+, iPadOS 13+, Mac Catalyst 13.1+, visionOS 1+ — no native macOS |
PKStroke, PKStrokePoint, PKInk (stroke introspection) | iOS 14+ |
PKToolPicker.init(toolItems:), PKToolPickerCustomItem, accessory bar button | iPadOS 18+, visionOS 2+ |
Double-tap (pencilInteraction(_:didReceiveTap:), .onPencilDoubleTap) | iPadOS 12.1+ (Apple Pencil 2nd gen) |
Squeeze + hover pose (didReceiveSqueeze:, .onPencilSqueeze), UITouch.rollAngle | iOS/iPadOS 17.5+ (Apple Pencil Pro) |
UICanvasFeedbackGenerator (canvas haptics) | iOS 17.5+ — SwiftUI .sensoryFeedback(.alignment / .pathComplete) itself is iOS 17.0+ |
PaperKit (PaperMarkupViewController, PaperMarkup, MarkupEditViewController, MarkupToolbarViewController, FeatureSet) | iOS / iPadOS / Mac Catalyst / macOS / visionOS 26.0+ |
PencilKit runs on Mac Catalyst, but the tool picker does not display on Catalyst — provide your own tool UI there. PaperKit does run natively on macOS 26 (Tahoe).
Critical Gotchas
| Gotcha | Why it bites | Fix |
|---|---|---|
| Tool picker never appears | The picker only shows for the active first responder | addObserver(canvas), setVisible(true, forFirstResponder: canvas), then canvas.becomeFirstResponder() |
PKToolPicker.shared(for:) returns nothing useful | Deprecated — it is no longer the per-window picker | Create your own PKToolPicker() and hold a strong reference |
selectedTool is deprecated | Replaced by item-based selection | Read selectedToolItem / selectedToolItemIdentifier |
| Saved drawing won't reload | You archived the view (or a screenshot), not the drawing | Persist drawing.dataRepresentation(); restore with PKDrawing(data:) |
| Squeeze handler never fires | A device-global preference can route squeeze to a system shortcut — your app then gets no event | Treat squeeze as an enhancement; never gate a core feature on it |
| Finger drawing does nothing | Default drawingPolicy becomes pencil-only once a pencil is used | Set canvas.drawingPolicy = .anyInput to allow finger / Simulator drawing |
| PaperKit symbols won't compile | PaperKit is 26.0+ only | Wrap in if #available(iOS 26, *) with a fallback |
Part 1 — The canvas + tool picker (the part everyone gets wrong)
The picker is attached to the canvas as an observer, made visible for a responder, and the canvas must then become first responder. Miss the last step and the picker silently never shows.
import PencilKit
final class DrawingViewController: UIViewController {
private let canvasView = PKCanvasView()
private let toolPicker = PKToolPicker() // hold a strong reference
override func viewDidLoad() {
super.viewDidLoad()
canvasView.frame = view.bounds
canvasView.drawingPolicy = .anyInput // allow finger + Simulator drawing
view.addSubview(canvasView)
toolPicker.addObserver(canvasView) // canvas reacts to tool changes
toolPicker.setVisible(true, forFirstResponder: canvasView)
canvasView.becomeFirstResponder() // REQUIRED — or the picker won't appear
}
}drawingPolicy options: .default (pencil-only after a pencil is detected), .anyInput (finger or pencil — needed in the Simulator), .pencilOnly.
Part 2 — Persisting drawings
Persist the drawing's data, not the view. dataRepresentation() is a versioned binary blob; round-trip it through PKDrawing(data:).
// Save
let data = canvasView.drawing.dataRepresentation()
try data.write(to: drawingURL)
// Load
let restored = try PKDrawing(data: Data(contentsOf: drawingURL))
canvasView.drawing = restored
// Export a raster image for thumbnails / sharing (does not round-trip)
let image = canvasView.drawing.image(from: canvasView.drawing.bounds, scale: UIScreen.main.scale)Store the Data blob in your model (a SwiftData/Core Data attribute, a file). Re-rendering an image is one-way — keep the PKDrawing data as the source of truth so strokes stay editable. See axiom-data for storing the blob safely.
Part 3 — SwiftUI integration
No native SwiftUI canvas exists. Wrap PKCanvasView in UIViewRepresentable and bridge the drawing with a binding.
import SwiftUI
import PencilKit
struct CanvasView: UIViewRepresentable {
@Binding var drawing: PKDrawing
let toolPicker = PKToolPicker()
func makeUIView(context: Context) -> PKCanvasView {
let canvas = PKCanvasView()
canvas.drawingPolicy = .anyInput
canvas.delegate = context.coordinator
canvas.drawing = drawing
toolPicker.addObserver(canvas)
toolPicker.setVisible(true, forFirstResponder: canvas)
DispatchQueue.main.async { canvas.becomeFirstResponder() }
return canvas
}
func updateUIView(_ canvas: PKCanvasView, context: Context) {
if canvas.drawing != drawing { canvas.drawing = drawing }
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
final class Coordinator: NSObject, PKCanvasViewDelegate {
let parent: CanvasView
init(_ parent: CanvasView) { self.parent = parent }
func canvasViewDrawingDidChange(_ canvas: PKCanvasView) {
parent.drawing = canvas.drawing // push edits back to the binding
}
}
}See skills/uikit-bridging.md for the representable lifecycle and coordinator gotchas.
Part 4 — Apple Pencil tiers and interactions
Features are gated by hardware, not just OS. Check before assuming a gesture exists.
| Apple Pencil | Double-tap | Squeeze | Barrel roll | Hover |
|---|---|---|---|---|
| 1st gen / USB-C | No | No | No | No |
| 2nd gen | Yes | No | No | Yes (M2 iPad) |
| Pro | Yes | Yes | Yes | Yes (+ roll) |
UIKit interactions go through UIPencilInteraction; SwiftUI has matching view modifiers.
// SwiftUI — double-tap and squeeze
myCanvas
.onPencilDoubleTap { value in
// value.hoverPose has location / azimuth / altitude / rollAngle (if available)
toggleEraser()
}
.onPencilSqueeze { phase in
// Respect the user's Settings preference; squeeze may be routed to a shortcut
if case .ended(let value) = phase, let pose = value.hoverPose {
showToolPalette(at: pose.location)
}
}Treat squeeze as a single discrete action. If the device preference is set to run a system shortcut, your app never receives the squeeze event — so it must remain an enhancement, not a requirement.
Part 5 — Apple Pencil Pro: barrel roll, hover, haptics
PKCanvasView applies barrel roll to the marker and fountain pen automatically. For a custom canvas, read rollAngle (iOS 17.5+, returns 0 on pencils without the sensor) from UITouch or UIHoverGestureRecognizer.
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
// Combine roll + azimuth so finger / older-pencil input still varies the stroke
let angle = touch.rollAngle + touch.azimuthAngle(in: view)
applyStrokeAngle(angle)
}
// Roll is estimated first, then refined over Bluetooth — capture the final value
override func touchesEstimatedPropertiesUpdated(_ touches: Set<UITouch>) {
for touch in touches { finalizeStrokeAngle(touch.rollAngle, for: touch.estimationUpdateIndex) }
}Use roll for stroke input, not for driving UI controls. For drawing haptics, use UICanvasFeedbackGenerator (.alignment when snapping to a guide, .pathComplete when a stroke snaps to a recognized shape), or SwiftUI's .sensoryFeedback(.alignment, trigger:) / .pathComplete.
Part 6 — Custom tools in the tool picker (iPadOS 18+)
init(toolItems:) lets you choose and order the picker's tools, and PKToolPickerCustomItem adds your tool (a stamp, a retouch brush) alongside the system tools. When a custom item is selected, drawing on observing canvases is turned off and your app does the rendering — PencilKit only does the picking.
var config = PKToolPickerCustomItem.Configuration(identifier: "com.example.stamp", name: "Stamp")
config.imageProvider = { item in renderStampThumbnail(width: item.width, color: item.color) }
let stamp = PKToolPickerCustomItem(configuration: config)
let picker = PKToolPicker(toolItems: [
PKToolPickerInkingItem(type: .pen),
PKToolPickerEraserItem(type: .vector),
PKToolPickerLassoItem(),
PKToolPickerRulerItem(),
stamp,
])Call the item's reloadImage() when a custom attribute changes so the picker thumbnail updates.
Part 7 — PaperKit (26.0+)
PaperKit is built from three pieces:
- `PaperMarkup` — the data model container (a struct — its
insertNew…/appendmethods aremutating, so hold it in avar). Saves/loads markup and the PencilKit drawing; renders thumbnails via itsdrawfunction. - `PaperMarkupViewController` — the interactive canvas. Observes a
PKToolPicker; conforms toObservable(or use its delegate). - The insertion menu —
MarkupEditViewControlleron iOS/iPadOS/visionOS, or aMarkupToolbarViewControlleron macOS.
if #available(iOS 26, *) {
let markupModel = PaperMarkup(bounds: view.bounds)
let markupVC = PaperMarkupViewController(markup: markupModel, supportedFeatureSet: .latest)
addChild(markupVC)
view.addSubview(markupVC.view)
markupVC.didMove(toParent: self)
let toolPicker = PKToolPicker()
toolPicker.addObserver(markupVC) // markup VC reacts to tool changes
}Forwards compatibility is mandatory. A drawing saved by a newer OS may not load on an older one. On load, verify the content version; on mismatch, show a pre-rendered thumbnail (render it at save time with the model's draw into a CGContext) rather than failing. This is what Notes does. To proactively drop content an older FeatureSet can't render, call markup.removeContentUnsupported(by: .version1) before saving or displaying.
FeatureSet controls which tools/elements are available. Start from FeatureSet.latest, then remove/insert to customize, and assign the same set to both the markup controller and the insertion controller. Enable HDR inks by setting colorMaximumLinearExposure > 1 on the feature set and the tool picker (use 1 for SDR). Set contentView to any UIView to render markup over a background template.
PaperKit's PaperMarkup interoperates with PencilKit — append(contentsOf:) accepts a PKDrawing, so existing PencilKit content drops straight in.
Part 8 — Handwriting recognition & programmatic markup OS27
Two big 27 additions (full signatures in skills/pencilkit-paperkit-ref.md):
- On-device handwriting recognition —
PKStrokeRecognizer, a Swift actor (allawait). Feed it aPKDrawing(updateDrawing), then readrecognizedText(),indexableContent(for Spotlight), orsearch(_:)(returns match bounds for highlighting). Offline, 29 languages, on every 27-capable device. Works without aPKCanvasViewbecausePKStrokePathnow round-trips to/fromCGPath(Bézier). - PaperKit opens up —
PaperMarkup.subelements(a read/writeMarkupOrderedSet) lets you read and mutate every element. Each conforms toMarkup(frame/rotation/allowedInteractions); lock template elements withallowedInteractions = .readOnly.MarkupAdornments add interactive overlays that are not persisted.
@available(iOS 27, macOS 27, visionOS 27, *)
func transcribe(_ drawing: PKDrawing) async -> String? {
let recognizer = PKStrokeRecognizer()
await recognizer.updateDrawing(drawing)
return await recognizer.recognizedText()
}Common Mistakes
- Forgetting
becomeFirstResponder()— the most common "the tool picker won't show" bug. - Using
PKToolPicker.shared(for:)orselectedTool— both deprecated; use an instance andselectedToolItem. - Persisting a screenshot or the view instead of
drawing.dataRepresentation()— strokes become uneditable. - Leaving
drawingPolicyat.defaultand wondering why finger/Simulator drawing is dead. - Gating a core feature on squeeze — the user may have routed it to a system shortcut.
- Assuming barrel roll / squeeze exist — they are Apple Pencil Pro only;
rollAngleis0otherwise. - Calling PaperKit without an availability gate — it is 26.0+ and will not compile against older SDK targets.
- Skipping PaperKit forwards-compatibility — newer files fail to open with no fallback thumbnail.
- Calling
PKStrokeRecognizersynchronously (OS27) — it's an actor; every call isawait, and you mustupdateDrawingbefore reading results. - Running stroke slicing (
erasePath/substroke,OS27) on the main thread — it's expensive on complex drawings; do it off-main. - Persisting
MarkupAdornments (OS27) — they're overlay-only, never saved/printed/exported; store their state yourself.
Resources
WWDC: 2019-221, 2020-10107, 2024-10214, 2025-285, 2026-203, 2026-372
Docs: /pencilkit, /pencilkit/pkcanvasview, /pencilkit/pktoolpicker, /pencilkit/pkdrawing, /pencilkit/pkstroke, /pencilkit/pkstrokerecognizer, /uikit/uipencilinteraction, /uikit/uitouch/rollangle, /paperkit, /paperkit/papermarkupviewcontroller, /paperkit/papermarkup, /paperkit/markupadornment
Skills: skills/pencilkit-paperkit-ref.md, skills/uikit-bridging.md (UIViewRepresentable), axiom-data (persisting drawing data), axiom-swiftui (SwiftUI canvas wrapping)
TextKit 2 Reference
Complete reference for TextKit 2 covering architecture, migration from TextKit 1, Writing Tools integration, and SwiftUI TextEditor with AttributedString through iOS 26.
Architecture
TextKit 2 uses MVC pattern with new classes optimized for correctness, safety, and performance.
Model Layer
NSTextContentManager (abstract)
- Generates NSTextElement objects from backing store
- Tracks element ranges within document
- Default implementation: NSTextContentStorage
NSTextContentStorage
- Uses NSTextStorage as backing store
- Automatically divides content into NSTextParagraph elements
- Generates updated elements when text changes
NSTextElement (abstract)
- Represents portion of content (paragraph, attachment, custom type)
- Immutable value semantics
- Properties cannot change after creation
- Default implementation: NSTextParagraph
NSTextParagraph
- Represents single paragraph
- Contains range within document
Controller Layer
NSTextLayoutManager
- Replaces TextKit 1's NSLayoutManager
- NO glyph APIs (abstracts away glyphs entirely)
- Takes elements, lays out into container, generates layout fragments
- Always uses noncontiguous layout
NSTextLayoutFragment
- Immutable layout information for one or more elements
- Key properties:
textLineFragments— array of NSTextLineFragmentlayoutFragmentFrame— layout bounds within containerrenderingSurfaceBounds— actual drawing bounds (can exceed frame)
NSTextLineFragment
- Measurement info for single line of text
- Used for line counting and geometric queries
View Layer
NSTextViewportLayoutController
- Source of truth for viewport layout
- Coordinates visible-only layout
- Calls delegate methods:
willLayout,configureRenderingSurface,didLayout
NSTextContainer
- Provides geometric information for layout destination
- Can define exclusion paths (non-rectangular layout)
Object-Based Ranges
NSTextLocation (protocol)
- Represents single location in text
- Replaces integer indices
- Supports structured documents (e.g., DOM with nested elements)
NSTextRange
- Start and end locations (end is excluded)
- Can represent nested structure
- Incompatible with NSRange for non-linear documents
NSTextSelection
- Contains: granularity, affinity, possibly disjoint ranges
- Read-only properties
- Immutable value semantics
NSTextSelectionNavigation
- Performs actions on selections
- Returns new NSTextSelection instances
- Handles bidirectional text correctly
Core Design Principles
1. Correctness — No Glyph APIs
From WWDC 2021:
"TextKit 2 abstracts away glyph handling to provide a consistent experience for international text."
Why no glyphs?
Problem: In scripts like Kannada and Arabic:
- One glyph can represent multiple characters (ligatures)
- One character can split into multiple glyphs
- Glyphs reorder during shaping
- No correct character→glyph mapping
Example (Kannada word "October"):
- Character 4 splits into 2 glyphs
- Glyphs reorder before ligature application
- Glyph 3 becomes conjoining form and moves below another glyph
Solution: Use NSTextLocation, NSTextRange, NSTextSelection instead of glyph indices.
2. Safety — Value Semantics
Immutable objects:
- NSTextElement
- NSTextLayoutFragment
- NSTextLineFragment
- NSTextSelection
Benefits:
- No unintended sharing
- No side effects from mutations
- Easier to reason about state
Pattern: To change layout/selection, create new instances with desired changes.
3. Performance — Viewport Layout
Always Noncontiguous: TextKit 2 performs layout only for visible content + overscroll region.
TextKit 1:
- Optional noncontiguous layout (boolean property)
- No visibility into layout state
- Can't control which parts get laid out
TextKit 2:
- Always noncontiguous
- Viewport defines visible area
- Consistent layout info for viewport
- Notifications for viewport layout updates
Viewport Delegate Methods: 1. textViewportLayoutControllerWillLayout(_:) — setup before layout 2. textViewportLayoutController(_:configureRenderingSurfaceFor:) — per fragment 3. textViewportLayoutControllerDidLayout(_:) — cleanup after layout
Migration from TextKit 1
Key Paradigm Shift
| TextKit 1 | TextKit 2 |
|---|---|
| Glyphs | Elements |
| NSRange | NSTextLocation/NSTextRange |
| NSLayoutManager | NSTextLayoutManager |
| Glyph APIs | NO glyph APIs |
| Optional noncontiguous | Always noncontiguous |
| NSTextStorage directly | Via NSTextContentManager |
API Naming Heuristics
From WWDC 2022:
.offsetin name → TextKit 1.locationin name → TextKit 2
NSRange ↔ NSTextRange Conversion
NSRange → NSTextRange:
// UITextView/NSTextView
let nsRange = NSRange(location: 0, length: 10)
// Via content manager
let startLocation = textContentManager.location(
textContentManager.documentRange.location,
offsetBy: nsRange.location
)!
let endLocation = textContentManager.location(
startLocation,
offsetBy: nsRange.length
)!
let textRange = NSTextRange(location: startLocation, end: endLocation)NSTextRange → NSRange:
let startOffset = textContentManager.offset(
from: textContentManager.documentRange.location,
to: textRange.location
)
let length = textContentManager.offset(
from: textRange.location,
to: textRange.endLocation
)
let nsRange = NSRange(location: startOffset, length: length)Glyph API Replacements
NO direct glyph API equivalents. Must use higher-level structures.
Example (TextKit 1 - counting lines):
// TextKit 1 - iterate glyphs
var lineCount = 0
let glyphRange = layoutManager.glyphRange(for: textContainer)
for glyphIndex in glyphRange.location..<NSMaxRange(glyphRange) {
let lineRect = layoutManager.lineFragmentRect(
forGlyphAt: glyphIndex,
effectiveRange: nil
)
// Count unique rects...
}Replacement (TextKit 2 - enumerate fragments):
// TextKit 2 - enumerate layout fragments
var lineCount = 0
textLayoutManager.enumerateTextLayoutFragments(
from: textLayoutManager.documentRange.location,
options: [.ensuresLayout]
) { fragment in
lineCount += fragment.textLineFragments.count
return true
}Compatibility Mode (UITextView/NSTextView)
Automatic Fallback to TextKit 1: Happens when you access .layoutManager property.
Warning (WWDC 2022):
"Accessing textView.layoutManager triggers TK1 fallback"
Once fallback occurs:
- No automatic way back to TextKit 2
- Expensive to switch
- Lose UI state (selection, scroll position)
- One-way operation
Prevent Fallback: 1. Check .textLayoutManager first (TextKit 2) 2. Only access .layoutManager in else clause 3. Opt out at initialization if TK1 required
// Check TextKit 2 first
if let textLayoutManager = textView.textLayoutManager {
// TextKit 2 code
} else if let layoutManager = textView.layoutManager {
// TextKit 1 fallback (old OS versions)
}Debug Fallback:
- UIKit: Breakpoint on
_UITextViewEnablingCompatibilityMode - AppKit: Subscribe to
willSwitchToNSLayoutManagerNotification
NSTextView Opt-In (macOS)
Create TextKit 2 NSTextView:
let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer()
textLayoutManager.textContainer = textContainer
let textView = NSTextView(frame: .zero, textContainer: textContainer)
// textView.textLayoutManager now availableNew Convenience Constructor:
// iOS 16+ / macOS 13+
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)Delegate Hooks
NSTextContentStorageDelegate
Customize attributes without modifying storage:
func textContentStorage(
_ textContentStorage: NSTextContentStorage,
textParagraphWith range: NSRange
) -> NSTextParagraph? {
// Modify attributes for display
var attributedString = textContentStorage.attributedString!
.attributedSubstring(from: range)
// Add custom attributes
if isComment(range) {
attributedString.addAttribute(
.foregroundColor,
value: UIColor.systemIndigo,
range: NSRange(location: 0, length: attributedString.length)
)
}
return NSTextParagraph(attributedString: attributedString)
}Filter elements (hide/show content):
func textContentManager(
_ textContentManager: NSTextContentManager,
shouldEnumerate textElement: NSTextElement,
options: NSTextContentManager.EnumerationOptions
) -> Bool {
// Return false to hide element
if hideComments && isComment(textElement) {
return false
}
return true
}NSTextLayoutManagerDelegate
Provide custom layout fragments:
func textLayoutManager(
_ textLayoutManager: NSTextLayoutManager,
textLayoutFragmentFor location: NSTextLocation,
in textElement: NSTextElement
) -> NSTextLayoutFragment {
// Return custom fragment for special styling
if isComment(textElement) {
return BubbleLayoutFragment(
textElement: textElement,
range: textElement.elementRange
)
}
return NSTextLayoutFragment(
textElement: textElement,
range: textElement.elementRange
)
}NSTextViewportLayoutController.Delegate
Viewport layout lifecycle:
func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) {
// Prepare for layout: clear sublayers, begin animation
}
func textViewportLayoutController(
_ controller: NSTextViewportLayoutController,
configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment
) {
// Update geometry for each visible fragment
let layer = getOrCreateLayer(for: textLayoutFragment)
layer.frame = textLayoutFragment.layoutFragmentFrame
// Animate to new position if needed
}
func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) {
// Finish: commit animations, update scroll indicators
}Practical Patterns
Custom Layout Fragment (Bubble Backgrounds)
class BubbleLayoutFragment: NSTextLayoutFragment {
override func draw(at point: CGPoint, in context: CGContext) {
// Draw custom background
context.setFillColor(UIColor.systemIndigo.cgColor)
let bubblePath = UIBezierPath(
roundedRect: layoutFragmentFrame,
cornerRadius: 8
)
context.addPath(bubblePath.cgPath)
context.fillPath()
// Draw text on top
super.draw(at: point, in: context)
}
}Rendering Attributes (Temporary Styling)
Add attributes that don't modify text storage:
textLayoutManager.addRenderingAttribute(
.foregroundColor,
value: UIColor.green,
for: ingredientRange
)
// Remove when no longer needed
textLayoutManager.removeRenderingAttribute(
.foregroundColor,
for: ingredientRange
)Text Attachment with UIView
// iOS 15+
let attachment = NSTextAttachment()
attachment.image = UIImage(systemName: "star.fill")
// Provide view for interaction
class AttachmentViewProvider: NSTextAttachmentViewProvider {
override func loadView() {
super.loadView()
let button = UIButton(type: .system)
button.setTitle("Tap me", for: .normal)
button.addTarget(self, action: #selector(didTap), for: .touchUpInside)
view = button
}
@objc func didTap() {
// Handle tap
}
}Lists and Tables
// Create list
let listItem = NSTextList(markerFormat: .disc, options: 0)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.textLists = [listItem]
attributedString.addAttribute(
.paragraphStyle,
value: paragraphStyle,
range: range
)NSTextList available in UIKit (iOS 16+), previously AppKit-only.
Hit Testing & Selection Geometry
// Get text range at point
let location = textLayoutManager.location(
interactingAt: point,
inContainerAt: textContainer.location
)
// Get bounding rect for range
var boundingRect = CGRect.zero
textLayoutManager.enumerateTextSegments(
in: textRange,
type: .standard,
options: []
) { segmentRange, segmentRect, baselinePosition, textContainer in
boundingRect = boundingRect.union(segmentRect)
return true
}Writing Tools (iOS 18+)
Basic Integration (TextKit 2 Required)
From WWDC 2024:
"UITextView or NSTextView has to use TextKit 2 to support the full Writing Tools experience. If using TextKit 1, you will get a limited experience that just shows rewritten results in a panel."
Free for native text views:
// UITextView, NSTextView, WKWebView
// Writing Tools appears automaticallyLifecycle Delegate Methods
func textViewWritingToolsWillBegin(_ textView: UITextView) {
// Pause syncing, prevent edits
isSyncing = false
}
func textViewWritingToolsDidEnd(_ textView: UITextView) {
// Resume syncing
isSyncing = true
}
// Check if active
if textView.isWritingToolsActive {
// Don't persist text storage
}Controlling Behavior
// Opt out completely
textView.writingToolsBehavior = .none
// Panel-only experience (no in-line edits)
textView.writingToolsBehavior = .limited
// Full experience (default)
textView.writingToolsBehavior = .defaultResult Options
// Plain text only
textView.writingToolsResultOptions = [.plainText]
// Rich text
textView.writingToolsResultOptions = [.richText]
// Rich text + tables
textView.writingToolsResultOptions = [.richText, .table]
// Rich text + lists
textView.writingToolsResultOptions = [.richText, .list]Protected Ranges
// UITextViewDelegate / NSTextViewDelegate
func textView(
_ textView: UITextView,
writingToolsIgnoredRangesIn enclosingRange: NSRange
) -> [NSRange] {
// Return ranges that Writing Tools should not modify
return codeBlockRanges + quoteRanges
}WKWebView: <blockquote> and <pre> tags automatically ignored.
Writing Tools Coordinator (iOS 18.2+)
Advanced integration for custom text engines. UIWritingToolsCoordinator and NSWritingToolsCoordinator — along with their Context, ContextScope, State, and delegate types — are available on iOS 18.2+ / iPadOS 18.2+ / visionOS 2.4+ / macOS 15.2+. A few sub-features are gated at iOS 26.0+ and called out explicitly below (the includesTextListMarkers property and the .presentationIntent result option).
Setup
// UIKit
let coordinator = UIWritingToolsCoordinator()
coordinator.delegate = self
textView.addInteraction(coordinator)
coordinator.writingToolsBehavior = .default
coordinator.writingToolsResultOptions = [.richText]
// AppKit
let coordinator = NSWritingToolsCoordinator()
coordinator.delegate = self
customView.writingToolsCoordinator = coordinatorCoordinator Delegate
Provide context:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
requestContexts scope: NSWritingToolsCoordinator.ContextScope
) async -> [NSWritingToolsCoordinator.Context] {
// Return attributed string + selection range
let context = NSWritingToolsCoordinator.Context(
attributedString: currentText,
range: currentSelection
)
return [context]
}Apply changes:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
replace context: NSWritingToolsCoordinator.Context,
range: NSRange,
with attributedString: NSAttributedString
) async {
// Update text storage
textStorage.replaceCharacters(in: range, with: attributedString)
}Update selection:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
updateSelectedRange selectedRange: NSRange,
in context: NSWritingToolsCoordinator.Context
) async {
// Update selection
self.selectedRange = selectedRange
}Provide previews for animation:
// macOS
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
previewsFor context: NSWritingToolsCoordinator.Context,
range: NSRange
) async -> [NSTextPreview] {
// Return one preview per line for smooth animation
return textLines.map { line in
NSTextPreview(
image: renderImage(for: line),
frame: line.frame
)
}
}
// iOS
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
previewFor context: UIWritingToolsCoordinator.Context,
range: NSRange
) async -> UITargetedPreview {
// Return single preview
return UITargetedPreview(
view: previewView,
parameters: parameters
)
}Proofreading marks:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
underlinesFor context: NSWritingToolsCoordinator.Context,
range: NSRange
) async -> [NSValue] {
// Return bezier paths for underlines
return ranges.map { range in
let path = bezierPath(for: range)
return NSValue(bytes: &path, objCType: "CGPath")
}
}PresentationIntent (iOS 26+)
Semantic rich text result option:
coordinator.writingToolsResultOptions = [.richText, .presentationIntent]Difference from display attributes:
Display attributes (bold, italic):
- Concrete font info (point sizes, font names)
- No semantic meaning
PresentationIntent (header, code block, emphasis):
- Semantic style info
- App converts to internal styles
- Lists, tables, code blocks use presentation intent
- Underline, subscript, superscript still use display attributes
Example:
// Check for presentation intent
if attributedString.runs[\.presentationIntent].contains(where: { $0?.components.contains(.header(level: 1)) == true }) {
// This is a heading
}SwiftUI TextEditor + AttributedString (iOS 26+)
Basic Usage
struct RecipeEditor: View {
@State private var text: AttributedString = "Recipe text"
var body: some View {
TextEditor(text: $text)
}
}Supported attributes:
- Bold, italic, underline, strikethrough
- Custom fonts, point size
- Foreground and background colors
- Kerning, tracking, baseline offset
- Genmoji
- Line height, text alignment, base writing direction
TextAlignment and WritingDirection
// Text alignment (AttributedString.TextAlignment)
var text = AttributedString("Centered paragraph")
text.alignment = .center // .left, .right, .center
// Writing direction for bidirectional text
var bidiText = AttributedString("Hello عربي")
bidiText.writingDirection = .rightToLeft // .leftToRight, .rightToLeftLineHeight Control
var multiline = AttributedString("Paragraph\nwith multiple\nlines.")
multiline.lineHeight = .exact(points: 32) // Fixed height
multiline.lineHeight = .multiple(factor: 2.5) // Multiplier
multiline.lineHeight = .loose // System loose spacingSelection Binding
@State private var selection: AttributedTextSelection?
TextEditor(text: $text, selection: $selection)AttributedTextSelection: A struct (iOS 26.0+), not an enum. Selection is expressed with AttributedString.Index and RangeSet, never NSRange. Its nested Indices enum, obtained via the indices(in:) method, distinguishes a caret from one or more ranges:
public struct AttributedTextSelection: Equatable, Sendable { // iOS 26.0+
@frozen public enum Indices: Equatable, Sendable {
case insertionPoint(AttributedString.Index)
case ranges(RangeSet<AttributedString.Index>)
}
}
// Inits:
AttributedTextSelection() // empty
AttributedTextSelection(insertionPoint: index) // caret
AttributedTextSelection(ranges: rangeSet) // discontiguous
AttributedTextSelection(range: text.range(of: "dog")!) // single rangeGet selected text:
if let selection {
// indices(in:) is a method, not a property
switch selection.indices(in: text) {
case .insertionPoint:
// Caret only — nothing selected
break
case .ranges:
// text[selection] yields a DiscontiguousAttributedSubstring,
// covering both single-range and multi-range (bidirectional) selections
let selectedText: DiscontiguousAttributedSubstring = text[selection]
_ = selectedText
}
}Programmatic Selection Replacement
var text = AttributedString("Here is my dog")
var selection = AttributedTextSelection(range: text.range(of: "dog")!)
// Replace with plain text
text.replaceSelection(&selection, withCharacters: "cat")
// Replace with attributed content
let replacement = AttributedString("horse")
text.replaceSelection(&selection, with: replacement)DiscontiguousAttributedSubstring
Work with non-contiguous selections using RangeSet:
let text = AttributedString("Select multiple parts of this text")
let range1 = text.range(of: "Select")!
let range2 = text.range(of: "text")!
let rangeSet = RangeSet([range1, range2])
var substring = text[rangeSet] // DiscontiguousAttributedSubstring
substring.backgroundColor = .yellow
// Convert back to AttributedString
let combined = AttributedString(substring)Text Selection Affinity
Control selection affinity for the view hierarchy:
TextEditor(text: $text, selection: $selection)
.textSelectionAffinity(.upstream) // .upstream or .downstreamUse .upstream when selection should resolve toward the beginning of text at line boundaries.
Custom Formatting Definition
Constrain which attributes are editable:
struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
typealias FormatScope = RecipeAttributeScope
static let constraints: [any AttributedTextValueConstraint<RecipeFormattingDefinition>] = [
IngredientsAreGreen()
]
}
struct RecipeAttributeScope: AttributedScope {
var ingredient: IngredientAttribute
var foregroundColor: ForegroundColorAttribute
var genmoji: GenmojiAttribute
}Apply to TextEditor:
TextEditor(text: $text)
.attributedTextFormattingDefinition(RecipeFormattingDefinition.self)Value Constraints
Control attribute values based on custom logic:
struct IngredientsAreGreen: AttributedTextValueConstraint {
typealias Definition = RecipeFormattingDefinition
typealias AttributeKey = ForegroundColorAttribute
func constrain(
_ value: inout Color?,
in scope: RecipeFormattingDefinition.FormatScope
) {
if scope.ingredient != nil {
value = .green // Ingredients are always green
} else {
value = nil // Others use default
}
}
}System behavior:
- TextEditor probes constraints to determine if changes are valid
- If constraint would revert change, control is disabled
- Constraints applied to pasted content
Custom Attributes
Define attribute:
struct IngredientAttribute: CodableAttributedStringKey {
typealias Value = UUID // Ingredient ID
static let name = "ingredient"
}
extension AttributeScopes.RecipeAttributeScope {
var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
}Attribute behavior:
extension IngredientAttribute {
// Don't expand when typing after ingredient
static let inheritedByAddedText = false
// Remove if text in run changes
static let invalidationConditions: [AttributedString.InvalidationCondition] = [
.textChanged
]
// Optional: constrain to paragraph boundaries
static let runBoundaries: AttributedString.RunBoundaries = .paragraph
}AttributedString Mutations
Safe index updates:
// Transform updates indices/selection during mutation
text.transform(updating: &selection) { mutableText in
// Find ranges
let ranges = mutableText.characters.ranges(of: "butter")
// Set attribute for all ranges at once
for range in ranges {
mutableText[range].ingredient = ingredientID
}
}
// selection is now updated to match transformed textDon't use old indices:
// BAD - indices invalidated by mutation
let range = text.characters.range(of: "butter")!
text[range].foregroundColor = .green
text.append(" (unsalted)") // range is now invalid!AttributedString Views
Multiple views into same content:
characters— grapheme clustersunicodeScalars— Unicode scalarsutf8— UTF-8 code unitsutf16— UTF-16 code units
All views share same indices.
Viewport Rendering Surfaces & Attachment Reuse OS27
Before 27 you faced a hard choice: use a framework text view (UITextView/NSTextView/TextEditor — lots free, little rendering control) or build a custom view on a raw NSTextLayoutManager + viewport layout (total control, but you re-implement input, selection, accessibility, undo, dictation). At 27 you can customize rendering and viewport from inside a framework text view by subclassing it.
Subclassable viewport delegate on framework text views
A UITextView subclass can now override the three NSTextViewportLayoutControllerDelegate hooks directly and drive relayout:
final class CodeTextView: UITextView {
override func textViewportLayoutControllerWillLayout(_ c: NSTextViewportLayoutController) { … }
override func textViewportLayoutController(_ c: NSTextViewportLayoutController,
configureRenderingSurfaceFor fragment: NSTextLayoutFragment) { … } // attach a surface per fragment
override func textViewportLayoutControllerDidLayout(_ c: NSTextViewportLayoutController) { … }
func relayout() {
textLayoutManager?.textViewportLayoutController.delegate?
.textViewportLayoutControllerReceivedSetNeedsLayout?(/* controller */)
}
}Rendering surfaces
NSTextViewportRenderingSurface (new at 27) lets your UIView/layer render text fragments — exposing rendering customization previously locked inside framework text views. Cache surfaces per fragment keyed by NSTextLayoutFragment (which already conforms to the older NSTextViewportRenderingSurfaceKey protocol, iOS 18) using an NSMapTable<NSTextLayoutFragment, MyView>.
Attachment view-provider reuse
Inline interactive/animated attachments (Messages-style inline photos, stickers, animations) can recycle their views instead of rebuilding:
textView.registerTextAttachmentViewProviderReusePolicy(
[.onEditingInlineParagraphs],
forTextAttachmentViewProviderType: AnimatedAttachmentViewProvider.self)
// Swift: textView.register([.onEditingInlineParagraphs], forTextAttachmentViewProviderType: …)(registerTextAttachmentViewProviderReusePolicy(_:forTextAttachmentViewProviderType:) is iOS27/macOS27/tvOS27/visionOS27 — NSTextView gets it too — not watchOS.)
Skip-layout / collapsible content
Exclude collapsed paragraphs (e.g. collapsible recipe sections) from layout via NSTextContentStorageDelegate:
func textContentManager(_ m: NSTextContentManager, shouldEnumerate element: NSTextElement,
options: NSTextContentManager.EnumerationOptions) -> Bool { isVisible(element) }Known Limitations & Gotchas
Viewport Scroll Issues
From expert articles:
- Viewport can cause scroll position instability
usageBoundsForTextContainerchanges during scroll- Apple's TextEdit exhibits same issues
- Trade-off for performance benefits
TextKit 1 Compatibility
- Accessing
.layoutManagertriggers fallback - One-way operation (no automatic return)
- Loses UI state during switch
- Expensive to switch layout systems
AttributedString Index Invalidation
- Any mutation invalidates all indices
- Must use
.transform(updating:)to keep indices valid - Indices only work with originating AttributedString
Limited TextKit 1 Support
Unsupported in TextKit 2:
- NSTextTable (use NSTextList or custom layouts)
- Some legacy text attachments
- Direct glyph manipulation
Resources
WWDC: 2021-10061, 2022-10090, 2023-10058, 2024-10168, 2025-265, 2025-280, 2026-370
Docs: /uikit/nstextlayoutmanager, /appkit/textkit/using_textkit_2_to_interact_with_text, /uikit/display-text-with-a-custom-layout, /swiftui/building-rich-swiftui-text-experiences, /foundation/attributedstring, /foundation/attributedstring/textalignment, /foundation/attributedstring/lineheight, /foundation/discontiguousattributedsubstring, /uikit/writing-tools, /appkit/enhancing-your-custom-text-engine-with-writing-tools
UIKit App Modernization — Scene Lifecycle & Resizability
The 27 cycle makes the scene-based life cycle mandatory and assumes every app is resizable. This is the highest-impact UIKit change in years: it is a launch-time breaking change, not an opt-in.
The breaking change — UIScene is required at 27
When you build against the 27 SDKs, an app with only a `UIApplicationDelegate` (no `UISceneDelegate`) will no longer launch. You must adopt the scene-based life cycle.
- Migration path: WWDC 2025 "Make your UIKit app more flexible" + Apple's doc "Transitioning to the UIKit scene-based life cycle."
- The SDK reflects this:
UIApplicationDelegate.application(_:supportedInterfaceOrientationsForWindow:)andUIApplication.supportedInterfaceOrientationsForWindow(_:)are deprecated at iOS 27 in favor ofUIWindowSceneDelegate.supportedInterfaceOrientations(for:).
This is a behavior/requirement change, so it carries no additive OS27 marker — but it gates launch. Treat it as a must-fix before building against the 27 SDK.
Every app is now resizable
iPhone apps resize freely (iPhone Mirroring on Mac; an iPhone-only app on iPad). Your UI must adapt to any scene size at runtime.
Stop reading the screen and the idiom
| Don't (wrong in resizable / external-display contexts) | Do |
|---|---|
UIScreen.main | window.windowScene?.screen |
screen.scale | traitCollection.displayScale |
screen.bounds | the view's own bounds, or windowScene.effectiveGeometry.coordinateSpace.bounds |
UIDevice.userInterfaceIdiom for layout | size classes (traitCollection.horizontalSizeClass) |
supportedInterfaceOrientations for layout | size classes — orientation is only a preference at 27 and is ignored in resizable environments |
effectiveGeometry (iOS 16) and the windowScene(_:didUpdateEffectiveGeometry:) delegate (iOS 26) are the adaptive-geometry APIs to adopt — they predate 27, but 27 is where ignoring them breaks. UIRequiresFullScreen is now honored on iPhone but only enables discrete resizing that snaps to orientation-honoring configurations (for games); it no longer fully opts out of resizing.
override func layoutSubviews() {
super.layoutSubviews()
let displayScale = traitCollection.displayScale // not UIScreen.main.scale
// size from self.bounds, not the screen
}
func windowScene(_ windowScene: UIWindowScene,
didUpdateEffectiveGeometry previous: UIWindowScene.Geometry) {
let bounds = windowScene.effectiveGeometry.coordinateSpace.bounds
}Express preferences, not a fixed canvas
You no longer own a fixed canvas — you express preferences the user and system honor.
- Minimum size — the documented replacement for the old
UIRequiresFullScreenopt-out (TN3192). Set it on the scene'sUISceneSizeRestrictionsso users can't shrink the window below a usable size:
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 400, height: 600)- Orientation lock — a preference, not a guarantee, in resizable environments. Override
UIViewController.prefersInterfaceOrientationLocked(returnsBool) and callsetNeedsUpdateOfPrefersInterfaceOrientationLocked()when it changes; read the resolved state fromwindowScene.effectiveGeometry.isInterfaceOrientationLocked(iOS 26). - Interactive vs settled resize —
UIWindowSceneGeometry.isInteractivelyResizing(iOS 26) istruewhile the user drags; throttle expensive work during the drag and settle when it clears. SwiftUI's equivalent is.onInteractiveResizeChange(_:)(see axiom-swiftui (skills/layout-ref.md)).
New 27 additive APIs
| API | Scope | Use |
|---|---|---|
UITabBarController.prominentTabIdentifier | iOS27/visionOS27 | mark one tab always-visible/prominent |
UITabBarControllerSidebar.preferredPlacement (.sidebar) + Placement | iOS27/visionOS27 | iPhone can now opt a tab bar into a sidebar (the sidebar object itself is iOS 18) |
UINavigationItem.barMinimizationSafeAreaAdjustment | iOS27/tvOS27/visionOS27 | tune safe-area behavior when the bar minimizes |
UIMenuElement.preferredImageVisibility | iOS27 | Liquid Glass may hide menu images by default; opt an item back in |
CMMotionManager.deviceMotionBody | iOS27/watchOS27/visionOS27 | assign a UIView as the motion reference frame (Body protocols) |
CLLocationManager.headingBody | iOS27/macOS27/watchOS27 | replaces the deprecated headingOrientation |
UIView conforms to the CoreMotion/CoreLocation Body protocols, so you set motionManager.deviceMotionBody = view / locationManager.headingBody = view directly.
Apple Intelligence touchpoints
Menus gain an automatic "Ask Siri" affordance, and UIKit adds a View Annotations API to annotate views with AppEntitys for Siri context (see WWDC 2026-278). If you support drag and drop, Siri may load resources via your drag handlers — avoid animations/modal UI in sessionWillBegin (a drag can start without a gesture); put stateful drag UI in sessionDidMove.
Let Xcode do the mechanical migration
Xcode 27 ships an app-modernization agent skill that rewrites UIScreen.main calls → traitCollection/scene bounds, orientation checks → size classes, and can migrate to the scene life cycle. Export the skill for other tools with xcrun agent skills export. See axiom-xcode-mcp for the agentic-Xcode workflow.
Resources
WWDC: 2025-243, 2026-278
Docs: /uikit/app-and-environment, /uikit/uiscenedelegate, /uikit/uiwindowscene, /uikit/uiscenesizerestrictions, /uikit/transitioning-to-the-uikit-scene-based-life-cycle, /uikit/uitabbarcontroller, /uikit/uitabbarcontrollersidebar, /uikit/uimenuelement, /technotes/tn3192-migrating-your-app-from-the-deprecated-uirequiresfullscreen-key
Skills: skills/uikit-bridging.md, axiom-xcode-mcp, axiom-swiftui (size-class-driven adaptive layout)
Related skills
How it compares
Use axiom-uikit for symptom-indexed UIKit bridging fixes; use generic SwiftUI docs when the screen never touches UIKit APIs.
FAQ
When must developers use axiom-uikit?
axiom-uikit must be used for any UIKit bridging, Auto Layout, Combine, TextKit, or UIKit animation work per the skill README. It routes symptoms to guides such as skills/uikit-bridging.md.
Does axiom-uikit cover SwiftUI inside UIKit hosting?
axiom-uikit covers embedding SwiftUI in UIKit via UIHostingController and related bridging topics. Reference tables link UIViewRepresentable, Coordinator lifecycle, and constraint errors to dedicated markdown guides.
Is Axiom Uikit safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.