tvos-specific-patterns
SKILL.md
tvOS Specific Patterns — Expert Decisions
Expert decision frameworks for tvOS choices. Claude knows @FocusState and SwiftUI basics — this skill provides judgment calls for focus architecture, remote handling, and TV-optimized UI trade-offs.
Decision Trees
Focus Management Strategy
How complex is your focus navigation?
├─ Linear list/grid navigation
│ └─ System focus handling
│ Default behavior, no custom code needed
│
├─ Multiple focus sections with priority
│ └─ .focusSection() grouping
│ Group related elements, system handles within
│
├─ Custom initial focus on appear
│ └─ @FocusState + .onAppear
│ Set focused item programmatically
│
├─ Complex conditional focus logic
│ └─ .prefersDefaultFocus() + @FocusState
│ Combine for sophisticated control
│
└─ Focus must follow data changes
└─ Bind @FocusState to data model
Update focus when selection changes
The trap: Overriding focus behavior when system defaults work fine. Custom focus logic adds complexity and can break expected navigation patterns.
Custom Focus Effect Decision
Should you create custom focus effects?
├─ Standard card/button scaling
│ └─ Use .buttonStyle(.card)
│ Apple's built-in TV-appropriate effect
│
├─ Need subtle highlight
│ └─ .isFocused environment
│ Opacity/border changes only
│
├─ Complex parallax/tilt effects
│ └─ Custom view with rotation3DEffect
│ Only for hero content, not lists
│
└─ Media-browsing app (Netflix-like)
└─ Custom focus with content preview
Scale + shadow + metadata reveal
Top Shelf Content Strategy
What content should appear in Top Shelf?
├─ Personalized content (continue watching)
│ └─ TVTopShelfCarouselContent with .actions style
│ Play and display actions per item
│
├─ Browse/discovery content
│ └─ TVTopShelfSectionedContent
│ Multiple categories with items
│
├─ Single featured item
│ └─ TVTopShelfCarouselContent single item
│ Hero image with actions
│
├─ Time-sensitive content
│ └─ Update via background refresh
│ Schedule updates, cache for speed
│
└─ User not logged in
└─ Promotional content + CTA
Drive app opens and registration
Siri Remote Gesture Selection
Which gesture for this interaction?
├─ Primary action (play, select)
│ └─ Tap (click center)
│ .onTapGesture or Button
│
├─ Secondary action (info, options)
│ └─ Long press
│ Context menu or info overlay
│
├─ Content navigation (carousel)
│ └─ Swipe left/right
│ DragGesture with threshold
│
├─ Volume/scrubbing
│ └─ Swipe up/down
│ System handles in video player
│
└─ Cancel/back
└─ Menu button
.onExitCommand handler
NEVER Do
Focus Management
NEVER fight the Focus Engine:
// ❌ Trying to manually manage all focus
struct BadNavigation: View {
@State private var selectedIndex = 0
var body: some View {
HStack {
ForEach(0..<items.count) { index in
ItemView(item: items[index])
.opacity(selectedIndex == index ? 1 : 0.5) // Fake focus
}
}
.onMoveCommand { direction in
// Manual index management — reinventing Focus Engine!
switch direction {
case .left: selectedIndex = max(0, selectedIndex - 1)
case .right: selectedIndex = min(items.count - 1, selectedIndex + 1)
default: break
}
}
}
}
// ✅ Let Focus Engine handle it
struct GoodNavigation: View {
@FocusState private var focusedItem: Item.ID?
var body: some View {
HStack {
ForEach(items) { item in
ItemView(item: item)
.focusable()
.focused($focusedItem, equals: item.id)
}
}
}
}
NEVER use .focusable() without visual feedback:
// ❌ User can't see what's focused
Text("Invisible Focus")
.focusable() // Focusable but no visual change!
// ✅ Always show focus state
struct FocusableText: View {
@Environment(\.isFocused) var isFocused
var body: some View {
Text("Visible Focus")
.scaleEffect(isFocused ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isFocused)
.focusable()
}
}
NEVER animate focus changes slowly:
// ❌ Laggy feel — focus must be instant
.animation(.easeInOut(duration: 0.5), value: isFocused) // Too slow!
// ✅ Quick transitions (200ms or less)
.animation(.easeInOut(duration: 0.2), value: isFocused)
Remote Handling
NEVER use complex gestures on tvOS:
// ❌ Siri Remote doesn't support these
.gesture(
MagnificationGesture() // No pinch on Siri Remote!
)
.gesture(
RotationGesture() // No rotation on Siri Remote!
)
// ✅ Stick to supported gestures
.onTapGesture { }
.onLongPressGesture { }
.gesture(DragGesture()) // Swipe detection
NEVER ignore the Menu button:
// ❌ User trapped in screen
struct PlayerView: View {
var body: some View {
VideoPlayer(player: player)
// No way to exit!
}
}
// ✅ Always handle exit
struct PlayerView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
VideoPlayer(player: player)
.onExitCommand {
dismiss()
}
}
}
UI Layout
NEVER use iOS-sized touch targets:
// ❌ Way too small for TV
Button("Play") { }
.frame(width: 44, height: 44) // iOS size
// ✅ TV-appropriate sizes (minimum 80pt, prefer 100+)
Button("Play") { }
.frame(minWidth: 200, minHeight: 80)
.buttonStyle(.card)
NEVER use small text on TV:
// ❌ Unreadable from 10 feet
Text("Description")
.font(.caption) // ~11pt — can't read!
Text("Details")
.font(.footnote) // ~13pt — still too small
// ✅ Use legible sizes
Text("Description")
.font(.title3) // Minimum for body text
Text("Title")
.font(.system(size: 48, weight: .bold)) // Hero content
NEVER forget edge insets:
// ❌ Content touches screen edges
ScrollView {
LazyVGrid(columns: columns) {
// Content starts at edge — looks cramped
}
}
// ✅ Use proper TV margins (40-60pt minimum)
ScrollView {
LazyVGrid(columns: columns) {
ForEach(items) { item in
ItemView(item: item)
}
}
.padding(60) // TV safe area
}
Top Shelf
NEVER use low-resolution images:
// ❌ Blurry on 4K TV
item.setImageURL(thumbnailURL, for: .screenScale1x) // Only 1x
// ✅ Provide all resolutions
item.setImageURL(url1x, for: .screenScale1x)
item.setImageURL(url2x, for: .screenScale2x) // Essential for Retina
NEVER return empty Top Shelf for logged-out users:
// ❌ Wasted promotional space
override func loadTopShelfContent(completionHandler: @escaping (TVTopShelfContent?) -> Void) {
if !isLoggedIn {
completionHandler(nil) // Empty!
return
}
// ...
}
// ✅ Show promotional content
override func loadTopShelfContent(completionHandler: @escaping (TVTopShelfContent?) -> Void) {
if !isLoggedIn {
completionHandler(createPromotionalContent()) // Drive engagement
return
}
// ...
}
Essential Patterns
Focus-Aware Card Component
struct TVCard<Content: View>: View {
@Environment(\.isFocused) private var isFocused
let content: () -> Content
var body: some View {
content()
.scaleEffect(isFocused ? 1.1 : 1.0)
.shadow(
color: .black.opacity(isFocused ? 0.3 : 0),
radius: isFocused ? 20 : 0,
y: isFocused ? 10 : 0
)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isFocused)
.focusable()
}
}
// Usage
TVCard {
VStack {
AsyncImage(url: movie.posterURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle().fill(Color.gray.opacity(0.3))
}
.frame(width: 300, height: 450)
.cornerRadius(12)
Text(movie.title)
.font(.headline)
}
}
Focus Section Navigation
struct TVHomeView: View {
var body: some View {
ScrollView {
VStack(spacing: 40) {
// Hero section — gets initial focus
heroSection
.focusSection()
// Continue watching — separate focus group
sectionView(title: "Continue Watching", items: continueWatching)
.focusSection()
// Recommendations — separate focus group
sectionView(title: "For You", items: recommendations)
.focusSection()
}
}
}
private func sectionView(title: String, items: [Movie]) -> some View {
VStack(alignment: .leading, spacing: 16) {
Text(title)
.font(.title2)
.padding(.leading, 60)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 30) {
ForEach(items) { item in
TVCard {
MoviePoster(movie: item)
}
}
}
.padding(.horizontal, 60)
}
}
}
}
Top Shelf Provider
class ContentProvider: TVTopShelfContentProvider {
override func loadTopShelfContent() async -> TVTopShelfContent? {
do {
let items = try await fetchContent()
return TVTopShelfCarouselContent(style: .actions, items: items)
} catch {
return createFallbackContent()
}
}
private func fetchContent() async throws -> [TVTopShelfCarouselItem] {
let movies = try await API.fetchFeatured()
return movies.prefix(10).map { movie in
let item = TVTopShelfCarouselItem(identifier: movie.id)
item.setImageURL(movie.posterURL(size: .x1), for: .screenScale1x)
item.setImageURL(movie.posterURL(size: .x2), for: .screenScale2x)
item.title = movie.title
item.subtitle = "\(movie.year) • \(movie.genre)"
// Deep links
item.displayAction = TVTopShelfAction(url: URL(string: "myapp://movie/\(movie.id)")!)
item.playAction = TVTopShelfAction(url: URL(string: "myapp://play/\(movie.id)")!)
return item
}
}
}
Quick Reference
Focus Modifiers
| Modifier | Purpose |
|---|---|
| .focusable() | Make view focusable |
| .focused($state, equals:) | Bind to FocusState |
| .focusSection() | Group related focusable items |
| .prefersDefaultFocus() | Set preferred initial focus |
| .onExitCommand | Handle Menu button |
| .onPlayPauseCommand | Handle Play/Pause |
| .onMoveCommand | Handle directional input |
TV Size Guidelines
| Element | Minimum Size |
|---|---|
| Button/Card | 200 × 80 pt |
| Poster card | 300 × 450 pt |
| Hero banner | Full width × 800 pt |
| Body text | title3 (20pt) |
| Title text | title (28pt) |
| Edge padding | 60 pt |
Top Shelf Content Types
| Style | Use Case |
|---|---|
| Carousel (.actions) | Featured with play buttons |
| Carousel (.details) | Info-focused items |
| Sectioned | Multiple categories |
| Inset | Banner-style single item |
Red Flags
| Smell | Problem | Fix |
|---|---|---|
| Manual index tracking for focus | Reinventing Focus Engine | Use @FocusState |
| .focusable() without visual change | User can't see focus | Add isFocused styling |
| Slow focus animations (>200ms) | Laggy navigation feel | Use quick transitions |
| 44×44 buttons | Too small for TV | Minimum 200×80 |
| .caption/.footnote text | Unreadable at distance | Use .title3 minimum |
| No .onExitCommand handler | User trapped in screen | Always handle Menu |
| Complex gestures (pinch/rotate) | Not supported by Siri Remote | Use tap/swipe only |
| Empty Top Shelf for logged-out | Wasted promotional space | Show promo content |
Weekly Installs
24
Repository
kaakati/rails-e…rise-devGitHub Stars
6
First Seen
Jan 25, 2026
Security Audits
Installed on
claude-code21
opencode20
codex20
gemini-cli18
github-copilot16
cursor14