
Axiom Swift
- 717 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-swift is a Swift review skill that modernizes Swift and SwiftUI code around ownership, Transferable, deep links, and tvOS APIs for developers who need Apple platform apps aligned with current language idioms and fe
About
axiom-swift is an Axiom agent skill for reviewing and modernizing Swift and SwiftUI on Apple platforms. It routes common tasks to reference files such as swift-modern.md for outdated patterns like Date(), CGFloat, and DateFormatter, plus Foundation modernization with FormatStyle and URL.documentsDirectory. The skill also covers noncopyable types, Transferable drag-and-drop, debug deep links, and tvOS-specific guidance. Developers reach for axiom-swift when an agent proposes Swift that may hallucinate APIs or use pre-modern idioms during feature work or PR review. The MIT-licensed skill is marked mandatory for Swift idiom review, ownership work, Transferable implementations, deep links, and tvOS builds in its README triggers.
- MUST-use gate for Swift idiom review, noncopyable types, Transferable, debug deep links, and tvOS
- Quick-reference routing to swift-modern, ownership-conventions, transferable-ref, deep-link-debugging topics
- Covers FormatStyle modernization, ~Copyable, borrowing/consuming, InlineArray, Span, and ARC reduction
- Documents .draggable, .dropDestination, PasteButton, ShareLink, UTType, and TransferRepresentation choices
- Flags common Claude Swift hallucinations (Date(), CGFloat, DateFormatter patterns)
Axiom Swift by the numbers
- 717 all-time installs (skills.sh)
- Ranked #258 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 717 |
|---|---|
| repo stars | ★ 1.1k |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you modernize Swift and SwiftUI code safely?
Review and modernize Swift and SwiftUI code—ownership, Transferable, deep links, and tvOS—so Apple platform apps match current APIs and fewer agent hallucinations ship.
Who is it for?
Apple platform developers reviewing Swift or SwiftUI PRs who want agents to follow current ownership, Transferable, and tvOS patterns.
Skip if: Teams working only in Kotlin, Dart, or JavaScript without Swift source files should skip axiom-swift.
When should I use this skill?
A developer requests Swift idiom review, noncopyable type fixes, Transferable drag-and-drop, debug deep links, or tvOS implementation help.
What you get
Reviewed Swift files with updated idioms, corrected API usage, and platform-specific guidance for iOS and tvOS.
- Modernized Swift patterns
- Platform-specific review notes
Files
Swift Language & Platform
You MUST use this skill for ANY Swift idiom review, ownership/noncopyable types, Transferable/drag-and-drop, debug deep links, or tvOS development.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| Outdated Swift patterns (Date(), CGFloat, DateFormatter) | See skills/swift-modern.md |
| Foundation modernization (FormatStyle, URL.documentsDirectory) | See skills/swift-modern.md |
| Common Claude hallucinations in Swift code | See skills/swift-modern.md |
Swift 6.4 idioms — anyAppleOS, weak let, ~Sendable (OS27) | See skills/swift-modern.md |
| Noncopyable types (~Copyable) | See skills/ownership-conventions.md |
| borrowing/consuming parameter ownership | See skills/ownership-conventions.md |
InlineArray, Span, value generics; Swift 6.4 borrow/mutate accessors (OS27) | See skills/ownership-conventions.md |
| Reducing ARC overhead | See skills/ownership-conventions.md |
| Drag and drop (.draggable, .dropDestination) | See skills/transferable-ref.md |
| Copy/paste (.copyable, PasteButton) | See skills/transferable-ref.md |
| ShareLink, content sharing | See skills/transferable-ref.md |
| Custom UTType declarations | See skills/transferable-ref.md |
| TransferRepresentation choices | See skills/transferable-ref.md |
| Debug-only deep links for simulator testing | See skills/deep-link-debugging.md |
| Navigate to specific screens for screenshots | See skills/deep-link-debugging.md |
| tvOS Focus Engine, Siri Remote input | See skills/tvos.md |
| tvOS storage constraints (no Documents dir) | See skills/tvos.md |
| tvOS text input, AVPlayer tuning | See skills/tvos.md |
| TVUIKit components | See skills/tvos.md |
| Simplify Swift for clarity (behavior-preserving cleanups) | swift-simplifier agent — /axiom:audit swift-simplify |
Decision Tree
digraph swift {
start [label="Swift task" shape=ellipse];
what [label="What do you need?" shape=diamond];
start -> what;
what -> "skills/swift-modern.md" [label="modern idioms,\noutdated patterns,\nFoundation APIs"];
what -> "skills/ownership-conventions.md" [label="~Copyable, borrowing,\nconsuming, InlineArray,\nSpan, ARC reduction"];
what -> "skills/transferable-ref.md" [label="drag & drop, copy/paste,\nShareLink, UTTypes,\nTransferable conformance"];
what -> "skills/deep-link-debugging.md" [label="debug deep links,\nsimulator navigation,\nscreenshot automation"];
what -> "skills/tvos.md" [label="tvOS app,\nFocus Engine,\nSiri Remote, storage"];
}1. Outdated Swift patterns / modern API replacements / Claude hallucinations? -> skills/swift-modern.md 2. ~Copyable / borrowing / consuming / InlineArray / Span? -> skills/ownership-conventions.md 3. Drag and drop / copy/paste / ShareLink / Transferable / UTTypes? -> skills/transferable-ref.md 4. Debug deep links / simulator navigation / screenshot automation? -> skills/deep-link-debugging.md 5. tvOS development / Focus Engine / Siri Remote / storage / AVPlayer? -> skills/tvos.md 6. Swift concurrency (async/await, actors, Sendable) -> /skill axiom-concurrency 7. Swift performance (COW, ARC, generics optimization) -> See axiom-performance (skills/swift-performance.md) 8. Codable patterns (JSON, CodingKeys, enum serialization) -> See axiom-data (skills/codable.md) 9. Simplify Swift for clarity (guard/optional cleanups, if/switch expressions, boilerplate)? -> swift-simplifier agent (/axiom:audit swift-simplify)
Conflict Resolution
swift vs concurrency: When Swift 6 concurrency errors appear:
- Use concurrency, NOT swift -- Concurrency errors are actor isolation / Sendable issues.
skills/swift-modern.mdcovers concurrency posture (defaults), but detailed patterns live in axiom-concurrency.
swift vs performance: When optimizing Swift code:
- Use swift for ownership if the question is borrowing/consuming/~Copyable/InlineArray/Span ->
skills/ownership-conventions.md - Use performance if the question is COW, ARC profiling, generic specialization, or Instruments workflows -> axiom-performance
swift vs swiftui: When implementing drag and drop or copy/paste:
- Use swift for Transferable conformance, representation choices, UTType declarations ->
skills/transferable-ref.md - Use swiftui for view-level modifiers (.draggable, .dropDestination styling, animations)
swift vs integration: When sharing content:
- ShareLink + Transferable -> use swift (
skills/transferable-ref.md) - UIActivityViewController customization, share extensions -> use integration
swift vs axiom-build: When tvOS build fails:
- Environment/Xcode issues -> use axiom-build first
- tvOS platform-specific code issues (Focus Engine, storage, no WebView) -> use swift (
skills/tvos.md)
Critical Patterns
Modern Swift Idioms (skills/swift-modern.md):
- 12+ outdated patterns Claude defaults to (Date(), CGFloat, DateFormatter, DispatchQueue.main.async)
- Foundation modernization (FormatStyle, URL.documentsDirectory, .replacing())
- SwiftUI convenience APIs Claude misses (ContentUnavailableView.search, LabeledContent)
- Swift 6.4 concurrency posture defaults
- 12 common Claude hallucinations with corrections
Ownership & Noncopyable Types (skills/ownership-conventions.md):
- borrowing/consuming parameter modifiers with 7 patterns
- ~Copyable types: FileHandle pattern, limitations table, common compiler errors
- InlineArray: fixed-size stack-allocated arrays with value generics
- Span family: safe contiguous memory access replacing UnsafeBufferPointer
- Decision tree for when ownership modifiers help vs when to skip
Transferable & Sharing (skills/transferable-ref.md):
- Decision tree: CodableRepresentation vs DataRepresentation vs FileRepresentation vs ProxyRepresentation
- Drag and drop, copy/paste, ShareLink with complete SwiftUI API
- Custom UTType declarations (Swift + Info.plist, both required)
- 7 common errors with fixes (representation ordering, missing Info.plist, hit testing)
- UIKit bridging via NSItemProvider
Debug Deep Links (skills/deep-link-debugging.md):
- Debug-only URL scheme for simulator navigation
- NavigationPath integration for robust routing
- State configuration links (error states, empty states)
- Integration with /axiom:screenshot and simulator-tester agent
- 60-75% faster iteration with visual verification
tvOS Development (skills/tvos.md):
- Dual focus system (UIKit Focus Engine + SwiftUI @FocusState)
- Siri Remote input (two generations, three input layers)
- Storage constraints (no Documents directory, iCloud required)
- No WebView (JavaScriptCore only, no DOM)
- AVPlayer tuning, Menu button state machine
- TVUIKit components
Anti-Rationalization
| Thought | Reality |
|---|---|
| "Date() is fine, everyone uses it" | Date.now has been the modern pattern since Swift 5.6. skills/swift-modern.md lists 12+ patterns Claude gets wrong. |
| "I don't need ownership modifiers" | For most code, correct. But ~Copyable types require them, and large value types in hot paths benefit measurably. |
| "Transferable is just Codable for drag and drop" | Transferable has 4 representation types, ordering rules, and Info.plist requirements. Getting it wrong causes silent cross-app failures. |
| "I'll just use the same code as iOS for tvOS" | tvOS has no Documents directory, no WebView, a dual focus system, and two generations of remote hardware. It compiles fine and fails at runtime. |
| "Debug deep links are overkill" | Manual navigation costs 2-3 minutes per iteration. Deep links cut it to 45 seconds. Over a debugging session, that's hours saved. |
| "CGFloat is what SwiftUI uses" | Swift 5.5+ has implicit Double-CGFloat bridging. Use Double everywhere except optionals, inout, and ObjC-bridged APIs. |
| "I'll add the Info.plist entry later" | Custom UTTypes work in-app without Info.plist but silently fail cross-app. This is the #1 "works in dev, fails in prod" Transferable issue. |
| "FormatStyle is too verbose" | val.formatted(.number.precision(.fractionLength(2))) is type-safe and localized. String(format:) is neither. |
Example Invocations
User: "Is this Swift code using modern patterns?" -> Read: skills/swift-modern.md
User: "How do I use borrowing and consuming?" -> Read: skills/ownership-conventions.md
User: "How do I make my model draggable?" -> Read: skills/transferable-ref.md
User: "How do I implement ShareLink with a custom preview?" -> Read: skills/transferable-ref.md
User: "I need debug deep links for simulator testing" -> Read: skills/deep-link-debugging.md
User: "I'm building a tvOS app and focus navigation doesn't work" -> Read: skills/tvos.md
User: "What is InlineArray and when should I use it?" -> Read: skills/ownership-conventions.md
User: "My drag and drop works in-app but not across apps" -> Read: skills/transferable-ref.md
User: "tvOS keeps losing my saved data" -> Read: skills/tvos.md
User: "How do I optimize large struct passing?" -> Read: skills/ownership-conventions.md
User: "I need to fix my async/await code" -> See /skill axiom-concurrency
User: "Check my code for Swift 6 concurrency issues" -> See /skill axiom-concurrency
Deep Link Debugging
When to Use This Skill
Use when:
- Adding debug-only deep links for simulator testing
- Enabling automated navigation to specific screens for screenshot/testing
- Integrating with
simulator-testeragent or/axiom:screenshot - Need to navigate programmatically without production deep link implementation
- Testing navigation flows without manual tapping
Do NOT use for:
- Production deep linking (use
axiom-swiftuinavigation reference instead) - Universal links or App Clips
- Complex routing architectures
Example Prompts
1. "Claude Code can't navigate to specific screens for testing"
→ Add debug-only URL scheme to enable xcrun simctl openurl navigation
2. "I want to take screenshots of different screens automatically"
→ Create debug deep links for each screen, callable from simulator
3. "Automated testing needs to set up specific app states"
→ Add debug links that navigate AND configure state
---
Red Flags — When You Need Debug Deep Links
If you're experiencing ANY of these, add debug deep links:
Testing friction:
- ❌ "I have to manually tap through 5 screens to test this feature"
- ❌ "Screenshot capture can't show the screen I need to debug"
- ❌ "Automated tests can't reach the error state without complex setup"
Debugging inefficiency:
- ❌ "I make a fix, rebuild, manually navigate, check — takes 3 minutes per iteration"
- ❌ "Can't visually verify fixes because Claude Code can't navigate there"
Solution: Add debug deep links that let you (and Claude Code) jump directly to any screen with any state configuration.
---
Implementation
Pattern 1: Basic Debug URL Scheme (SwiftUI)
Add a debug-only URL scheme that routes to screens.
import SwiftUI
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
#if DEBUG
.onOpenURL { url in
handleDebugURL(url)
}
#endif
}
}
#if DEBUG
private func handleDebugURL(_ url: URL) {
guard url.scheme == "debug" else { return }
// Route based on host
switch url.host {
case "settings":
// Navigate to settings
NotificationCenter.default.post(
name: .navigateToSettings,
object: nil
)
case "profile":
// Navigate to profile
let userID = url.queryItems?["id"] ?? "current"
NotificationCenter.default.post(
name: .navigateToProfile,
object: userID
)
case "reset":
// Reset app to initial state
resetApp()
default:
print("⚠️ Unknown debug URL: \(url)")
}
}
#endif
}
#if DEBUG
extension Notification.Name {
static let navigateToSettings = Notification.Name("navigateToSettings")
static let navigateToProfile = Notification.Name("navigateToProfile")
}
extension URL {
var queryItems: [String: String]? {
guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false),
let items = components.queryItems else {
return nil
}
return Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value ?? "") })
}
}
#endifUsage:
# From simulator
xcrun simctl openurl booted "debug://settings"
xcrun simctl openurl booted "debug://profile?id=123"
xcrun simctl openurl booted "debug://reset"---
Pattern 2: NavigationPath Integration (iOS 16+)
Integrate debug deep links with NavigationStack for robust navigation.
import SwiftUI
@MainActor
class DebugRouter: ObservableObject {
@Published var path = NavigationPath()
#if DEBUG
func handleDebugURL(_ url: URL) {
guard url.scheme == "debug" else { return }
switch url.host {
case "settings":
path.append(Destination.settings)
case "recipe":
if let id = url.queryItems?["id"], let recipeID = Int(id) {
path.append(Destination.recipe(id: recipeID))
}
case "recipe-edit":
if let id = url.queryItems?["id"], let recipeID = Int(id) {
// Navigate to recipe, then to edit
path.append(Destination.recipe(id: recipeID))
path.append(Destination.recipeEdit(id: recipeID))
}
case "reset":
path = NavigationPath() // Pop to root
default:
print("⚠️ Unknown debug URL: \(url)")
}
}
#endif
}
struct ContentView: View {
@StateObject private var router = DebugRouter()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Destination.self) { destination in
destinationView(for: destination)
}
}
#if DEBUG
.onOpenURL { url in
router.handleDebugURL(url)
}
#endif
}
@ViewBuilder
private func destinationView(for destination: Destination) -> some View {
switch destination {
case .settings:
SettingsView()
case .recipe(let id):
RecipeDetailView(recipeID: id)
case .recipeEdit(let id):
RecipeEditView(recipeID: id)
}
}
}
enum Destination: Hashable {
case settings
case recipe(id: Int)
case recipeEdit(id: Int)
}Usage:
# Navigate to settings
xcrun simctl openurl booted "debug://settings"
# Navigate to recipe #42
xcrun simctl openurl booted "debug://recipe?id=42"
# Navigate to recipe #42 edit screen
xcrun simctl openurl booted "debug://recipe-edit?id=42"
# Pop to root
xcrun simctl openurl booted "debug://reset"---
Pattern 3: State Configuration Links
Debug links that both navigate AND configure state.
#if DEBUG
extension DebugRouter {
func handleDebugURL(_ url: URL) {
guard url.scheme == "debug" else { return }
switch url.host {
case "login":
// Show login screen
path.append(Destination.login)
case "login-error":
// Show login screen WITH error state
path.append(Destination.login)
// Trigger error state
NotificationCenter.default.post(
name: .showLoginError,
object: "Invalid credentials"
)
case "recipe-empty":
// Show recipe list in empty state
UserDefaults.standard.set(true, forKey: "debug_emptyRecipeList")
path.append(Destination.recipes)
case "recipe-error":
// Show recipe list with network error
UserDefaults.standard.set(true, forKey: "debug_networkError")
path.append(Destination.recipes)
default:
print("⚠️ Unknown debug URL: \(url)")
}
}
}
#endifUsage:
# Test login error state
xcrun simctl openurl booted "debug://login-error"
# Test empty recipe list
xcrun simctl openurl booted "debug://recipe-empty"
# Test network error handling
xcrun simctl openurl booted "debug://recipe-error"---
Pattern 4: Info.plist Configuration (DEBUG only)
Register the debug URL scheme ONLY in debug builds.
Step 1: Add scheme to Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>debug</string>
</array>
<key>CFBundleURLName</key>
<string>com.example.debug</string>
</dict>
</array>Step 2: Strip from release builds
Add a Run Script phase to your target's Build Phases (runs BEFORE "Copy Bundle Resources"):
# Strip debug URL scheme from Release builds
if [ "${CONFIGURATION}" = "Release" ]; then
echo "Removing debug URL scheme from Info.plist"
/usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes:0" "${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}" 2>/dev/null || true
fiAlternative: Use separate Info.plist files for Debug vs Release configurations in Build Settings.
---
Integration with Simulator Testing
With /axiom:screenshot Command
# 1. Navigate to screen
xcrun simctl openurl booted "debug://settings"
# 2. Wait for navigation
sleep 1
# 3. Capture screenshot
/axiom:screenshotWith simulator-tester Agent
Simply tell the agent:
- "Navigate to Settings and take a screenshot"
- "Open the recipe editor and verify the layout"
- "Go to the error state and show me what it looks like"
The agent will use your debug deep links to navigate.
---
Mandatory First Steps
ALWAYS complete these steps before adding debug deep links:
Step 1: Define Navigation Needs
List all screens you need to reach for testing:
- Settings screen
- Profile screen (with specific user ID)
- Recipe detail (with specific recipe ID)
- Error states (login error, network error, etc.)
- Empty states (no recipes, no favorites)Step 2: Choose URL Scheme Pattern
debug://screen-name # Simple screen navigation
debug://screen-name?param=value # Navigation with parameters
debug://state-name # State configurationStep 3: Add URL Handler
Use #if DEBUG to ensure code is stripped from release builds.
Step 4: Test Deep Links
# Boot simulator
xcrun simctl boot "iPhone 16 Pro"
# Launch app
xcrun simctl launch booted com.example.YourApp
# Test each deep link
xcrun simctl openurl booted "debug://settings"
xcrun simctl openurl booted "debug://profile?id=123"---
Common Mistakes
❌ WRONG — Hardcoding navigation in URL handler
#if DEBUG
func handleDebugURL(_ url: URL) {
if url.host == "settings" {
// ❌ WRONG — Creates tight coupling
self.showingSettings = true
}
}
#endifProblem: URL handler now owns navigation logic, duplicating coordinator/router patterns.
✅ RIGHT — Use existing navigation system:
#if DEBUG
func handleDebugURL(_ url: URL) {
if url.host == "settings" {
// Use existing NavigationPath
path.append(Destination.settings)
}
}
#endif---
❌ WRONG — Leaving debug code in production
// ❌ WRONG — No #if DEBUG
func handleDebugURL(_ url: URL) {
// This ships to users!
}Problem: Debug endpoints exposed in production. Security risk.
✅ RIGHT — Wrap in #if DEBUG:
#if DEBUG
func handleDebugURL(_ url: URL) {
// Stripped from release builds
}
#endif---
❌ WRONG — Using query parameters without validation
#if DEBUG
case "profile":
let userID = Int(url.queryItems?["id"] ?? "0")! // ❌ Force unwrap
path.append(Destination.profile(id: userID))
#endifProblem: Crashes if id is missing or invalid.
✅ RIGHT — Validate parameters:
#if DEBUG
case "profile":
guard let idString = url.queryItems?["id"],
let userID = Int(idString) else {
print("⚠️ Invalid profile ID")
return
}
path.append(Destination.profile(id: userID))
#endif---
Testing Checklist
Before using debug deep links in automated workflows:
- [ ] URL handler wrapped in
#if DEBUG - [ ] All deep links tested manually in simulator
- [ ] Parameters validated (don't force unwrap)
- [ ] Deep links integrate with existing navigation (don't duplicate logic)
- [ ] URL scheme stripped from Release builds (script or separate Info.plist)
- [ ] Documented in README or comments for other developers
- [ ] Works with
/axiom:screenshotcommand - [ ] Works with
simulator-testeragent
---
Real-World Example
Scenario: You're debugging a recipe app layout issue in the editor screen.
Before (manual testing): 1. Build app → 30 seconds 2. Launch simulator 3. Tap "Recipes" → wait for load 4. Scroll to recipe #42 5. Tap to open detail 6. Tap "Edit" 7. Check if layout is fixed 8. Make change, rebuild → repeat from step 1 Total: 2-3 minutes per iteration
After (with debug deep links): 1. Build app → 30 seconds 2. Run: xcrun simctl openurl booted "debug://recipe-edit?id=42" 3. Run: /axiom:screenshot 4. Claude analyzes screenshot and confirms layout fix 5. Make change if needed, rebuild → repeat from step 2 Total: 45 seconds per iteration
Time savings: 60-75% faster iteration with visual verification
---
Integration with Existing Navigation
For Apps Using NavigationStack
Add debug URL handler that appends to existing NavigationPath:
router.path.append(Destination.fromDebugURL(url))For Apps Using Coordinator Pattern
Trigger coordinator methods from debug URL handler:
coordinator.navigate(to: .fromDebugURL(url))For Apps Using Custom Routing
Integrate with your router's navigation API:
AppRouter.shared.push(Screen.fromDebugURL(url))Key principle: Debug deep links should USE existing navigation, not replace it.
---
Advanced Patterns
Pattern 5: Parameterized State Setup
#if DEBUG
case "test-scenario":
// Parse complex test scenario from URL
// Example: debug://test-scenario?user=premium&recipes=empty&network=slow
if let userType = url.queryItems?["user"] {
configureUser(type: userType) // "premium", "free", "trial"
}
if let recipesState = url.queryItems?["recipes"] {
configureRecipes(state: recipesState) // "empty", "full", "error"
}
if let networkState = url.queryItems?["network"] {
configureNetwork(state: networkState) // "fast", "slow", "offline"
}
// Now navigate
path.append(Destination.recipes)
#endifUsage:
# Test premium user with empty recipe list
xcrun simctl openurl booted "debug://test-scenario?user=premium&recipes=empty"
# Test slow network with error handling
xcrun simctl openurl booted "debug://test-scenario?network=slow&recipes=error"---
Pattern 6: Screenshot Automation Helper
Create a single URL that sets up AND captures state:
#if DEBUG
case "screenshot":
// Parse screen and configuration
guard let screen = url.queryItems?["screen"] else { return }
// Configure state
if let state = url.queryItems?["state"] {
applyState(state)
}
// Navigate
navigate(to: screen)
// Post notification for external capture
Task { @MainActor in
try? await Task.sleep(for: .seconds(1))
NotificationCenter.default.post(
name: .readyForScreenshot,
object: screen
)
}
#endifUsage:
# Navigate to login screen with error state, wait, then screenshot
xcrun simctl openurl booted "debug://screenshot?screen=login&state=error"
sleep 2
xcrun simctl io booted screenshot login-error.png---
Related Skills
axiom-swiftui(navigation reference) — Production deep linking and NavigationStack patternssimulator-tester— Automated simulator testing using debug deep linksaxiom-build (skills/xcode-debugging.md)— Environment-first debugging workflows
---
Summary
Debug deep links enable:
- Closed-loop debugging with visual verification
- 60-75% faster iteration on visual fixes
- Automated testing without manual navigation
- Screenshot automation for any app state
Remember: 1. Wrap ALL debug code in #if DEBUG 2. Strip URL scheme from release builds 3. Integrate with existing navigation, don't duplicate 4. Validate all parameters (no force unwraps) 5. Document for team members
borrowing & consuming — Parameter Ownership
Explicit ownership modifiers for performance optimization and noncopyable type support.
When to Use
✅ Use when:
- Large value types being passed read-only (avoid copies)
- Working with noncopyable types (
~Copyable) - Reducing ARC retain/release traffic
- Factory methods that consume builder objects
- Performance-critical code where copies show in profiling
❌ Don't use when:
- Simple types (Int, Bool, small structs)
- Compiler optimization is sufficient (most cases)
- Readability matters more than micro-optimization
- You're not certain about the performance impact
Quick Reference
| Modifier | Ownership | Copies | Use Case |
|---|---|---|---|
| (default) | Compiler chooses | Implicit | Most cases |
borrowing | Caller keeps | Explicit copy only | Read-only, large types |
consuming | Caller transfers | None needed | Final use, factories |
inout | Caller keeps, mutable | None | Modify in place |
Default Behavior by Context
| Context | Default | Reason |
|---|---|---|
| Function parameters | borrowing | Most params are read-only |
| Initializer parameters | consuming | Usually stored in properties |
| Property setters | consuming | Value is stored |
Method self | borrowing | Methods read self |
Patterns
Pattern 1: Read-Only Large Struct
struct LargeBuffer {
var data: [UInt8] // Could be megabytes
}
// ❌ Default may copy
func process(_ buffer: LargeBuffer) -> Int {
buffer.data.count
}
// ✅ Explicit borrow — no copy
func process(_ buffer: borrowing LargeBuffer) -> Int {
buffer.data.count
}Pattern 2: Consuming Factory
struct Builder {
var config: Configuration
// Consumes self — builder invalid after call
consuming func build() -> Product {
Product(config: config)
}
}
let builder = Builder(config: .default)
let product = builder.build()
// builder is now invalid — compiler error if usedPattern 3: Explicit Copy in Borrowing
With borrowing, copies must be explicit:
func store(_ value: borrowing LargeValue) {
// ❌ Error: Cannot implicitly copy borrowing parameter
self.cached = value
// ✅ Explicit copy
self.cached = copy value
}Pattern 4: Consume Operator
Transfer ownership explicitly:
let data = loadLargeData()
process(consume data)
// data is now invalid — compiler prevents usePattern 5: Noncopyable Type
For ~Copyable types, ownership modifiers are required:
struct FileHandle: ~Copyable {
private let fd: Int32
init(path: String) throws {
fd = open(path, O_RDONLY)
guard fd >= 0 else { throw POSIXError.errno }
}
borrowing func read(count: Int) -> Data {
// Read without consuming handle
var buffer = [UInt8](repeating: 0, count: count)
_ = Darwin.read(fd, &buffer, count)
return Data(buffer)
}
consuming func close() {
Darwin.close(fd)
// Handle consumed — can't use after close()
}
deinit {
Darwin.close(fd)
}
}
// Usage
let file = try FileHandle(path: "/tmp/data.txt")
let data = file.read(count: 1024) // borrowing
file.close() // consuming — file invalidatedPattern 6: Reducing ARC Traffic
class ExpensiveObject { /* ... */ }
// ❌ Default: May retain/release
func inspect(_ obj: ExpensiveObject) -> String {
obj.description
}
// ✅ Borrowing: No ARC traffic
func inspect(_ obj: borrowing ExpensiveObject) -> String {
obj.description
}Pattern 7: Consuming Method on Self
struct Transaction {
var amount: Decimal
var recipient: String
// After commit, transaction is consumed
consuming func commit() async throws {
try await sendToServer(self)
// self consumed — can't modify or reuse
}
}Common Mistakes
Mistake 1: Over-Optimizing Small Types
// ❌ Unnecessary — Int is trivially copyable
func add(_ a: borrowing Int, _ b: borrowing Int) -> Int {
a + b
}
// ✅ Let compiler optimize
func add(_ a: Int, _ b: Int) -> Int {
a + b
}Mistake 2: Forgetting Explicit Copy
func cache(_ value: borrowing LargeValue) {
// ❌ Compile error
self.values.append(value)
// ✅ Explicit copy required
self.values.append(copy value)
}Mistake 3: Consuming When Borrowing Suffices
// ❌ Consumes unnecessarily — caller loses access
func validate(_ data: consuming Data) -> Bool {
data.count > 0
}
// ✅ Borrow for read-only
func validate(_ data: borrowing Data) -> Bool {
data.count > 0
}~Copyable Limitations
Know the constraints before adopting ~Copyable:
| Limitation | Impact | Workaround |
|---|---|---|
Can't store in Array, Dictionary, Set | Collections require Copyable | Use Optional<T> wrapper or manage manually |
| Can't use with most generics | <T> implicitly means <T: Copyable> | Use <T: ~Copyable> (requires library support) |
| Protocol conformance restricted | Most protocols require Copyable | Use ~Copyable protocol definitions |
| Can't capture in closures by default | Closures copy captured values | Use borrowing closure parameters |
| No existential support | any ~Copyable doesn't work | Use generics instead |
Common compiler errors when adopting ownership modifiers:
// Error: "Cannot implicitly copy a borrowing parameter"
// Fix: Add explicit `copy` or change to consuming
func store(_ v: borrowing LargeValue) {
self.cached = copy v // ✅ Explicit copy
}
// Error: "Noncopyable type cannot be used with generic"
// Fix: Constrain generic to ~Copyable
func use<T: ~Copyable>(_ value: borrowing T) { } // ✅
// Error: "Cannot consume a borrowing parameter"
// Fix: Change to consuming if you need ownership transfer
func takeOwnership(_ v: consuming FileHandle) { } // ✅
// Error: "Missing 'consuming' or 'borrowing' modifier"
// Fix: ~Copyable types require explicit ownership on all methods
struct Token: ~Copyable {
borrowing func peek() -> String { ... } // ✅ Explicit
consuming func redeem() { ... } // ✅ Explicit
}When NOT to use ~Copyable:
- If you need collection storage (arrays, dictionaries)
- If you need to work with existing generic APIs
- If the type needs broad protocol conformance
- Prefer
consuming funcon regular types as a lighter alternative for "use once" semantics
Performance Considerations
When Ownership Modifiers Help
- Large structs (arrays, dictionaries, custom value types)
- High-frequency function calls in tight loops
- Reference types where ARC traffic is measurable
- Noncopyable types (required, not optional)
When to Skip
- Default behavior is almost always optimal
- Small value types (primitives, small structs)
- Code where profiling shows no benefit
- API stability concerns (modifiers affect ABI)
InlineArray
Fixed-size, stack-allocated array using value generics. No heap allocation, no reference counting, no copy-on-write.
Declaration
@frozen struct InlineArray<let count: Int, Element> where Element: ~CopyableThe let count: Int is a value generic — the size is part of the type, checked at compile time. InlineArray<3, Int> and InlineArray<4, Int> are different types.
On Swift 6.4 (Xcode 27) you can also write the type with the [count of Element] shorthand (OS27):
let rgb: [3 of Double] = [0.2, 0.4, 0.8] // == InlineArray<3, Double>When to Use InlineArray
| Use InlineArray | Use Array |
|---|---|
| Size known at compile time | Size changes at runtime |
| Hot path needing zero heap allocation | Copy-on-write sharing is beneficial |
| Embedded in other value types | Frequently copied between variables |
| Performance-critical inner loops | General-purpose collection needs |
Canonical Example
// Fixed-size, inline storage — no heap allocation
var matrix: InlineArray<9, Float> = [1, 0, 0, 0, 1, 0, 0, 0, 1]
matrix[4] = 2.0
// Type inference works for count, element, or both
let rgb: InlineArray = [0.2, 0.4, 0.8] // InlineArray<3, Double>
// Eager copy on assignment (no COW)
var copy = matrix
copy[0] = 99 // matrix[0] still 1Memory Layout
Elements are stored contiguously with no overhead:
MemoryLayout<InlineArray<3, UInt16>>.size // 6 (2 bytes × 3)
MemoryLayout<InlineArray<3, UInt16>>.alignment // 2 (same as UInt16)~Copyable Integration
InlineArray supports noncopyable elements — enables fixed-size collections of unique resources:
struct Sensor: ~Copyable { var id: Int }
var sensors: InlineArray<4, Sensor> = ... // Valid: ~Copyable elements allowedSpan — Safe Contiguous Memory Access
Span replaces unsafe pointers with compile-time-enforced safe memory views. Zero runtime overhead.
The Span Family
| Type | Access | Use Case |
|---|---|---|
Span<Element> | Read-only elements | Safe iteration, passing to algorithms |
MutableSpan<Element> | Read-write elements | In-place mutation without copies |
RawSpan | Read-only bytes | Binary parsing, protocol decoding |
MutableRawSpan | Read-write bytes | Binary serialization |
OutputSpan | Write-only | Initializing new collection storage |
UTF8Span | Read-only UTF-8 | Safe Unicode processing |
Accessing Spans
Containers with contiguous storage expose .span and .mutableSpan:
let array = [1, 2, 3, 4]
let span = array.span // Span<Int>
var mutable = [10, 20, 30]
var ms = mutable.mutableSpan // MutableSpan<Int>
ms[0] = 99Lifetime Safety — Compile-Time Enforcement
Spans are non-escapable — the compiler guarantees they cannot outlive the container they borrow from:
// ❌ Cannot return span that depends on local variable
func getSpan() -> Span<UInt8> {
let array: [UInt8] = Array(repeating: 0, count: 128)
return array.span // Compile error
}
// ❌ Cannot capture span in closure
let span = array.span
let closure = { span.count } // Compile error
// ❌ Cannot access span after mutating original
var array = [1, 2, 3]
let span = array.span
array.append(4)
// span[0] // Compile error: container was modifiedThese constraints prevent use-after-free, dangling pointers, and overlapping mutation at compile time with zero runtime cost.
Span vs Unsafe Pointers
| Span | UnsafeBufferPointer | |
|---|---|---|
| Memory safety | Compile-time enforced | Manual, error-prone |
| Lifetime tracking | Automatic, non-escapable | None — dangling pointers possible |
| Runtime overhead | Zero | Zero |
| Use-after-free | Impossible | Common source of crashes |
Canonical Example — Binary Parsing
func parseHeader(_ data: borrowing [UInt8]) -> Header {
var raw = data.span.bytes // RawSpan over the array's bytes (Span<Element: BitwiseCopyable>.bytes)
let magic = raw.unsafeLoadUnaligned(as: UInt32.self)
raw = raw.extracting(droppingFirst: 4)
let version = raw.unsafeLoadUnaligned(as: UInt16.self)
return Header(magic: magic, version: version)
}When to Use Span
- Replace `UnsafeBufferPointer` — same performance, compile-time safety
- Performance-critical algorithms — direct memory access without copying
- Binary parsing/serialization —
RawSpanfor byte-level access - Passing data between functions — borrow the container, pass the span
- UTF-8 processing —
UTF8Spanfor safe string byte access
Value Generics
Value generics allow integer values as generic parameters, making sizes part of the type system:
// `let count: Int` is a value generic parameter
struct InlineArray<let count: Int, Element> { ... }
// Different counts = different types
let a: InlineArray<3, Int> = [1, 2, 3]
let b: InlineArray<4, Int> = [1, 2, 3, 4]
// a = b // Compile error: different typesCurrently limited to Int parameters. Enables stack-allocated, fixed-size abstractions where the compiler verifies size compatibility at compile time.
Swift 6.4 Additions (OS27)
The 6.4 toolchain (Xcode 27) extends the ownership toolkit. These are verified against the Xcode 27.0 beta compiler:
borrow / mutate accessors
Replace get/set to expose shared storage without copying — and to vend ~Copyable values from a computed property:
var value: Value {
borrow { storage.pointee } // read-only, no copy
mutate { &storage.pointee } // exclusive in-place access
}Noncopyable & nonescapable conformances
Equatable, Comparable, and Hashable now work on ~Copyable types (Equatable/Comparable also on ~Escapable), and associated types may be ~Copyable / ~Escapable. You no longer have to make a unique-resource type copyable just to compare or hash it:
struct FileHandle: ~Copyable, Equatable {
let fd: Int32
static func == (a: borrowing FileHandle, b: borrowing FileHandle) -> Bool { a.fd == b.fd }
}Not yet in the beta SDK
WWDC 2026-262 also announced new stdlib containers for 6.4 — UniqueArray (~Copyable array), UniqueBox, Ref/MutableRef (a single-value Span), Continuation (compile-time single-resume), and for-loop iteration over a new Iterable protocol (borrows elements, batches via Span). None of these are in the Xcode 27.0 beta-1 stdlib yet (verified absent from Swift.swiftinterface) — treat them as forthcoming, not adoptable today. Re-check in a later beta.
Decision Tree
Need explicit ownership?
├─ Working with ~Copyable type?
│ └─ Yes → Required (borrowing/consuming)
├─ Fixed-size collection, no heap allocation?
│ └─ Yes → InlineArray<let count, Element>
├─ Need safe pointer-like access to contiguous memory?
│ ├─ Read-only? → Span<Element>
│ ├─ Mutable? → MutableSpan<Element>
│ └─ Raw bytes? → RawSpan / MutableRawSpan
├─ Large value type passed frequently?
│ ├─ Read-only? → borrowing
│ └─ Final use? → consuming
├─ ARC traffic visible in profiler?
│ ├─ Read-only? → borrowing
│ └─ Transferring ownership? → consuming
└─ Otherwise → Let compiler chooseResources
Swift Evolution: SE-0377, SE-0453 (Span), SE-0451 (InlineArray), SE-0452 (value generics)
WWDC: 2024-10170, 2025-245, 2025-312, 2026-262
Docs: /swift/inlinearray, /swift/span
Skills: axiom-performance (skills/swift-performance.md), axiom-concurrency
Modern Swift Idioms
Purpose
Claude frequently generates outdated Swift patterns from its training data. This skill corrects the most common ones — patterns that compile fine but use legacy APIs when modern equivalents are clearer, more efficient, or more correct.
Philosophy: "Don't repeat what LLMs already know — focus on edge cases, surprises, soft deprecations." (Paul Hudson)
Modern API Replacements
| Old Pattern | Modern Swift | Since | Why |
|---|---|---|---|
Date() | Date.now | 5.6 | Clearer intent |
filter { }.count | count(where:) | 6.0 | Single pass, no intermediate allocation (SE-0220; reverted before 5.0 shipped, re-introduced in 6.0) |
replacingOccurrences(of:with:) | replacing(_:with:) | 5.7 | Swift native, no Foundation bridge |
CGFloat | Double | 5.5 | Implicit bridging; exceptions: optionals, inout, ObjC-bridged APIs |
Task.sleep(nanoseconds:) | Task.sleep(for: .seconds(1)) | 5.7 | Type-safe Duration API |
DateFormatter() | .formatted() / FormatStyle | 5.5 | No instance management, localizable by default |
String(format: "%.2f", val) | val.formatted(.number.precision(.fractionLength(2))) | 5.5 | Type-safe, localized |
localizedCaseInsensitiveContains() | localizedStandardContains() | 5.0 | Handles diacritics, ligatures, width variants |
"\(firstName) \(lastName)" | PersonNameComponents with .formatted() | 5.5 | Respects locale name ordering |
"yyyy-MM-dd" with DateFormatter | try Date(string, strategy: .iso8601) | 5.6 | Modern parsing (throws); use "y" not "yyyy" for display |
contains() on user input | localizedStandardContains() | 5.0 | Required for correct text search/filtering |
Modern Syntax
| Old Pattern | Modern Swift | Since |
|---|---|---|
if let value = value { | if let value { | 5.7 |
Explicit return in single-expression | Omit return; if/switch are expressions | 5.9 |
Circle() in modifiers | .circle (static member lookup) | 5.5 |
Dropping import UIKit/import AppKit when using SwiftUI | Keep them — SwiftUI re-exports only CoreGraphics, CoreTransferable, DeveloperToolsSupport, and SwiftUICore, NOT UIKit or AppKit. import UIKit/import AppKit is still required for UIViewController, UIView, UIApplication, gesture recognizers, etc. A few cross-platform types are surfaced through SwiftUI's own bridges (Image(uiImage:), Color/Font) | — |
Foundation Modernization
| Old Pattern | Modern Foundation | Since |
|---|---|---|
FileManager.default.urls(for: .documentDirectory, ...) | URL.documentsDirectory | 5.7 |
url.appendingPathComponent("file") | url.appending(path: "file") | 5.7 |
books.sorted { $0.author < $1.author } (repeated) | Conform to Comparable, call .sorted() | — |
"yyyy" in date format for display | "y" — correct in all calendar systems | — |
SwiftUI Convenience APIs Claude Misses
- `ContentUnavailableView.search(text: searchText)` (iOS 17+) automatically includes the search term — no need to compose a custom string
- `LabeledContent` in Forms (iOS 16+) provides consistent label alignment without manual HStack layout
- `confirmationDialog()` must attach to triggering UI — Liquid Glass morphing animations depend on the source element
Swift 6.4 Language Features (OS27)
Swift 6.4 ships with Xcode 27 (the toolchain also folds in the 6.3 work). Prefer these in new code:
| Feature | Use | Replaces |
|---|---|---|
@available(anyAppleOS 27, *) / #if os(anyAppleOS) | One token for all Apple OSes | Verbose @available(iOS 27, macOS 27, watchOS 27, tvOS 27, visionOS 27, *) |
weak let | Immutable weak ref → the class can be Sendable, not @unchecked Sendable | weak var forcing @unchecked Sendable |
class T: ~Sendable | Explicitly suppress Sendable (subclasses can still add it back) | No prior syntax |
| Second memberwise init | A struct mixing internal + private stored properties also gets an internal memberwise init usable from other files | Hand-written init |
// anyAppleOS — one availability token for the whole 27 cycle
@available(anyAppleOS 27, *)
func showStatus() { ... }
@available(anyAppleOS 27, *)
@available(tvOS, unavailable) // still exclude specific platforms
func launch() { ... }
// weak let → Sendable without the escape hatch
final class Spacecraft: Sendable {
weak let dockedAt: SpaceStation?
}Caveat: anyAppleOS requires the Swift 6.4 toolchain (Xcode 27+). For code that must build on older Xcode, keep the explicit per-platform @available. Either way, @available(iOS 27, *)-style gating remains the authoritative runtime check.
Swift 6.4 Concurrency Posture
Write Swift 6.4-first code, not Swift 5-era code. These defaults apply to ALL new Swift code, not just when concurrency errors appear.
| Default | Rationale |
|---|---|
| Assume strict concurrency and MainActor default isolation for app/UI modules | Default for new Xcode 27 app projects (approachable concurrency, Swift 6.2+) |
Handle errors thrown inside Task { } — don't silently ignore them | Swift 6.4 warns on an unhandled thrown error in a Task; handle in-task or save the task and check later |
await is allowed in defer blocks | Swift 6.4 removed the old restriction — clean up with async work directly in defer |
| Prefer async/await over GCD, DispatchGroup, and callback pyramids | GCD is a bridge pattern for legacy APIs, not default architecture |
Async does not mean background — use @concurrent (Swift 6.2+) to force off-main | Async functions resume on the same actor they were called from |
Prefer structured concurrency (async let, TaskGroup) over unstructured Task {} | Structured tasks propagate cancellation and errors automatically |
Do not use Task.detached unless there is a specific, stated reason | Loses actor context, priority, and task-local values |
| Prefer Sendable structs/enums for data that crosses actor boundaries | Value types are inherently safe to share |
| Use actors only for truly shared mutable state across concurrency domains | Don't make every class an actor — UI code stays @MainActor |
Treat @unchecked Sendable, @preconcurrency, nonisolated(unsafe) as temporary bridge tools | Each should have a removal ticket, not be permanent |
| Do not add escape hatches just to silence compiler errors | They hide data races that crash in production |
For detailed patterns, decision trees, and error-specific guidance, see axiom-concurrency (swift-concurrency reference).
Common Claude Hallucinations
These patterns appear frequently in Claude-generated code:
1. Creates `DateFormatter` instances inline — Use .formatted() or FormatStyle instead. If a formatter must exist, make it static let. 2. Uses `DispatchQueue.main.async` — Use @MainActor or MainActor.run. GCD is a bridge pattern, not a default. 3. Uses `DispatchQueue.global().async` for background work — Use @concurrent (Swift 6.2+) or extract to an actor. 4. Uses `Task.detached` to "make it background" — Use @concurrent. Task.detached loses actor context. 5. Uses `CGFloat` for SwiftUI parameters — Double works everywhere since Swift 5.5 implicit bridging. 6. Generates `guard let x = x else` — Use guard let x else shorthand. 7. Returns explicitly in single-expression computed properties — Omit return. 8. Spawns unstructured `Task {}` in loops — Use TaskGroup for dynamic parallel work. 9. Adds `@unchecked Sendable` to silence warnings — Convert to actor or proper Sendable type. 10. Writes the verbose 5-platform `@available(iOS 27, macOS 27, …)` — On Swift 6.4 (Xcode 27), use @available(anyAppleOS 27, *); add per-platform unavailable lines only for exclusions. 11. Uses `weak var` + `@unchecked Sendable` — On Swift 6.4, weak let lets the class be plain Sendable with no escape hatch. 12. Ignores an error thrown in `Task { try … }` — Swift 6.4 warns; handle it in the task (do/catch) or save the task and check the result later.
Resources
WWDC: 2026-262
Skills: axiom-performance (skills/swift-performance.md), axiom-concurrency, axiom-swiftui
Transferable & Content Sharing Reference
Comprehensive guide to the CoreTransferable framework and SwiftUI sharing surfaces: drag and drop, copy/paste, and ShareLink.
When to Use This Skill
- Implementing drag and drop (
.draggable,.dropDestination) - Adding copy/paste support (
.copyable,.pasteDestination,PasteButton) - Sharing content via
ShareLink - Making custom types transferable
- Declaring custom UTTypes for app-specific formats
- Bridging
Transferabletypes with UIKit'sNSItemProvider - Choosing between
CodableRepresentation,DataRepresentation,FileRepresentation, andProxyRepresentation
Example Prompts
"How do I make my model draggable in SwiftUI?" "ShareLink isn't showing my custom preview" "How do I accept dropped files in my view?" "What's the difference between DataRepresentation and FileRepresentation?" "How do I add copy/paste support for my custom type?" "My drag and drop works within the app but not across apps" "How do I declare a custom UTType?"
---
Part 1: Quick Reference
Decision Tree: Which TransferRepresentation?
Your model type...
├─ Conforms to Codable + no specific binary format needed?
│ → CodableRepresentation
├─ Has custom binary format (Data in memory)?
│ → DataRepresentation (exporting/importing closures)
├─ Lives on disk (large files, videos, documents)?
│ → FileRepresentation (passes file URLs, not bytes)
├─ Need a fallback for receivers that don't understand your type?
│ → Add ProxyRepresentation (e.g., export as String or URL)
└─ Need to conditionally hide a representation?
→ Apply .exportingCondition to any representationCommon Errors
| Error / Symptom | Cause | Fix |
|---|---|---|
| "Type does not conform to Transferable" | Missing transferRepresentation | Add static var transferRepresentation: some TransferRepresentation |
| Drop works in-app but not across apps | Custom UTType not declared in Info.plist | Add UTExportedTypeDeclarations entry |
| Receiver always gets plain text instead of rich type | ProxyRepresentation listed before CodableRepresentation | Reorder: richest representation first |
| FileRepresentation crashes with "file not found" | Receiver didn't copy file before sandbox extension expired | Copy to app storage in the importing closure |
| PasteButton always disabled | Pasteboard doesn't contain matching Transferable type | Check UTType conformance; verify the pasted data matches |
| ShareLink shows generic preview | No SharePreview provided or image isn't Transferable | Supply explicit SharePreview with title and image |
.dropDestination closure never fires | Wrong payload type or view has zero hit-test area | Verify for: type matches dragged content; add .frame() or .contentShape() |
Built-in Transferable Types
These work with zero additional code — no conformance needed:
String, Data, URL, AttributedString, Image, Color
---
Part 2: Making Types Transferable
The Transferable protocol has one requirement: a static transferRepresentation property.
CodableRepresentation
Best for: models already conforming to Codable. Uses JSON by default.
import UniformTypeIdentifiers
extension UTType {
static var todo: UTType = UTType(exportedAs: "com.example.todo")
}
struct Todo: Codable, Transferable {
var text: String
var isDone: Bool
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .todo)
}
}Custom encoder/decoder (e.g., PropertyList instead of JSON):
CodableRepresentation(
contentType: .todo,
encoder: PropertyListEncoder(),
decoder: PropertyListDecoder()
)Requirement: Custom UTTypes need matching UTExportedTypeDeclarations in Info.plist (see Part 4).
DataRepresentation
Best for: custom binary formats where data is in memory and you control serialization.
struct ProfilesArchive: Transferable {
var profiles: [Profile]
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(contentType: .commaSeparatedText) { archive in
try archive.toCSV()
} importing: { data in
try ProfilesArchive(csvData: data)
}
}
}Import-only or export-only variants:
// Import only
DataRepresentation(importedContentType: .png) { data in
try MyImage(pngData: data)
}
// Export only
DataRepresentation(exportedContentType: .png) { image in
try image.pngData()
}Avoid using UTType.data as the content type — use a specific type like .png, .pdf, .commaSeparatedText.
FileRepresentation
Best for: large payloads on disk (videos, documents, archives). Passes file URLs instead of loading bytes into memory.
struct Video: Transferable {
let file: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .mpeg4Movie) { video in
SentTransferredFile(video.file)
} importing: { received in
// MUST copy — sandbox extension is temporary
let dest = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mp4")
try FileManager.default.copyItem(at: received.file, to: dest)
return Video(file: dest)
}
}
}Critical: The received.file URL has a temporary sandbox extension. Copy the file to your own storage in the importing closure — the URL becomes inaccessible after the closure returns.
SentTransferredFile properties:
file: URL— the file locationallowAccessingOriginalFile: Bool— whenfalse(default), receiver gets a copy
ReceivedTransferredFile properties:
file: URL— the received file on diskisOriginalFile: Bool— whether this is the sender's original file or a copy
Content type precision: .mpeg4Movie only matches .mp4 files. To accept all common video formats (.mp4, .mov, .m4v), use the parent type .movie — or declare multiple FileRepresentations for specific subtypes:
// Broad: accept any video format the system recognizes
FileRepresentation(contentType: .movie) { ... } importing: { ... }
// Or specific: separate handlers per format
FileRepresentation(contentType: .mpeg4Movie) { ... } importing: { ... }
FileRepresentation(contentType: .quickTimeMovie) { ... } importing: { ... }Import-only: When your type only receives files (drop target, no export), use the import-only initializer — it makes intent explicit and avoids accidental export:
FileRepresentation(importedContentType: .movie) { received in
let dest = appStorageURL.appendingPathComponent(received.file.lastPathComponent)
try FileManager.default.copyItem(at: received.file, to: dest)
return VideoClip(localURL: dest)
}ProxyRepresentation
Best for: fallback representations that let your type work with receivers expecting simpler types.
struct Profile: Transferable {
var name: String
var avatar: Image
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .profile)
ProxyRepresentation(exporting: \.name) // Fallback: paste as text
}
}Export-only proxy (common pattern — reverse conversion often impossible):
ProxyRepresentation(exporting: \.name) // Profile → String (one-way)Bidirectional proxy (when reverse makes sense):
ProxyRepresentation { item in
item.name // export
} importing: { name in
Profile(name: name) // import
}Combining Multiple Representations
List representations in the transferRepresentation body. Order matters — receivers use the first representation they support.
struct Profile: Transferable {
static var transferRepresentation: some TransferRepresentation {
// 1. Richest: full profile data (apps that understand .profile)
CodableRepresentation(contentType: .profile)
// 2. Fallback: plain text (text fields, notes, any app)
ProxyRepresentation(exporting: \.name)
}
}Common mistake: putting ProxyRepresentation first causes receivers that support both to always get the degraded version.
Conditional Export
Hide a representation at runtime when conditions aren't met:
DataRepresentation(contentType: .commaSeparatedText) { archive in
try archive.toCSV()
} importing: { data in
try Self(csvData: data)
}
.exportingCondition { archive in
archive.supportsCSV
}Visibility
Control which processes can see a representation:
CodableRepresentation(contentType: .profile)
.visibility(.ownProcess) // Only within this appOptions: .all (default), .team (same developer team), .group (same App Group, macOS), .ownProcess (same app only)
Suggested File Name
Hint for receivers writing to disk:
FileRepresentation(contentType: .mpeg4Movie) { video in
SentTransferredFile(video.file)
} importing: { received in
// ...
}
.suggestedFileName("My Video.mp4")
// Or dynamic:
.suggestedFileName { video in video.title + ".mp4" }---
Part 3: SwiftUI Surfaces
ShareLink
The standard sharing entry point. Accepts any Transferable type.
// Simple: share a string
ShareLink(item: "Check out this app!")
// With preview
ShareLink(
item: photo,
preview: SharePreview(photo.caption, image: photo.image)
)
// Share a URL with custom preview (prevents system metadata fetch)
ShareLink(
item: URL(string: "https://example.com")!,
preview: SharePreview("My Site", image: Image("hero"))
)Sharing multiple items with per-item previews:
ShareLink(items: photos) { photo in
SharePreview(photo.caption, image: photo.image)
}SharePreview initializers:
SharePreview("Title")— text onlySharePreview("Title", image: someImage)— text + full-size imageSharePreview("Title", icon: someIcon)— text + thumbnail iconSharePreview("Title", image: someImage, icon: someIcon)— all three
Gotcha: If you omit SharePreview for a custom type, the share sheet shows a generic preview. Always provide one for non-trivial types.
Drag and Drop
Making a view draggable:
Text(profile.name)
.draggable(profile)With custom drag preview:
Text(profile.name)
.draggable(profile) {
Label(profile.name, systemImage: "person")
.padding()
.background(.regularMaterial)
}Accepting drops:
Color.clear
.frame(width: 200, height: 200)
.dropDestination(for: Profile.self) { profiles, location in
guard let profile = profiles.first else { return false }
self.droppedProfile = profile
return true
} isTargeted: { isTargeted in
self.isDropTargeted = isTargeted
}Multiple item types — use an enum wrapper conforming to Transferable rather than stacking .dropDestination modifiers (stacking may cause only the outermost handler to fire):
enum DroppableItem: Transferable {
case image(Image)
case text(String)
static var transferRepresentation: some TransferRepresentation {
ProxyRepresentation { (image: Image) in DroppableItem.image(image) }
ProxyRepresentation { (text: String) in DroppableItem.text(text) }
}
}
myView
.dropDestination(for: DroppableItem.self) { items, _ in
for item in items {
switch item {
case .image(let img): handleImage(img)
case .text(let str): handleString(str)
}
}
return true
}ForEach with reordering — combine with .onMove or use draggable/dropDestination for cross-container moves.
Clipboard (Copy/Paste)
Copy support (activates Edit > Copy / Cmd+C):
List(items) { item in
Text(item.name)
}
.copyable(items)Paste support (activates Edit > Paste / Cmd+V):
List(items) { item in
Text(item.name)
}
.pasteDestination(for: Item.self) { pasted in
items.append(contentsOf: pasted)
} validator: { candidates in
candidates.filter { $0.isValid }
}The validator closure runs before the action — return an empty array to prevent the paste.
Cut support:
.cuttable(for: Item.self) {
let selected = items.filter { $0.isSelected }
items.removeAll { $0.isSelected }
return selected
}PasteButton — system button that handles paste with type filtering:
PasteButton(payloadType: String.self) { strings in
notes.append(contentsOf: strings)
}Platform difference: PasteButton auto-validates pasteboard changes on iOS but not on macOS.
Availability: .copyable, .pasteDestination, and .cuttable are macOS 13+ only — they do not exist on iOS. On iOS, use PasteButton (iOS 16+) for paste, and standard context menus or UIPasteboard for programmatic copy/cut. PasteButton is cross-platform: macOS 10.15+, iOS 16+, visionOS 1.0+.
---
Part 4: UTType Declarations
System Types
Use Apple's built-in UTTypes when possible — they're already recognized across the system:
import UniformTypeIdentifiers
// Common types
UTType.plainText // public.plain-text
UTType.utf8PlainText // public.utf8-plain-text
UTType.json // public.json
UTType.png // public.png
UTType.jpeg // public.jpeg
UTType.pdf // com.adobe.pdf
UTType.mpeg4Movie // public.mpeg-4
UTType.commaSeparatedText // public.comma-separated-values-textDeclaring Custom Types
Step 1: Declare in Swift:
extension UTType {
static var recipe: UTType = UTType(exportedAs: "com.myapp.recipe")
}Step 2: Add to Info.plist under UTExportedTypeDeclarations:
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.myapp.recipe</string>
<key>UTTypeDescription</key>
<string>Recipe</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>recipe</string>
</array>
</dict>
</dict>
</array>Both are required. The Swift declaration alone makes it compile, but cross-app transfers silently fail without the Info.plist entry.
Imported vs Exported Types
- Exported (
exportedAs:) — Your app owns this type. Use for app-specific formats. - Imported (
importedAs:) — Another app owns this type. Use when you want to accept their format.
UTType Conformance
Custom types should conform to system types for broader compatibility:
// Your .recipe conforms to public.data (binary data)
// This means any receiver that accepts generic data can also accept recipesCommon conformance parents: public.data, public.content, public.text, public.image
---
Part 5: UIKit Bridging
NSItemProvider + Transferable
Bridge between UIKit's NSItemProvider (used by UIActivityViewController, extensions, drag sessions) and Transferable:
// Load a Transferable from an NSItemProvider
let provider: NSItemProvider = // from drag session, extension, etc.
provider.loadTransferable(type: Profile.self) { result in
switch result {
case .success(let profile):
// Use the profile
case .failure(let error):
// Handle error
}
}When to Use UIActivityViewController
ShareLink covers most sharing needs. Use UIActivityViewController when you need:
- Custom activity items or excluded activity types
UIActivityItemsConfigurationfor lazy item provision- Custom
UIActivitysubclasses - Programmatic presentation control
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}For most apps, ShareLink is sufficient and preferred — it integrates with Transferable natively.
---
Part 6: Gotchas & Troubleshooting
FileRepresentation Temporary File Lifecycle
The received.file URL in a FileRepresentation importing closure has a temporary sandbox extension. The system may revoke access after the closure returns. Always copy the file:
// WRONG — file may become inaccessible
return Video(file: received.file)
// RIGHT — copy to your own storage
let dest = myAppDirectory.appendingPathComponent(received.file.lastPathComponent)
try FileManager.default.copyItem(at: received.file, to: dest)
return Video(file: dest)Async Work After File Drop
The FileRepresentation importing closure is synchronous — you cannot await inside it. Copy the file first, return the model, then do async post-processing (thumbnails, transcoding, metadata extraction) on the copied URL:
// WRONG — can't await in the importing closure
FileRepresentation(importedContentType: .movie) { received in
let dest = ...
try FileManager.default.copyItem(at: received.file, to: dest)
let thumbnail = await generateThumbnail(for: dest) // ❌ compile error
return VideoClip(localURL: dest, thumbnail: thumbnail)
}
// RIGHT — return immediately, process async afterward
// In your view model or drop handler:
.dropDestination(for: VideoClip.self) { clips, _ in
for clip in clips {
timeline.append(clip)
Task {
// clip.localURL is the COPY — safe to access anytime
let thumbnail = await generateThumbnail(for: clip.localURL)
clip.thumbnail = thumbnail
}
}
return true
}Representation Ordering
Representations are tried in declaration order. The receiver uses the first one it supports.
// WRONG — receivers always get plain text
static var transferRepresentation: some TransferRepresentation {
ProxyRepresentation(exporting: \.name) // ← every receiver supports String
CodableRepresentation(contentType: .profile) // ← never reached
}
// RIGHT — richest first, fallbacks last
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .profile) // ← apps that understand Profile
ProxyRepresentation(exporting: \.name) // ← fallback for everyone else
}Custom UTType Without Info.plist
If you declare UTType(exportedAs: "com.myapp.type") in Swift but forget the Info.plist entry:
- In-app transfers work (same process recognizes the type)
- Cross-app transfers silently fail (other apps can't resolve the type)
This is the most common "works in development, fails in production" issue.
Drop Target Hit Testing
.dropDestination requires the view to have a non-zero frame for hit testing. If drops aren't registering:
// WRONG — Color.clear has zero intrinsic size
Color.clear
.dropDestination(for: Image.self) { ... }
// RIGHT — give it a frame
Color.clear
.frame(width: 200, height: 200)
.contentShape(Rectangle()) // ensure full area is hit-testable
.dropDestination(for: Image.self) { ... }Async Loading with loadTransferable
NSItemProvider.loadTransferable is asynchronous. Update UI on the main actor:
provider.loadTransferable(type: Profile.self) { result in
Task { @MainActor in
switch result {
case .success(let profile):
self.profile = profile
case .failure(let error):
self.errorMessage = error.localizedDescription
}
}
}PasteButton Platform Differences
PasteButton auto-validates against pasteboard changes on iOS — the button enables/disables as the pasteboard content changes. On macOS, this automatic validation does not occur. If your iOS app needs dynamic paste validation, monitor UIPasteboard.changedNotification. On macOS, monitor NSPasteboard change count manually (there is no equivalent notification).
---
Resources
WWDC: 2022-10062, 2022-10052, 2022-10023, 2022-10093, 2022-10095
Docs: /coretransferable/transferable, /coretransferable/choosing-a-transfer-representation-for-a-model-type, /coretransferable/filerepresentation, /coretransferable/proxyrepresentation, /swiftui/sharelink, /swiftui/drag-and-drop, /swiftui/clipboard, /uniformtypeidentifiers
Skills: axiom-integration, axiom-data (skills/codable.md), axiom-swiftui
tvOS Development
Overview
tvOS shares UIKit and SwiftUI with iOS but diverges in critical ways that catch every iOS developer. The three most dangerous assumptions: (1) local files persist, (2) WebView exists, (3) focus works like @FocusState.
Core principle tvOS is not "iOS on TV." It has a dual focus system, no persistent local storage, no WebView, and a remote with two incompatible generations. Treat it as its own platform.
tvOS 26 Adopts Liquid Glass design language with new app icon system. See axiom-design (skills/liquid-glass.md) for implementation patterns.
tvOS Porting Triage
Before shipping a tvOS port, verify these five areas — they account for 90% of tvOS-specific bugs:
| Area | Check | Section |
|---|---|---|
| Storage | No persistent local files — iCloud required | §3 |
| Focus | Dual system working, focus guides for gaps | §1 |
| WebView | Replaced with JavaScriptCore or native rendering | §4 |
| Text input | Shadow input or fullscreen keyboard handled | §6 |
| AVPlayer | Audio session, buffer, Menu button state machine | §7, §8 |
"It compiles on tvOS" means nothing. These five areas compile fine and fail at runtime.
When to Use This Skill
- Building a new tvOS app or adding tvOS target
- Porting an iOS app to tvOS
- Debugging focus, remote input, or storage issues on tvOS
- Working with AVPlayer, TVUIKit, or text input on tvOS
Example Prompts
These are real questions developers ask that this skill answers:
1. "I'm porting my iOS app to tvOS and focus navigation doesn't work"
-> The skill explains the dual focus system (UIKit Focus Engine vs @FocusState) and common traps
2. "My tvOS app loses all data between launches"
-> The skill explains there is no persistent local storage and shows the iCloud-first pattern
3. "How do I handle Siri Remote input in SwiftUI on tvOS?"
-> The skill covers both generations of remote and the three input layers (SwiftUI, UIKit gestures, GameController)
4. "WebView doesn't work on tvOS, how do I display web content?"
-> The skill shows JavaScriptCore for parsing and native rendering alternatives
Red Flags
If ANY of these appear, STOP:
- "I'll just use the same storage code as iOS" — tvOS has no Document directory
- "WebView will work for this" — No WebView on tvOS at all (Apple HIG: "Not supported in tvOS")
- "@FocusState handles focus" — tvOS has a dual focus system; @FocusState alone is incomplete
- "I'll save to Application Support" — It's Cache-only; the system deletes files when app is not running
- "Standard UITextField will work" — tvOS text input triggers a fullscreen keyboard; consider the shadow input pattern
- "I'll just use the same AVPlayer code" — tvOS needs .ambient audio session on launch, custom Menu button handling, and buffer tuning. Default iOS AVPlayer setup causes audio session conflicts and broken back navigation.
---
1. Focus Engine vs @FocusState
tvOS has two focus systems that must coexist. This is the #1 source of confusion for iOS developers.
The Dual System
| System | Controls | API |
|---|---|---|
| UIKit Focus Engine | Hardware remote navigation, directional scanning | UIFocusEnvironment, UIFocusSystem, UIFocusGuide |
| SwiftUI Focus | Programmatic focus binding, focus sections | @FocusState, .focused(), .focusable(), .focusSection() |
When Each Applies
User swipes on remote → UIKit Focus Engine handles it (always)
Code sets @FocusState → SwiftUI handles it (sometimes overridden by Focus Engine)The trap: @FocusState can set focus programmatically, but the UIKit Focus Engine is the ultimate authority. If the Focus Engine considers a view unfocusable, @FocusState assignments are silently ignored.
UIKit Focus Engine API
The UIFocusEnvironment protocol (implemented by UIView, UIViewController, UIWindow) provides:
class MyViewController: UIViewController {
// Priority-ordered list of where focus should go
override var preferredFocusEnvironments: [UIFocusEnvironment] {
[preferredButton, fallbackButton]
}
// Validate proposed focus changes
override func shouldUpdateFocus(
in context: UIFocusUpdateContext
) -> Bool {
// Return false to block focus movement
return context.nextFocusedView != disabledButton
}
// Respond to completed focus changes
override func didUpdateFocus(
in context: UIFocusUpdateContext,
with coordinator: UIFocusAnimationCoordinator
) {
coordinator.addCoordinatedAnimations {
context.nextFocusedView?.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
context.previouslyFocusedView?.transform = .identity
}
}
// Request focus update (async)
func moveFocusToPreferred() {
setNeedsFocusUpdate() // Schedule update
updateFocusIfNeeded() // Execute immediately
}
}UIFocusGuide — Bridging Navigation Gaps
When focusable views aren't in a direct grid layout, the Focus Engine can't find them by scanning directionally. UIFocusGuide creates invisible focusable regions that redirect to real views:
let focusGuide = UIFocusGuide()
view.addLayoutGuide(focusGuide)
// Position the guide between two non-adjacent views
NSLayoutConstraint.activate([
focusGuide.leadingAnchor.constraint(equalTo: leftButton.trailingAnchor),
focusGuide.trailingAnchor.constraint(equalTo: rightButton.leadingAnchor),
focusGuide.topAnchor.constraint(equalTo: leftButton.topAnchor),
focusGuide.heightAnchor.constraint(equalTo: leftButton.heightAnchor)
])
// When focus enters the guide, redirect to the target view
focusGuide.preferredFocusEnvironments = [rightButton]SwiftUI Focus API
struct ContentView: View {
@FocusState private var focusedItem: MenuItem?
var body: some View {
VStack {
ForEach(MenuItem.allCases) { item in
Button(item.title) { select(item) }
.focused($focusedItem, equals: item)
}
}
.focusSection() // Group focusable items for navigation
.defaultFocus($focusedItem, .home) // Set initial focus
}
}Key SwiftUI focus modifiers for tvOS:
.focused(_:equals:)— Bind focus to a value.focusable()— Make custom views focusable.focusSection()— Group related items for directional navigation.defaultFocus(_:_:)— Set where focus starts in a scope
Default Focusable Elements
UIButton, UITextField, UITableViewCell, and UICollectionViewCell are focusable by default. Custom views need canBecomeFocused (UIKit) or .focusable() (SwiftUI). The top-left item receives initial focus at launch.
Common Focus Gotchas
| Gotcha | Symptom | Fix |
|---|---|---|
| Non-focusable container | Swipe skips your view | Add .focusable() or override canBecomeFocused |
| Focus guide missing | Can't navigate to isolated view | Add UIFocusGuide to bridge the gap |
| @FocusState ignored | Programmatic focus doesn't work | Check preferredFocusEnvironments chain |
| Focus update not requested | Focus stays stale after layout change | Call setNeedsFocusUpdate() + updateFocusIfNeeded() |
| Items not in grid layout | Focus jumps unpredictably | Arrange focusable items in a grid or use focus guides |
| UIHostingConfiguration focus | Focus corruption in mixed UIKit/SwiftUI | Known issue — test UIHostingConfiguration cells carefully |
---
2. Siri Remote Input
Two generations with different hardware — your code must handle both.
Generation Differences
| Feature | Gen 1 (2015-2021) | Gen 2 (2021+) |
|---|---|---|
| Top surface | Touchpad (full swipe) | Clickpad + outer touch ring |
| Swipe gestures | Full area | Ring edge only |
| Click navigation | Center press | D-pad style |
| Accelerometer | Yes | Yes |
Standard SwiftUI Modifiers (Preferred)
For most UI, SwiftUI handles remote input automatically through the focus system:
Button("Play") { startPlayback() }
.focused($isFocused) // Automatically responds to remote navigation
List(items) { item in
Text(item.title)
}
// List navigation works automatically with remote
// Note: First item receives focus by default on tvOS — use .defaultFocus() to overrideGesture Recognizers (UIKit)
Detect specific button presses and gestures via UIKit recognizers:
// Detect Play/Pause button
let playPause = UITapGestureRecognizer(target: self, action: #selector(handlePlayPause))
playPause.allowedPressTypes = [NSNumber(value: UIPress.PressType.playPause.rawValue)]
view.addGestureRecognizer(playPause)
// Detect swipe on touchpad
let swipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe))
swipe.direction = .right
view.addGestureRecognizer(swipe)Available UIPress.PressType values: .menu, .playPause, .select, .upArrow, .downArrow, .leftArrow, .rightArrow, .pageUp, .pageDown
Low-Level Press Handling
For fine-grained control, override UIResponder press methods:
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectDown()
}
}
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectUp()
}
}
}
// Always implement all four: pressesBegan, pressesEnded, pressesChanged, pressesCancelledGame Controller Framework (Raw Input)
For custom interactions (scrubbing, games), access the Siri Remote as a GCMicroGamepad:
import GameController
NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect, object: nil, queue: .main
) { notification in
guard let controller = notification.object as? GCController,
let micro = controller.microGamepad else { return }
// Touchpad as analog D-pad (-1.0 to 1.0)
micro.dpad.valueChangedHandler = { _, xValue, yValue in
handleRemoteInput(x: xValue, y: yValue)
}
// reportsAbsoluteDpadValues: true = absolute position, false = relative movement
micro.reportsAbsoluteDpadValues = false
// allowsRotation: true = values adjust when remote is rotated
micro.allowsRotation = false
// Face buttons
micro.buttonA.pressedChangedHandler = { _, _, pressed in }
micro.buttonX.pressedChangedHandler = { _, _, pressed in }
micro.buttonMenu.pressedChangedHandler = { _, _, pressed in }
}Progress Bar Scrubbing
UIPanGestureRecognizer with virtual damping for smooth seeking:
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
let velocity = gesture.velocity(in: view)
let dampingFactor: CGFloat = 0.002 // Tune for feel
switch gesture.state {
case .changed:
let seekDelta = velocity.x * dampingFactor
player.seek(to: currentTime + seekDelta)
default:
break
}
}---
3. Storage Constraints
This is the most dangerous iOS assumption on tvOS. tvOS has no Document directory. All local storage is Cache that the system can delete at any time. Skipping iCloud integration means 2-3 weeks debugging intermittent "data disappears" bugs that only happen on real devices between app launches.
From Apple's App Programming Guide for tvOS: "Every app developed for the new Apple TV must be able to store data in iCloud and retrieve it in a way that provides a great customer experience."
What tvOS Has
| Directory | Exists? | Persistent? |
|---|---|---|
| Documents | No | N/A |
| Application Support | Yes | No — system can delete when app is not running |
| Caches | Yes | No — system deletes under storage pressure |
| tmp | Yes | No |
Size Limits
- App bundle: 4 GB maximum
- NSUserDefaults / UserDefaults: Limited storage (significantly less than iOS). Available but subject to system purge — not guaranteed persistent between sessions
- On-demand resources: Available for read-only assets the OS manages
- Local cache: No guaranteed size; system can purge while app is not running
What This Means
- Every local file can vanish between app launches
- SQLite databases stored locally will be deleted
- Your app must survive with zero local data
- Downloaded data is NOT deleted while the app is running — only between sessions
Recommended Pattern
// ✅ CORRECT: iCloud as primary, local as cache only
func loadData() async throws -> [Item] {
// 1. Try iCloud first (persistent)
if let cloudData = try? await fetchFromICloud() {
// Cache locally for offline use
try? cacheLocally(cloudData)
return cloudData
}
// 2. Fall back to local cache (may not exist)
if let cached = try? loadFromLocalCache() {
return cached
}
// 3. Start fresh — this is normal on tvOS
return []
}Database Recommendations
| Solution | tvOS Viability | Notes |
|---|---|---|
| SQLiteData + CloudKit SyncEngine | Recommended | iCloud is persistent; local is just cache |
| SwiftData + CloudKit | Works, but fragile | No persistent local-only storage; ModelContainer must be configured for CloudKit from day one — adding sync later requires migration; system database deletion triggers full re-sync on next launch |
| CoreData + CloudKit | Dangerous | Space inflation from CloudKit metadata |
| Local-only GRDB/SQLite | Unreliable | System deletes the database file |
| NSUbiquitousKeyValueStore | Good for small data | 1 MB limit, key-value only |
| On-demand resources | Good for read-only assets | OS manages download/purge lifecycle |
See axiom-data (skills/sqlitedata.md) for CloudKit SyncEngine patterns, axiom-data (skills/storage.md) for full storage decision tree.
---
4. No WebView
tvOS has no WKWebView, no SFSafariViewController, no WebView. Apple HIG explicitly states: web views are "Not supported in tvOS."
What You Can Do
| Need | Solution |
|---|---|
| Parse HTML/JSON | Use JavaScriptCore (JSContext, JSValue — no DOM) |
| Display web content | Render natively from parsed data |
| HLS streaming from m3u8 | Local HTTP server pattern (see below) |
| OAuth login | Device code flow (RFC 8628) or companion device |
JavaScriptCore for Parsing
JavaScriptCore provides a JavaScript execution engine without DOM or web rendering. Available on tvOS.
import JavaScriptCore
let context = JSContext()!
// Evaluate scripts
context.evaluateScript("""
function parsePlaylist(m3u8Text) {
return m3u8Text.split('\\n')
.filter(line => !line.startsWith('#'))
.filter(line => line.trim().length > 0);
}
""")
// Pass data safely via setObject (avoids injection)
context.setObject(m3u8Content, forKeyedSubscript: "rawContent" as NSString)
let result = context.evaluateScript("parsePlaylist(rawContent)")
// Convert back to Swift types
let segments = result?.toArray() as? [String] ?? []Key classes: JSVirtualMachine (execution environment), JSContext (script evaluation), JSValue (type bridging)
Limitation: No DOM, no web rendering, no fetch/XMLHttpRequest. Pure JavaScript execution only.
Local HTTP Server for HLS
When you need to serve modified m3u8 playlists to AVPlayer:
// Use Swifter (httpswift/swifter) or GCDWebServer
// Serve rewritten m3u8 on localhost, point AVPlayer to it
let localURL = URL(string: "http://localhost:8080/playlist.m3u8")!
let playerItem = AVPlayerItem(url: localURL)---
5. TVUIKit Components
tvOS-exclusive UIKit components. Bridge to SwiftUI via UIViewRepresentable.
TVPosterView
Media content display with built-in focus expansion and parallax:
import TVUIKit
let poster = TVPosterView(image: UIImage(named: "moviePoster"))
poster.title = "Movie Title"
poster.subtitle = "2024"
// Focus expansion and parallax happen automatically
// Access the underlying image view:
poster.imageView.adjustsImageWhenAncestorFocused = trueTVLockupView
Base class for TVPosterView — a flexible container managing content with focus behavior:
let lockup = TVLockupView()
lockup.contentView.addSubview(customView)
lockup.headerView = headerFooter // TVLockupHeaderFooterView
lockup.footerView = footerFooter
// showsOnlyWhenAncestorFocused: header/footer visibility on focusOther TVUIKit Components
| Component | Purpose |
|---|---|
| TVCardView | Simple container with customizable background |
| TVCaptionButtonView | Button with image + text + directional parallax |
| TVMonogramView | User initials/image with PersonNameComponents |
| TVCollectionViewFullScreenLayout | Immersive full-screen collection with parallax + masking |
| TVMediaItemContentView | Content configuration with badges, playback progress |
TVDigitEntryViewController
System-provided passcode/PIN entry (tvOS 12+):
let digitEntry = TVDigitEntryViewController()
digitEntry.numberOfDigits = 4
digitEntry.titleText = "Enter PIN"
digitEntry.promptText = "Enter your parental control code"
digitEntry.isSecureDigitEntry = true
present(digitEntry, animated: true)
digitEntry.entryCompletionHandler = { pin in
guard let pin else { return } // User cancelled
authenticate(with: pin)
}
// Reset entry
digitEntry.clearEntry(animated: true)---
6. Text Input on tvOS
tvOS text input is fundamentally different from iOS. Apple recommends minimizing text input in your UI.
Text display — tvOS 27 brings system-wide Dynamic Type (Large Text). For adoption, layout adaptation, and Nutrition Labels, see axiom-accessibility (skills/accessibility-diag.md, "Dynamic Type Comes to tvOS").
Three Approaches
| Approach | Best For | Keyboard Style |
|---|---|---|
| UIAlertController | Quick, simple input | Modal with text field |
| UITextField | Multi-field forms | Fullscreen keyboard with Next/Previous |
| UISearchController | Search | Inline single-line keyboard |
UITextField (Fullscreen Keyboard)
The primary text input method. Calling becomeFirstResponder() presents a fullscreen keyboard:
let textField = UITextField()
textField.placeholder = "Enter name"
textField.becomeFirstResponder() // Presents keyboard immediately
// Done button returns user to previous page
// Built-in Next/Previous buttons navigate between text fieldsShadow Input Pattern (SwiftUI)
When you want a custom-styled input trigger in SwiftUI:
struct TVTextInput: View {
@State private var text = ""
@State private var isEditing = false
var body: some View {
Button {
isEditing = true
} label: {
HStack {
Text(text.isEmpty ? "Search..." : text)
.foregroundStyle(text.isEmpty ? .secondary : .primary)
Spacer()
Image(systemName: "keyboard")
}
.padding()
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.sheet(isPresented: $isEditing) {
TVKeyboardSheet(text: $text)
}
}
}UISearchController (Inline Keyboard)
For search interfaces — all input on a single line, but very limited customization:
let searchController = UISearchController(searchResultsController: resultsVC)
searchController.searchResultsUpdater = self
// Cannot customize text traits or add input accessoriesSwiftUI .searchable()
SwiftUI's .searchable() modifier works on tvOS and presents the system search keyboard. Use it for standard search patterns:
NavigationStack {
List(filteredItems) { item in
Text(item.title)
}
.searchable(text: $searchText, prompt: "Search movies")
}For custom search UI beyond what .searchable() offers, fall back to the shadow input pattern above.
---
7. AVPlayer Tuning
tvOS media apps need specific AVPlayer configuration for good UX.
Essential Settings
let player = AVPlayer(url: streamURL)
// automaticallyWaitsToMinimizeStalling defaults to true (iOS 10+/tvOS 10+)
// Set false for immediate playback when synchronizing players
// or when you want playback to start ASAP from a non-empty buffer
player.automaticallyWaitsToMinimizeStalling = false
// Buffer hint — 0 means system chooses automatically
// Higher values reduce stalling risk but consume more memory
player.currentItem?.preferredForwardBufferDuration = 30
// Audio session — don't interrupt other apps' audio on launch
try AVAudioSession.sharedInstance().setCategory(.ambient)
// Switch to .playback when user presses playCustom Dismiss Logic
The default swipe-down gesture dismisses the player. Override for media apps:
class PlayerViewController: AVPlayerViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Handle Menu button for custom back navigation
let menuPress = UITapGestureRecognizer(
target: self, action: #selector(handleMenu)
)
menuPress.allowedPressTypes = [
NSNumber(value: UIPress.PressType.menu.rawValue)
]
view.addGestureRecognizer(menuPress)
}
@objc func handleMenu() {
if isShowingControls {
hideControls()
} else {
dismiss(animated: true)
}
}
}---
8. Menu Button State Machine
The Siri Remote Menu button doubles as "back" and "dismiss." Media apps need a state machine to handle it correctly.
The Problem
State: Playing with controls visible
Menu press → Hide controls (not dismiss)
State: Playing with controls hidden
Menu press → Show "are you sure?" or dismiss
State: In submenu/settings overlay
Menu press → Close overlay (not dismiss player)Pattern
enum PlayerState {
case loading // Buffering / loading content
case playing // Controls hidden
case controlsShown // Controls visible
case submenu // Settings/subtitles overlay
}
func handleMenuPress(in state: PlayerState) -> PlayerState {
switch state {
case .submenu:
dismissSubmenu()
return .controlsShown
case .controlsShown:
hideControls()
return .playing
case .playing:
dismiss(animated: true)
return .playing
case .loading:
cancelLoading()
dismiss(animated: true)
return .loading
}
}---
9. Network Differences
IPv6 Priority
Apple TV strongly prefers IPv6. All App Store apps must support IPv6-only networks (DNS64/NAT64). If your backend is IPv4-only, connections may be slower or fail on some networks.
Device Performance Variance
| Device | Chip | RAM | Notes |
|---|---|---|---|
| Apple TV HD (4th gen) | A8 | 2 GB | Still supported; much slower |
| Apple TV 4K (1st gen) | A10X | 3 GB | Capable |
| Apple TV 4K (2nd gen) | A12 | 4 GB | Good |
| Apple TV 4K (3rd gen) | A15 | 4 GB | Excellent |
Test on older hardware. The Apple TV HD is still in use and dramatically slower than 4K models.
---
10. Developer Experience
Debug-Only Input Macros
Test without Siri Remote in Simulator using keyboard shortcuts:
#if DEBUG
extension View {
func debugOnlyModifier() -> some View {
self.onKeyPress(.space) {
print("Space pressed — simulating select")
return .handled
}
}
}
#endifView Inspection Helper
#if DEBUG
extension View {
func debugBorder() -> some View {
border(.red, width: 1)
}
}
#endifSimulator Limitations
- Simulator does not accurately simulate Focus Engine behavior
- Always test focus navigation on a real Apple TV device
- Simulator keyboard input != Siri Remote input
- Performance profiling must happen on device (especially Apple TV HD)
---
Anti-Rationalization
| Thought | Reality |
|---|---|
| "I'll just use the same code as iOS" | tvOS diverges in storage, focus, input, and web views. You will hit walls. |
| "Focus works like iOS" | tvOS has a dual focus system (UIKit Focus Engine + SwiftUI @FocusState). @FocusState alone is insufficient. |
| "Local storage is fine for now" | There is no persistent local storage on tvOS. Apple requires iCloud capability. |
| "WebView will work" | Apple HIG: web views are "Not supported in tvOS." JavaScriptCore only (no DOM). |
| "I'll handle text input with TextField" | UITextField triggers a fullscreen keyboard. Consider shadow input pattern or UISearchController for better UX. |
| "I only need to test on Simulator" | Focus Engine and performance require real device testing. |
---
Resources
Docs: /tvuikit, /uikit/uifocusenvironment, /uikit/uifocusguide, /swiftui/focus, /gamecontroller/gcmicrogamepad, /avfoundation/avplayer, /javascriptcore
WWDC: 2016-215, 2017-224, 2021-10023, 2021-10081, 2021-10191, 2023-10162, 2025-219
Skills: axiom-data (skills/storage.md), axiom-data (skills/sqlitedata.md), axiom-integration, axiom-design (skills/hig-ref.md), axiom-design (skills/liquid-glass.md)
Related skills
How it compares
Choose axiom-swift for Apple-specific Swift and SwiftUI review depth rather than generic language-agnostic lint rules.
FAQ
What Swift topics does axiom-swift cover?
axiom-swift covers Swift idiom review, noncopyable ownership types, Transferable drag-and-drop, debug deep links, Foundation modernization, and tvOS development, with references like swift-modern.md for outdated API patterns.
When must axiom-swift be used according to its README?
axiom-swift must be used for any Swift idiom review, ownership or noncopyable work, Transferable drag-and-drop, debug deep links, or tvOS development tasks listed in its skill triggers.
Is Axiom Swift safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.