重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-tvostvOS 与 iOS 共享 UIKit 和 SwiftUI,但在关键方面存在差异,这会让每位 iOS 开发者措手不及。三个最危险的假设是:(1) 本地文件持久化,(2) WebView 存在,(3) 焦点系统的工作方式与 @FocusState 类似。
核心原则 tvOS 并非“电视上的 iOS”。它拥有双重焦点系统、没有持久化的本地存储、没有 WebView,并且有两代不兼容的遥控器。请将其视为一个独立的平台。
tvOS 26 采用了 Liquid Glass 设计语言和新的应用图标系统。实现模式请参考 axiom-liquid-glass。
在发布 tvOS 移植版本之前,请验证以下五个方面——它们占 tvOS 特有错误的 90%:
| 领域 | 检查点 | 章节 |
|---|---|---|
| 存储 | 没有持久化的本地文件——必须使用 iCloud | §3 |
| 焦点 | 双重系统正常工作,为间隙添加焦点引导 | §1 |
| WebView | 替换为 JavaScriptCore 或原生渲染 | §4 |
| 文本输入 | 处理影子输入或全屏键盘 | §6 |
| AVPlayer | 音频会话、缓冲区、菜单按钮状态机 |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| §7, §8 |
“它在 tvOS 上能编译”毫无意义。这五个领域编译时一切正常,但会在运行时失败。
以下是开发者提出的真实问题,本技能可以解答:
-> 本技能解释了双重焦点系统(UIKit Focus Engine 与 @FocusState)以及常见的陷阱。
-> 本技能解释了 tvOS 没有持久化的本地存储,并展示了 iCloud 优先的模式。
-> 本技能涵盖了两代遥控器以及三个输入层(SwiftUI、UIKit 手势、GameController)。
-> 本技能展示了用于解析的 JavaScriptCore 以及原生渲染的替代方案。
如果出现任何以下情况,请立即停止:
tvOS 有两个必须共存的焦点系统。这是 iOS 开发者最困惑的来源。
| 系统 | 控制对象 | API |
|---|---|---|
| UIKit Focus Engine | 硬件遥控器导航、方向扫描 | UIFocusEnvironment, UIFocusSystem, UIFocusGuide |
| SwiftUI Focus | 程序化焦点绑定、焦点区域 | @FocusState, .focused(), .focusable(), .focusSection() |
用户滑动遥控器 → UIKit Focus Engine 处理(总是如此)
代码设置 @FocusState → SwiftUI 处理(有时会被 Focus Engine 覆盖)
陷阱:@FocusState 可以以编程方式设置焦点,但 UIKit Focus Engine 拥有最终决定权。如果 Focus Engine 认为某个视图不可聚焦,@FocusState 的赋值会被静默忽略。
UIFocusEnvironment 协议(由 UIView、UIViewController、UIWindow 实现)提供:
class MyViewController: UIViewController {
// 焦点应该去哪里的优先级列表
override var preferredFocusEnvironments: [UIFocusEnvironment] {
[preferredButton, fallbackButton]
}
// 验证提议的焦点变更
override func shouldUpdateFocus(
in context: UIFocusUpdateContext
) -> Bool {
// 返回 false 以阻止焦点移动
return context.nextFocusedView != disabledButton
}
// 响应已完成的焦点变更
override func didUpdateFocus(
in context: UIFocusUpdateContext,
with coordinator: UIFocusAnimationCoordinator
) {
coordinator.addCoordinatedAnimations {
context.nextFocusedView?.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
context.previouslyFocusedView?.transform = .identity
}
}
// 请求焦点更新(异步)
func moveFocusToPreferred() {
setNeedsFocusUpdate() // 安排更新
updateFocusIfNeeded() // 立即执行
}
}
当可聚焦的视图不在直接的网格布局中时,Focus Engine 无法通过方向扫描找到它们。UIFocusGuide 创建不可见的可聚焦区域,将焦点重定向到真实的视图:
let focusGuide = UIFocusGuide()
view.addLayoutGuide(focusGuide)
// 将引导放置在两个不相邻的视图之间
NSLayoutConstraint.activate([
focusGuide.leadingAnchor.constraint(equalTo: leftButton.trailingAnchor),
focusGuide.trailingAnchor.constraint(equalTo: rightButton.leadingAnchor),
focusGuide.topAnchor.constraint(equalTo: leftButton.topAnchor),
focusGuide.heightAnchor.constraint(equalTo: leftButton.heightAnchor)
])
// 当焦点进入引导时,重定向到目标视图
focusGuide.preferredFocusEnvironments = [rightButton]
struct ContentView: View {
@FocusState private var focusedItem: MenuItem?
var body: some View {
VStack {
ForEach(MenuItem.allCases) { item in
Button(item.title) { select(item) }
.focused($focusedItem, equals: item)
}
}
.focusSection() // 为导航分组可聚焦项
.defaultFocus($focusedItem, .home) // 设置初始焦点
}
}
tvOS 的关键 SwiftUI 焦点修饰符:
.focused(_:equals:) —— 将焦点绑定到一个值.focusable() —— 使自定义视图可聚焦.focusSection() —— 为方向导航分组相关项.defaultFocus(_:_:) —— 设置焦点在某个范围内的起始位置UIButton、UITextField、UITableViewCell 和 UICollectionViewCell 默认是可聚焦的。自定义视图需要 canBecomeFocused(UIKit)或 .focusable()(SwiftUI)。左上角的元素在启动时获得初始焦点。
| 陷阱 | 症状 | 修复方法 |
|---|---|---|
| 不可聚焦的容器 | 滑动跳过你的视图 | 添加 .focusable() 或重写 canBecomeFocused |
| 缺少焦点引导 | 无法导航到孤立的视图 | 添加 UIFocusGuide 来桥接间隙 |
| @FocusState 被忽略 | 程序化焦点不起作用 | 检查 preferredFocusEnvironments 链 |
| 未请求焦点更新 | 布局变更后焦点保持陈旧 | 调用 setNeedsFocusUpdate() + updateFocusIfNeeded() |
| 项目不在网格布局中 | 焦点不可预测地跳转 | 将可聚焦项排列成网格或使用焦点引导 |
| UIHostingConfiguration 焦点 | 混合 UIKit/SwiftUI 中的焦点损坏 | 已知问题——仔细测试 UIHostingConfiguration 单元格 |
两代硬件不同——你的代码必须同时处理两者。
| 特性 | 第一代 (2015-2021) | 第二代 (2021+) |
|---|---|---|
| 顶部表面 | 触摸板(全区域滑动) | 点击板 + 外圈触摸环 |
| 滑动手势 | 全区域 | 仅限环边缘 |
| 点击导航 | 中心按压 | D-pad 风格 |
| 加速度计 | 有 | 有 |
对于大多数 UI,SwiftUI 通过焦点系统自动处理遥控器输入:
Button("播放") { startPlayback() }
.focused($isFocused) // 自动响应遥控器导航
List(items) { item in
Text(item.title)
}
// 列表导航自动与遥控器配合
// 注意:在 tvOS 上,默认第一个项目获得焦点——使用 .defaultFocus() 来覆盖
通过 UIKit 识别器检测特定的按钮按压和手势:
// 检测播放/暂停按钮
let playPause = UITapGestureRecognizer(target: self, action: #selector(handlePlayPause))
playPause.allowedPressTypes = [NSNumber(value: UIPress.PressType.playPause.rawValue)]
view.addGestureRecognizer(playPause)
// 检测触摸板上的滑动
let swipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe))
swipe.direction = .right
view.addGestureRecognizer(swipe)
可用的 UIPress.PressType 值:.menu, .playPause, .select, .upArrow, .downArrow, .leftArrow, .rightArrow, .pageUp, .pageDown
为了进行细粒度控制,重写 UIResponder 的按压方法:
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectDown()
}
}
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectUp()
}
}
}
// 始终实现所有四个方法:pressesBegan, pressesEnded, pressesChanged, pressesCancelled
对于自定义交互(擦洗、游戏),将 Siri Remote 作为 GCMicroGamepad 访问:
import GameController
NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect, object: nil, queue: .main
) { notification in
guard let controller = notification.object as? GCController,
let micro = controller.microGamepad else { return }
// 触摸板作为模拟 D-pad(-1.0 到 1.0)
micro.dpad.valueChangedHandler = { _, xValue, yValue in
handleRemoteInput(x: xValue, y: yValue)
}
// reportsAbsoluteDpadValues: true = 绝对位置,false = 相对移动
micro.reportsAbsoluteDpadValues = false
// allowsRotation: true = 当遥控器旋转时数值会调整
micro.allowsRotation = false
// 面部按钮
micro.buttonA.pressedChangedHandler = { _, _, pressed in }
micro.buttonX.pressedChangedHandler = { _, _, pressed in }
micro.buttonMenu.pressedChangedHandler = { _, _, pressed in }
}
使用 UIPanGestureRecognizer 和虚拟阻尼实现平滑搜索:
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
let velocity = gesture.velocity(in: view)
let dampingFactor: CGFloat = 0.002 // 根据手感调整
switch gesture.state {
case .changed:
let seekDelta = velocity.x * dampingFactor
player.seek(to: currentTime + seekDelta)
default:
break
}
}
这是 iOS 在 tvOS 上最危险的假设。 tvOS 没有 Documents 目录。所有本地存储都是缓存,系统可以随时删除。跳过 iCloud 集成意味着需要花费 2-3 周调试仅在真实设备上、应用两次启动之间发生的间歇性“数据消失”错误。
来自 Apple 的 tvOS 应用编程指南:“为新的 Apple TV 开发的每个应用必须能够将数据存储在 iCloud 中并以提供出色客户体验的方式检索它。”
| 目录 | 存在? | 持久化? |
|---|---|---|
| Documents | 否 | 不适用 |
| Application Support | 是 | 否——系统可以在应用不运行时删除 |
| Caches | 是 | 否——系统在存储压力下删除 |
| tmp | 是 | 否 |
// ✅ 正确:iCloud 作为主存储,本地仅作为缓存
func loadData() async throws -> [Item] {
// 1. 首先尝试 iCloud(持久化)
if let cloudData = try? await fetchFromICloud() {
// 本地缓存以供离线使用
try? cacheLocally(cloudData)
return cloudData
}
// 2. 回退到本地缓存(可能不存在)
if let cached = try? loadFromLocalCache() {
return cached
}
// 3. 从头开始——这在 tvOS 上是正常的
return []
}
| 解决方案 | tvOS 可行性 | 备注 |
|---|---|---|
| SQLiteData + CloudKit SyncEngine | 推荐 | iCloud 是持久化的;本地只是缓存 |
| SwiftData + CloudKit | 可用,但脆弱 | 没有持久化的仅本地存储;ModelContainer 必须从一开始就为 CloudKit 配置——后续添加同步需要迁移;系统数据库删除会在下次启动时触发完全重新同步 |
| CoreData + CloudKit | 危险 | CloudKit 元数据导致空间膨胀 |
| 仅本地 GRDB/SQLite | 不可靠 | 系统删除数据库文件 |
| NSUbiquitousKeyValueStore | 适合小数据 | 1 MB 限制,仅键值对 |
| 按需资源 | 适合只读资源 | 操作系统管理下载/清理生命周期 |
参见 axiom-sqlitedata 了解 CloudKit SyncEngine 模式,axiom-storage 了解完整的存储决策树。
tvOS 没有 WKWebView,没有 SFSafariViewController,没有 WebView。Apple HIG 明确指出:网页视图“tvOS 不支持”。
| 需求 | 解决方案 |
|---|---|
| 解析 HTML/JSON | 使用 JavaScriptCore(JSContext,JSValue——无 DOM) |
| 显示网页内容 | 从解析的数据进行原生渲染 |
| 从 m3u8 进行 HLS 流媒体 | 本地 HTTP 服务器模式(见下文) |
| OAuth 登录 | 设备代码流(RFC 8628)或配套设备 |
JavaScriptCore 提供了一个没有 DOM 或网页渲染的 JavaScript 执行引擎。在 tvOS 上可用。
import JavaScriptCore
let context = JSContext()!
// 评估脚本
context.evaluateScript("""
function parsePlaylist(m3u8Text) {
return m3u8Text.split('\\n')
.filter(line => !line.startsWith('#'))
.filter(line => line.trim().length > 0);
}
""")
// 通过 setObject 安全地传递数据(避免注入)
context.setObject(m3u8Content, forKeyedSubscript: "rawContent" as NSString)
let result = context.evaluateScript("parsePlaylist(rawContent)")
// 转换回 Swift 类型
let segments = result?.toArray() as? [String] ?? []
关键类:JSVirtualMachine(执行环境)、JSContext(脚本评估)、JSValue(类型桥接)
限制:没有 DOM,没有网页渲染,没有 fetch/XMLHttpRequest。仅限纯 JavaScript 执行。
当你需要向 AVPlayer 提供修改过的 m3u8 播放列表时:
// 使用 Swifter (httpswift/swifter) 或 GCDWebServer
// 在 localhost 上提供重写过的 m3u8,将 AVPlayer 指向它
let localURL = URL(string: "http://localhost:8080/playlist.m3u8")!
let playerItem = AVPlayerItem(url: localURL)
tvOS 独有的 UIKit 组件。通过 UIViewRepresentable 桥接到 SwiftUI。
媒体内容显示,内置焦点扩展和视差效果:
import TVUIKit
let poster = TVPosterView(image: UIImage(named: "moviePoster"))
poster.title = "电影标题"
poster.subtitle = "2024"
// 焦点扩展和视差效果自动发生
// 访问底层图像视图:
poster.imageView.adjustsImageWhenAncestorFocused = true
TVPosterView 的基类——一个管理内容和焦点行为的灵活容器:
let lockup = TVLockupView()
lockup.contentView.addSubview(customView)
lockup.headerView = headerFooter // TVLockupHeaderFooterView
lockup.footerView = footerFooter
// showsOnlyWhenAncestorFocused: 页眉/页脚在获得焦点时可见
| 组件 | 用途 |
|---|---|
| TVCardView | 具有可定制背景的简单容器 |
| TVCaptionButtonView | 带有图像 + 文本 + 方向视差的按钮 |
| TVMonogramView | 带有 PersonNameComponents 的用户首字母/图像 |
| TVCollectionViewFullScreenLayout | 具有视差 + 遮罩效果的沉浸式全屏集合视图 |
| TVMediaItemContentView | 带有徽章、播放进度的内容配置 |
系统提供的密码/PIN 输入(tvOS 12+):
let digitEntry = TVDigitEntryViewController()
digitEntry.numberOfDigits = 4
digitEntry.titleText = "输入 PIN"
digitEntry.promptText = "输入您的家长控制代码"
digitEntry.isSecureDigitEntry = true
present(digitEntry, animated: true)
digitEntry.entryCompletionHandler = { pin in
guard let pin else { return } // 用户取消
authenticate(with: pin)
}
// 重置输入
digitEntry.clearEntry(animated: true)
tvOS 的文本输入与 iOS 有根本性的不同。Apple 建议在你的 UI 中尽量减少文本输入。
| 方法 | 最适合 | 键盘样式 |
|---|---|---|
| UIAlertController | 快速、简单的输入 | 带有文本字段的模态窗口 |
| UITextField | 多字段表单 | 带有下一个/上一个按钮的全屏键盘 |
| UISearchController | 搜索 | 内联单行键盘 |
主要的文本输入方法。调用 becomeFirstResponder() 会呈现全屏键盘:
let textField = UITextField()
textField.placeholder = "输入名称"
textField.becomeFirstResponder() // 立即呈现键盘
// 完成按钮使用户返回到上一页
// 内置的下一个/上一个按钮在文本字段之间导航
当你想在 SwiftUI 中有一个自定义样式的输入触发器时:
struct TVTextInput: View {
@State private var text = ""
@State private var isEditing = false
var body: some View {
Button {
isEditing = true
} label: {
HStack {
Text(text.isEmpty ? "搜索..." : text)
.foregroundStyle(text.isEmpty ? .secondary : .primary)
Spacer()
Image(systemName: "keyboard")
}
.padding()
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.sheet(isPresented: $isEditing) {
TVKeyboardSheet(text: $text)
}
}
}
用于搜索界面——所有输入都在单行上,但自定义选项非常有限:
let searchController = UISearchController(searchResultsController: resultsVC)
searchController.searchResultsUpdater = self
// 无法自定义文本特性或添加输入附件
.searchable()SwiftUI 的 .searchable() 修饰符在 tvOS 上可用,并呈现系统搜索键盘。用于标准搜索模式:
NavigationStack {
List(filteredItems) { item in
Text(item.title)
}
.searchable(text: $searchText, prompt: "搜索电影")
}
对于超出 .searchable() 提供的自定义搜索 UI,请回退到上述的影子输入模式。
tvOS 媒体应用需要特定的 AVPlayer 配置以获得良好的用户体验。
let player = AVPlayer(url: streamURL)
// automaticallyWaitsToMinimizeStalling 默认为 true (iOS 10+/tvOS 10+)
// 当同步播放器时,或当你希望从非空缓冲区尽快开始播放时,设置为 false
player.automaticallyWaitsToMinimizeStalling = false
// 缓冲区提示——0 表示系统自动选择
// 更高的值减少卡顿风险但消耗更多内存
player.currentItem?.preferredForwardBufferDuration = 30
// 音频会话——启动时不要中断其他应用的音频
try AVAudioSession.sharedInstance().setCategory(.ambient)
// 当用户按下播放时切换到 .playback
默认的下滑手势会关闭播放器。为媒体应用重写:
class PlayerViewController: AVPlayerViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 处理菜单按钮以实现自定义返回导航
let menuPress = UITapGestureRecognizer(
target: self, action: #selector(handleMenu)
)
menuPress.allowedPressTypes = [
NSNumber(value: UIPress.PressType.menu.rawValue)
]
view.addGestureRecognizer(menuPress)
}
@objc func handleMenu() {
if isShowingControls {
hideControls()
} else {
dismiss(animated: true)
}
}
}
Siri Remote 的菜单按钮兼具“返回”和“关闭”功能。媒体应用需要一个状态机来正确处理它。
状态:正在播放,控件可见
按下菜单 → 隐藏控件(不关闭)
状态:正在播放,控件隐藏
按下菜单 → 显示“你确定吗?”或关闭
状态:在子菜单/设置覆盖层中
按下菜单 → 关闭覆盖层(不关闭播放器)
enum PlayerState {
case loading // 缓冲 / 加载内容
case playing // 控件隐藏
case controlsShown // 控件可见
case submenu // 设置/字幕覆盖层
}
func handleMenuPress(in state: PlayerState) -> PlayerState {
switch state {
case .submenu:
dismissSubmenu()
return .controlsShown
case .controlsShown:
hideControls()
return .playing
case .playing:
dismiss(animated: true)
return .playing
case .loading:
cancelLoading()
dismiss(animated: true)
return .loading
}
}
Apple TV 强烈偏好 IPv6。所有 App Store 应用必须支持仅 IPv6 网络(DNS64/NAT64)。如果你的后端仅支持 IPv4,在某些网络上连接可能会变慢或失败。
| 设备 | 芯片 | 内存 | 备注 |
|---|---|---|---|
| Apple TV HD (第 4 代) | A8 | 2 GB | 仍受支持;速度慢得多 |
| Apple TV 4K (第 1 代) | A10X | 3 GB | 性能尚可 |
| Apple TV 4K (第 2 代) | A12 | 4 GB | 良好 |
| Apple TV 4K (第 3 代) | A15 | 4 GB | 优秀 |
在旧硬件上测试。 Apple TV HD 仍在使用,且速度远低于 4K 型号。
在模拟器中使用键盘快捷键测试,无需 Siri Remote:
#if DEBUG
extension View {
func debugOnlyModifier() -> some View {
self.onKeyPress(.space) {
print("按下空格——模拟选择")
return .handled
}
}
}
#endif
#if DEBUG
extension View {
func debugBorder() -> some View {
border(.red, width: 1)
}
}
#endif
| 想法 | 现实 |
|---|---|
| “我就用和 iOS 一样的代码” | tvOS 在存储、焦点、输入和网页视图方面存在差异。你会碰壁。 |
| “焦点系统像 iOS 一样工作” | tvOS 有双重焦点系统(UIKit Focus Engine + SwiftUI @FocusState)。仅靠 @FocusState 是不够的。 |
| “本地存储暂时没问题” | tvOS 上没有持久化的本地存储。Apple 要求具备 iCloud 能力。 |
| “WebView 会起作用” | Apple HIG:网页视图“tvOS 不支持。”仅限 JavaScriptCore(无 DOM)。 |
| “我用 TextField 处理文本输入” | UITextField 会触发全屏键盘。考虑影子输入模式或 UISearchController 以获得更好的用户体验。 |
| “我只需要在模拟器上测试” | Focus Engine 和性能需要在真实设备上测试。 |
来源:“Surviving tvOS”(Ronnie Wong,2026)——Syncnext 媒体播放器的 tvOS 工程日志
Apple 文档:/tvuikit, /uikit/uifocusenvironment, /uikit/uifocusguide, /swiftui/focus, /gamecontroller/gcmicrogamepad, /avfoundation/avplayer, /javascriptcore
Apple 指南:tvOS 应用编程指南(存储、输入、手势),HIG 网页视图(tvOS 排除)
WWDC:2016-215, 2017-224, 2021-10023, 2021-10081, 2021-10191, 2023-10162, 2025-219
技能:axiom-storage, axiom-sqlitedata, axiom-avfoundation-ref, axiom-hig-ref, axiom-liquid-glass
每周安装数
29
仓库
GitHub 星标数
590
首次出现
14 天前
安全审计
安装于
codex28
opencode27
github-copilot27
kimi-cli27
gemini-cli27
amp27
tvOS shares UIKit and SwiftUI with iOS but diverges in critical ways that catch every iOS developer. The three most dangerous assumptions: (1) local files persist, (2) WebView exists, (3) focus works like @FocusState.
Core principle tvOS is not "iOS on TV." It has a dual focus system, no persistent local storage, no WebView, and a remote with two incompatible generations. Treat it as its own platform.
tvOS 26 Adopts Liquid Glass design language with new app icon system. See axiom-liquid-glass for implementation patterns.
Before shipping a tvOS port, verify these five areas — they account for 90% of tvOS-specific bugs:
| Area | Check | Section |
|---|---|---|
| Storage | No persistent local files — iCloud required | §3 |
| Focus | Dual system working, focus guides for gaps | §1 |
| WebView | Replaced with JavaScriptCore or native rendering | §4 |
| Text input | Shadow input or fullscreen keyboard handled | §6 |
| AVPlayer | Audio session, buffer, Menu button state machine | §7, §8 |
"It compiles on tvOS" means nothing. These five areas compile fine and fail at runtime.
These are real questions developers ask that this skill answers:
-> The skill explains the dual focus system (UIKit Focus Engine vs @FocusState) and common traps
-> The skill explains there is no persistent local storage and shows the iCloud-first pattern
-> The skill covers both generations of remote and the three input layers (SwiftUI, UIKit gestures, GameController)
-> The skill shows JavaScriptCore for parsing and native rendering alternatives
If ANY of these appear, STOP:
tvOS has two focus systems that must coexist. This is the #1 source of confusion for iOS developers.
| System | Controls | API |
|---|---|---|
| UIKit Focus Engine | Hardware remote navigation, directional scanning | UIFocusEnvironment, UIFocusSystem, UIFocusGuide |
| SwiftUI Focus | Programmatic focus binding, focus sections | @FocusState, .focused(), .focusable(), .focusSection() |
User swipes on remote → UIKit Focus Engine handles it (always)
Code sets @FocusState → SwiftUI handles it (sometimes overridden by Focus Engine)
The trap : @FocusState can set focus programmatically, but the UIKit Focus Engine is the ultimate authority. If the Focus Engine considers a view unfocusable, @FocusState assignments are silently ignored.
The UIFocusEnvironment protocol (implemented by UIView, UIViewController, UIWindow) provides:
class MyViewController: UIViewController {
// Priority-ordered list of where focus should go
override var preferredFocusEnvironments: [UIFocusEnvironment] {
[preferredButton, fallbackButton]
}
// Validate proposed focus changes
override func shouldUpdateFocus(
in context: UIFocusUpdateContext
) -> Bool {
// Return false to block focus movement
return context.nextFocusedView != disabledButton
}
// Respond to completed focus changes
override func didUpdateFocus(
in context: UIFocusUpdateContext,
with coordinator: UIFocusAnimationCoordinator
) {
coordinator.addCoordinatedAnimations {
context.nextFocusedView?.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
context.previouslyFocusedView?.transform = .identity
}
}
// Request focus update (async)
func moveFocusToPreferred() {
setNeedsFocusUpdate() // Schedule update
updateFocusIfNeeded() // Execute immediately
}
}
When focusable views aren't in a direct grid layout, the Focus Engine can't find them by scanning directionally. UIFocusGuide creates invisible focusable regions that redirect to real views:
let focusGuide = UIFocusGuide()
view.addLayoutGuide(focusGuide)
// Position the guide between two non-adjacent views
NSLayoutConstraint.activate([
focusGuide.leadingAnchor.constraint(equalTo: leftButton.trailingAnchor),
focusGuide.trailingAnchor.constraint(equalTo: rightButton.leadingAnchor),
focusGuide.topAnchor.constraint(equalTo: leftButton.topAnchor),
focusGuide.heightAnchor.constraint(equalTo: leftButton.heightAnchor)
])
// When focus enters the guide, redirect to the target view
focusGuide.preferredFocusEnvironments = [rightButton]
struct ContentView: View {
@FocusState private var focusedItem: MenuItem?
var body: some View {
VStack {
ForEach(MenuItem.allCases) { item in
Button(item.title) { select(item) }
.focused($focusedItem, equals: item)
}
}
.focusSection() // Group focusable items for navigation
.defaultFocus($focusedItem, .home) // Set initial focus
}
}
Key SwiftUI focus modifiers for tvOS :
.focused(_:equals:) — Bind focus to a value.focusable() — Make custom views focusable.focusSection() — Group related items for directional navigation.defaultFocus(_:_:) — Set where focus starts in a scopeUIButton, UITextField, UITableViewCell, and UICollectionViewCell are focusable by default. Custom views need canBecomeFocused (UIKit) or .focusable() (SwiftUI). The top-left item receives initial focus at launch.
| Gotcha | Symptom | Fix |
|---|---|---|
| Non-focusable container | Swipe skips your view | Add .focusable() or override canBecomeFocused |
| Focus guide missing | Can't navigate to isolated view | Add UIFocusGuide to bridge the gap |
| @FocusState ignored | Programmatic focus doesn't work | Check preferredFocusEnvironments chain |
| Focus update not requested | Focus stays stale after layout change | Call setNeedsFocusUpdate() + updateFocusIfNeeded() |
| Items not in grid layout | Focus jumps unpredictably | Arrange focusable items in a grid or use focus guides |
| UIHostingConfiguration focus | Focus corruption in mixed UIKit/SwiftUI | Known issue — test UIHostingConfiguration cells carefully |
Two generations with different hardware — your code must handle both.
| Feature | Gen 1 (2015-2021) | Gen 2 (2021+) |
|---|---|---|
| Top surface | Touchpad (full swipe) | Clickpad + outer touch ring |
| Swipe gestures | Full area | Ring edge only |
| Click navigation | Center press | D-pad style |
| Accelerometer | Yes | Yes |
For most UI, SwiftUI handles remote input automatically through the focus system:
Button("Play") { startPlayback() }
.focused($isFocused) // Automatically responds to remote navigation
List(items) { item in
Text(item.title)
}
// List navigation works automatically with remote
// Note: First item receives focus by default on tvOS — use .defaultFocus() to override
Detect specific button presses and gestures via UIKit recognizers:
// Detect Play/Pause button
let playPause = UITapGestureRecognizer(target: self, action: #selector(handlePlayPause))
playPause.allowedPressTypes = [NSNumber(value: UIPress.PressType.playPause.rawValue)]
view.addGestureRecognizer(playPause)
// Detect swipe on touchpad
let swipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe))
swipe.direction = .right
view.addGestureRecognizer(swipe)
Available UIPress.PressType values : .menu, .playPause, .select, .upArrow, .downArrow, .leftArrow, .rightArrow, .pageUp, .pageDown
For fine-grained control, override UIResponder press methods:
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectDown()
}
}
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectUp()
}
}
}
// Always implement all four: pressesBegan, pressesEnded, pressesChanged, pressesCancelled
For custom interactions (scrubbing, games), access the Siri Remote as a GCMicroGamepad:
import GameController
NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect, object: nil, queue: .main
) { notification in
guard let controller = notification.object as? GCController,
let micro = controller.microGamepad else { return }
// Touchpad as analog D-pad (-1.0 to 1.0)
micro.dpad.valueChangedHandler = { _, xValue, yValue in
handleRemoteInput(x: xValue, y: yValue)
}
// reportsAbsoluteDpadValues: true = absolute position, false = relative movement
micro.reportsAbsoluteDpadValues = false
// allowsRotation: true = values adjust when remote is rotated
micro.allowsRotation = false
// Face buttons
micro.buttonA.pressedChangedHandler = { _, _, pressed in }
micro.buttonX.pressedChangedHandler = { _, _, pressed in }
micro.buttonMenu.pressedChangedHandler = { _, _, pressed in }
}
UIPanGestureRecognizer with virtual damping for smooth seeking:
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
let velocity = gesture.velocity(in: view)
let dampingFactor: CGFloat = 0.002 // Tune for feel
switch gesture.state {
case .changed:
let seekDelta = velocity.x * dampingFactor
player.seek(to: currentTime + seekDelta)
default:
break
}
}
This is the most dangerous iOS assumption on tvOS. tvOS has no Document directory. All local storage is Cache that the system can delete at any time. Skipping iCloud integration means 2-3 weeks debugging intermittent "data disappears" bugs that only happen on real devices between app launches.
From Apple's App Programming Guide for tvOS: "Every app developed for the new Apple TV must be able to store data in iCloud and retrieve it in a way that provides a great customer experience."
| Directory | Exists? | Persistent? |
|---|---|---|
| Documents | No | N/A |
| Application Support | Yes | No — system can delete when app is not running |
| Caches | Yes | No — system deletes under storage pressure |
| tmp | Yes | No |
// ✅ CORRECT: iCloud as primary, local as cache only
func loadData() async throws -> [Item] {
// 1. Try iCloud first (persistent)
if let cloudData = try? await fetchFromICloud() {
// Cache locally for offline use
try? cacheLocally(cloudData)
return cloudData
}
// 2. Fall back to local cache (may not exist)
if let cached = try? loadFromLocalCache() {
return cached
}
// 3. Start fresh — this is normal on tvOS
return []
}
| Solution | tvOS Viability | Notes |
|---|---|---|
| SQLiteData + CloudKit SyncEngine | Recommended | iCloud is persistent; local is just cache |
| SwiftData + CloudKit | Works, but fragile | No persistent local-only storage; ModelContainer must be configured for CloudKit from day one — adding sync later requires migration; system database deletion triggers full re-sync on next launch |
| CoreData + CloudKit | Dangerous | Space inflation from CloudKit metadata |
| Local-only GRDB/SQLite | Unreliable | System deletes the database file |
| NSUbiquitousKeyValueStore | Good for small data | 1 MB limit, key-value only |
| On-demand resources | Good for read-only assets | OS manages download/purge lifecycle |
See axiom-sqlitedata for CloudKit SyncEngine patterns, axiom-storage for full storage decision tree.
tvOS has no WKWebView, no SFSafariViewController, no WebView. Apple HIG explicitly states: web views are "Not supported in tvOS."
| Need | Solution |
|---|---|
| Parse HTML/JSON | Use JavaScriptCore (JSContext, JSValue — no DOM) |
| Display web content | Render natively from parsed data |
| HLS streaming from m3u8 | Local HTTP server pattern (see below) |
| OAuth login | Device code flow (RFC 8628) or companion device |
JavaScriptCore provides a JavaScript execution engine without DOM or web rendering. Available on tvOS.
import JavaScriptCore
let context = JSContext()!
// Evaluate scripts
context.evaluateScript("""
function parsePlaylist(m3u8Text) {
return m3u8Text.split('\\n')
.filter(line => !line.startsWith('#'))
.filter(line => line.trim().length > 0);
}
""")
// Pass data safely via setObject (avoids injection)
context.setObject(m3u8Content, forKeyedSubscript: "rawContent" as NSString)
let result = context.evaluateScript("parsePlaylist(rawContent)")
// Convert back to Swift types
let segments = result?.toArray() as? [String] ?? []
Key classes : JSVirtualMachine (execution environment), JSContext (script evaluation), JSValue (type bridging)
Limitation : No DOM, no web rendering, no fetch/XMLHttpRequest. Pure JavaScript execution only.
When you need to serve modified m3u8 playlists to AVPlayer:
// Use Swifter (httpswift/swifter) or GCDWebServer
// Serve rewritten m3u8 on localhost, point AVPlayer to it
let localURL = URL(string: "http://localhost:8080/playlist.m3u8")!
let playerItem = AVPlayerItem(url: localURL)
tvOS-exclusive UIKit components. Bridge to SwiftUI via UIViewRepresentable.
Media content display with built-in focus expansion and parallax:
import TVUIKit
let poster = TVPosterView(image: UIImage(named: "moviePoster"))
poster.title = "Movie Title"
poster.subtitle = "2024"
// Focus expansion and parallax happen automatically
// Access the underlying image view:
poster.imageView.adjustsImageWhenAncestorFocused = true
Base class for TVPosterView — a flexible container managing content with focus behavior:
let lockup = TVLockupView()
lockup.contentView.addSubview(customView)
lockup.headerView = headerFooter // TVLockupHeaderFooterView
lockup.footerView = footerFooter
// showsOnlyWhenAncestorFocused: header/footer visibility on focus
| Component | Purpose |
|---|---|
| TVCardView | Simple container with customizable background |
| TVCaptionButtonView | Button with image + text + directional parallax |
| TVMonogramView | User initials/image with PersonNameComponents |
| TVCollectionViewFullScreenLayout | Immersive full-screen collection with parallax + masking |
| TVMediaItemContentView | Content configuration with badges, playback progress |
System-provided passcode/PIN entry (tvOS 12+):
let digitEntry = TVDigitEntryViewController()
digitEntry.numberOfDigits = 4
digitEntry.titleText = "Enter PIN"
digitEntry.promptText = "Enter your parental control code"
digitEntry.isSecureDigitEntry = true
present(digitEntry, animated: true)
digitEntry.entryCompletionHandler = { pin in
guard let pin else { return } // User cancelled
authenticate(with: pin)
}
// Reset entry
digitEntry.clearEntry(animated: true)
tvOS text input is fundamentally different from iOS. Apple recommends minimizing text input in your UI.
| Approach | Best For | Keyboard Style |
|---|---|---|
| UIAlertController | Quick, simple input | Modal with text field |
| UITextField | Multi-field forms | Fullscreen keyboard with Next/Previous |
| UISearchController | Search | Inline single-line keyboard |
The primary text input method. Calling becomeFirstResponder() presents a fullscreen keyboard:
let textField = UITextField()
textField.placeholder = "Enter name"
textField.becomeFirstResponder() // Presents keyboard immediately
// Done button returns user to previous page
// Built-in Next/Previous buttons navigate between text fields
When you want a custom-styled input trigger in SwiftUI:
struct TVTextInput: View {
@State private var text = ""
@State private var isEditing = false
var body: some View {
Button {
isEditing = true
} label: {
HStack {
Text(text.isEmpty ? "Search..." : text)
.foregroundStyle(text.isEmpty ? .secondary : .primary)
Spacer()
Image(systemName: "keyboard")
}
.padding()
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.sheet(isPresented: $isEditing) {
TVKeyboardSheet(text: $text)
}
}
}
For search interfaces — all input on a single line, but very limited customization:
let searchController = UISearchController(searchResultsController: resultsVC)
searchController.searchResultsUpdater = self
// Cannot customize text traits or add input accessories
.searchable()SwiftUI's .searchable() modifier works on tvOS and presents the system search keyboard. Use it for standard search patterns:
NavigationStack {
List(filteredItems) { item in
Text(item.title)
}
.searchable(text: $searchText, prompt: "Search movies")
}
For custom search UI beyond what .searchable() offers, fall back to the shadow input pattern above.
tvOS media apps need specific AVPlayer configuration for good UX.
let player = AVPlayer(url: streamURL)
// automaticallyWaitsToMinimizeStalling defaults to true (iOS 10+/tvOS 10+)
// Set false for immediate playback when synchronizing players
// or when you want playback to start ASAP from a non-empty buffer
player.automaticallyWaitsToMinimizeStalling = false
// Buffer hint — 0 means system chooses automatically
// Higher values reduce stalling risk but consume more memory
player.currentItem?.preferredForwardBufferDuration = 30
// Audio session — don't interrupt other apps' audio on launch
try AVAudioSession.sharedInstance().setCategory(.ambient)
// Switch to .playback when user presses play
The default swipe-down gesture dismisses the player. Override for media apps:
class PlayerViewController: AVPlayerViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Handle Menu button for custom back navigation
let menuPress = UITapGestureRecognizer(
target: self, action: #selector(handleMenu)
)
menuPress.allowedPressTypes = [
NSNumber(value: UIPress.PressType.menu.rawValue)
]
view.addGestureRecognizer(menuPress)
}
@objc func handleMenu() {
if isShowingControls {
hideControls()
} else {
dismiss(animated: true)
}
}
}
The Siri Remote Menu button doubles as "back" and "dismiss." Media apps need a state machine to handle it correctly.
State: Playing with controls visible
Menu press → Hide controls (not dismiss)
State: Playing with controls hidden
Menu press → Show "are you sure?" or dismiss
State: In submenu/settings overlay
Menu press → Close overlay (not dismiss player)
enum PlayerState {
case loading // Buffering / loading content
case playing // Controls hidden
case controlsShown // Controls visible
case submenu // Settings/subtitles overlay
}
func handleMenuPress(in state: PlayerState) -> PlayerState {
switch state {
case .submenu:
dismissSubmenu()
return .controlsShown
case .controlsShown:
hideControls()
return .playing
case .playing:
dismiss(animated: true)
return .playing
case .loading:
cancelLoading()
dismiss(animated: true)
return .loading
}
}
Apple TV strongly prefers IPv6. All App Store apps must support IPv6-only networks (DNS64/NAT64). If your backend is IPv4-only, connections may be slower or fail on some networks.
| Device | Chip | RAM | Notes |
|---|---|---|---|
| Apple TV HD (4th gen) | A8 | 2 GB | Still supported; much slower |
| Apple TV 4K (1st gen) | A10X | 3 GB | Capable |
| Apple TV 4K (2nd gen) | A12 | 4 GB | Good |
| Apple TV 4K (3rd gen) | A15 | 4 GB | Excellent |
Test on older hardware. The Apple TV HD is still in use and dramatically slower than 4K models.
Test without Siri Remote in Simulator using keyboard shortcuts:
#if DEBUG
extension View {
func debugOnlyModifier() -> some View {
self.onKeyPress(.space) {
print("Space pressed — simulating select")
return .handled
}
}
}
#endif
#if DEBUG
extension View {
func debugBorder() -> some View {
border(.red, width: 1)
}
}
#endif
| Thought | Reality |
|---|---|
| "I'll just use the same code as iOS" | tvOS diverges in storage, focus, input, and web views. You will hit walls. |
| "Focus works like iOS" | tvOS has a dual focus system (UIKit Focus Engine + SwiftUI @FocusState). @FocusState alone is insufficient. |
| "Local storage is fine for now" | There is no persistent local storage on tvOS. Apple requires iCloud capability. |
| "WebView will work" | Apple HIG: web views are "Not supported in tvOS." JavaScriptCore only (no DOM). |
| "I'll handle text input with TextField" | UITextField triggers a fullscreen keyboard. Consider shadow input pattern or UISearchController for better UX. |
| "I only need to test on Simulator" | Focus Engine and performance require real device testing. |
Source : "Surviving tvOS" (Ronnie Wong, 2026) — tvOS engineering log for Syncnext media player
Apple Docs : /tvuikit, /uikit/uifocusenvironment, /uikit/uifocusguide, /swiftui/focus, /gamecontroller/gcmicrogamepad, /avfoundation/avplayer, /javascriptcore
Apple Guides : App Programming Guide for tvOS (storage, input, gestures), HIG Web Views (tvOS exclusion)
WWDC : 2016-215, 2017-224, 2021-10023, 2021-10081, 2021-10191, 2023-10162, 2025-219
Skills : axiom-storage, axiom-sqlitedata, axiom-avfoundation-ref, axiom-hig-ref, axiom-liquid-glass
Weekly Installs
29
Repository
GitHub Stars
590
First Seen
14 days ago
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex28
opencode27
github-copilot27
kimi-cli27
gemini-cli27
amp27