mcpField
T
tvos-specific-patterns

tvos-specific-patterns

github.com/kaakati開発1036 回インストール39 週間前に更新
リポジトリ

tvos-specific-patterns について

tvos-specific-patternsは、SwiftUIでtvOSの画面を開発する人向けの判断ガイドです。フォーカス、Siri Remoteの操作、Top Shelfの内容を扱い、システム標準の動作を使う場面も示します。

主な機能

  1. 01フォーカス管理の判断ツリー
  2. 02カスタムフォーカス効果の指針
  3. 03Top Shelfコンテンツの選択
  4. 04Siri Remoteジェスチャーの指針
  5. 05tvOSのサイズと可読性の確認

活用シーン

  1. 01リストやグループ画面のフォーカス動作の選択
  2. 02フォーカス中のカード表示の設計
  3. 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()初期フォーカスの優先項目を設定する
.onExitCommandMenu ボタンを処理する
.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 ではサポートされていないタップ/スワイプのみ使用する
ログアウト中のユーザーに空のトップシェルフプロモーション用のスペースが無駄になるプロモーションコンテンツを表示する

コメント