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
Top Shelf 内容策略
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

绝对不要这样做

焦点管理

绝不要对抗 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)
            }
        }
    }
}

绝不要在没有视觉反馈的情况下使用 .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
}
Top Shelf

绝不要使用低分辨率图片:

// ❌ 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

绝不要向已登出的用户返回空的 Top Shelf:

// ❌ 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)
            }
        }
    }
}
Top Shelf 提供程序
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
Top Shelf 内容类型
样式使用场景
轮播(.actions)带播放按钮的精选内容
轮播(.details)以信息为主的项目
分区多个类别
内嵌横幅式单个项目
危险信号
问题迹象问题修复方法
手动跟踪焦点索引重造 Focus Engine使用 @FocusState
.focusable() 没有视觉变化用户看不到焦点添加 isFocused 样式
焦点动画缓慢(>200ms)导航感觉迟滞使用快速过渡
44×44 按钮对电视来说太小最小尺寸为 200×80
.caption/.footnote 文本远距离难以阅读至少使用 .title3
没有 .onExitCommand 处理程序用户被困在屏幕中始终处理 Menu
复杂手势(捏合/旋转)Siri Remote 不支持仅使用点按/轻扫
已登出用户看到空的 Top Shelf浪费了推广空间显示推广内容

评论

支持 Markdown