关于 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
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