tvos-specific-patterns について
tvos-specific-patternsは、SwiftUIでtvOSの画面を開発する人向けの判断ガイドです。フォーカス、Siri Remoteの操作、Top Shelfの内容を扱い、システム標準の動作を使う場面も示します。
主な機能
- 01フォーカス管理の判断ツリー
- 02カスタムフォーカス効果の指針
- 03Top Shelfコンテンツの選択
- 04Siri Remoteジェスチャーの指針
- 05tvOSのサイズと可読性の確認
活用シーン
- 01リストやグループ画面のフォーカス動作の選択
- 02フォーカス中のカード表示の設計
- 03注目コンテンツ向けTop Shelfレイアウトの選択
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ kaakati/ rails-enterprise-dev --skill tvos-specific-patternsインストール後、Claude Code で/tvos-specific-patternsを入力して読み込まれたか確認します。
同梱ファイルreferences/complete-tvos-patterns.md
詳細
tvOS 固有のパターン — エキスパート向け判断基準
tvOS に関する選択肢を判断するためのエキスパート向けフレームワークです。Claude は @FocusState と SwiftUI の基本を理解しています。このスキルでは、フォーカス設計、リモコン操作、テレビ向け UI のトレードオフに関する判断を扱います。
判断ツリー
フォーカス管理戦略
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
落とし穴:システムのデフォルト動作で問題ないのに、フォーカス動作を上書きしてしまうことです。カスタムのフォーカスロジックを追加すると複雑さが増し、想定どおりのナビゲーションパターンが壊れる可能性があります。
カスタムフォーカスエフェクトの判断
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
トップシェルフのコンテンツ戦略
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 のジェスチャー選択
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
絶対にしてはいけないこと
フォーカス管理
フォーカスエンジンに絶対に逆らわないでください。
// ❌ 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)
}
}
}
}
視覚的なフィードバックなしで .focusable() を絶対に使用しないでください。
// ❌ 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()
}
}
フォーカス変更を遅くアニメーションさせるのは絶対に避けてください。
// ❌ 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)
リモコン操作
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
Menu ボタンを絶対に無視しないでください。
// ❌ 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 レイアウト
iOS サイズのタッチターゲットを絶対に使用しないでください。
// ❌ 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)
テレビで小さな文字を絶対に使用しないでください。
// ❌ 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
エッジインセットを絶対に忘れないでください。
// ❌ 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
}
トップシェルフ
低解像度の画像を絶対に使用しないでください。
// ❌ 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
ログアウト中のユーザーに空のトップシェルフを絶対に返さないでください。
// ❌ 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
}
// ...
}
必須パターン
フォーカス対応カードコンポーネント
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)
}
}
フォーカスセクションのナビゲーション
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)
}
}
}
}
トップシェルフプロバイダー
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
}
}
}
クイックリファレンス
フォーカス修飾子
| 修飾子 | 目的 |
|---|---|
| .focusable() | ビューをフォーカス可能にする |
| .focused($state, equals:) | FocusState にバインドする |
| .focusSection() | 関連するフォーカス可能な項目をグループ化する |
| .prefersDefaultFocus() | 初期フォーカスの優先項目を設定する |
| .onExitCommand | Menu ボタンを処理する |
| .onPlayPauseCommand | 再生/一時停止を処理する |
| .onMoveCommand | 方向入力を処理する |
テレビ向けサイズのガイドライン
| 要素 | 最小サイズ |
|---|---|
| ボタン/カード | 200 × 80 pt |
| ポスターカード | 300 × 450 pt |
| ヒーローバナー | 全幅 × 800 pt |
| 本文 | title3(20pt) |
| タイトル | title(28pt) |
| エッジのパディング | 60 pt |
トップシェルフのコンテンツタイプ
| スタイル | 用途 |
|---|---|
| カルーセル(.actions) | 再生ボタン付きの注目コンテンツ |
| カルーセル(.details) | 情報を重視した項目 |
| セクション分け | 複数のカテゴリ |
| インセット | バナー形式の単一項目 |
要注意事項
| 問題の兆候 | 問題 | 修正方法 |
|---|---|---|
| フォーカスを手動でインデックス管理する | フォーカスエンジンを再実装している | @FocusState を使用する |
| フォーカス時に視覚的な変化がない .focusable() | ユーザーがフォーカス位置を確認できない | isFocused のスタイルを追加する |
| 遅いフォーカスアニメーション(>200ms) | ナビゲーションが遅く感じられる | 素早いトランジションを使用する |
| 44×44 ボタン | テレビには小さすぎる | 最小 200×80 を使用する |
| .caption/.footnote のテキスト | 離れた位置から読めない | 最小でも .title3 を使用する |
| .onExitCommand のハンドラーがない | ユーザーが画面から抜け出せなくなる | 必ず Menu を処理する |
| 複雑なジェスチャー(ピンチ/回転) | Siri Remote ではサポートされていない | タップ/スワイプのみ使用する |
| ログアウト中のユーザーに空のトップシェルフ | プロモーション用のスペースが無駄になる | プロモーションコンテンツを表示する |