WWDC Quick Look 💓 By SwiftGGTeam
Migrate your TVML app to SwiftUI

Migrate your TVML app to SwiftUI

观看原视频

Highlight

tvOS 18 正式弃用 TVMLKit,SwiftUI 现已覆盖其全部能力,一行 .tabViewStyle(.sidebarAdaptable) 即可将 Tab 栏变为浮动侧边栏。


核心内容

2015 年 Apple 发布 tvOS 时,流媒体 app 几乎都是网页实现的。开发团队的技术栈和人力集中在 Web 端,原生开发对他们来说门槛很高。TVMLKit 就是 Apple 为这些团队提供的桥梁——用类似 HTML 的模板语言(TVML)描述界面,再由 JavaScript 驱动逻辑,就能在 Apple TV 上跑起来。

9 年后的今天,流媒体生态已经完全不同。用户在 iPhone、iPad、Mac、Vision Pro 上通过原生 app 消费内容,SwiftUI 成了跨平台构建原生 UI 的统一方案。Apple 在 tvOS 18 中正式宣布弃用 TVMLKit——它仍然可用,但不会再获得任何新功能。如果你的 app 还依赖 TVMLKit,现在就是开始迁移的时候。

SwiftUI 在 tvOS 18 已经能覆盖 TVMLKit 的全部能力,并且每年持续增加新功能。你用 Swift 编写代码,这是所有 Apple 平台通用的语言;你用 SwiftUI 构建视图,同一套组件在 iOS、iPadOS、tvOS 上都能运行。Session 从一个典型的 tvOS 媒体 app 入手,逐步演示了如何用 SwiftUI 实现内容锁屏(lockup)、水平货架(shelf)、首页促销布局、搜索界面,以及新的浮动侧边栏——这些正是 TVMLKit 过去做的事,现在 SwiftUI 做得更好。


详细内容

内容锁屏(Lockup):borderless buttonStyle

tvOS 上最常见的锁屏布局是图片浮在文字上方,图片带圆角,聚焦时抬起、倾斜、出现高光,文字自动下移避免被遮挡。SwiftUI 只需要一个 .borderless buttonStyle 就能实现全部效果(04:18)。

Button {} label: {
    Image("discovery_landscape")
        .resizable()
        .frame(width: 250, height: 375)
    Text("Borderless Portrait")
}
.buttonStyle(.borderless)

关键点:

  • Button 包裹图片和文字,tvOS 默认 buttonStyle 是 .bordered(带背景托盘),改用 .borderless 后图片自由浮起
  • 聚焦时图片自动抬起、倾斜、出现镜面高光,文字下移避开遮挡,全部由系统处理
  • 这段代码在 iOS/iPadOS 上也能直接运行,交互方式自动适配平台

水平货架(Shelf):containerRelativeFrame

内容货架需要水平滚动、对齐安全区域、边缘露出下一项内容。核心是 containerRelativeFrame 修饰符(05:38)。

ScrollView(.horizontal) {
    LazyHStack(spacing: 40) {
        ForEach(Asset.allCases) { asset in
            Button {} label: {
                asset.portraitImage
                    .resizable()
                    .aspectRatio(250/375, contentMode: .fit)
                    .containerRelativeFrame(.horizontal, count: 6, spacing: 40)
                Text(asset.title)
            }
        }
    }
}
.scrollClipDisabled()
.buttonStyle(.borderless)

关键点:

  • LazyHStack 搭配 ScrollView(.horizontal) 实现水平滚动,spacing: 40 控制项间距
  • aspectRatio 替代固定尺寸,让 SwiftUI 根据屏幕宽度自动调整
  • containerRelativeFrame(.horizontal, count: 6, spacing: 40) 让每行放 6 个元素,自动对齐安全区域并让屏幕外内容露出
  • spacing: 40 必须和 LazyHStackspacing: 40 一致,否则对齐会出错
  • .scrollClipDisabled() 必须加——聚焦抬起效果会让按钮超出自身 bounds,不加会被 ScrollView 裁剪

Card buttonStyle:信息密度更高的锁屏

搜索结果等场景需要展示更多文字信息,用 .card buttonStyle 可以获得带圆角背景托盘的锁屏,抬起效果更克制(08:19)。

Button {} label: {
    HStack(alignment: .top, spacing: 10) {
        asset.landscapeImage
            .resizable()
            .aspectRatio(contentMode: .fit)
            .clipShape(RoundedRectangle(cornerRadius: 12))
        VStack(alignment: .leading) {
            Text(asset.title)
                .font(.body)
            Text("Subtitle text goes here, limited to two lines")
                .font(.caption2)
                .foregroundStyle(.secondary)
                .lineLimit(2)
            Spacer(minLength: 0)
            HStack(spacing: 4) {
                ForEach(1..<4) { _ in
                    Image(systemName: "ellipsis.rectangle.fill")
                }
            }
            .foregroundStyle(.secondary)
        }
    }
    .padding([.leading, .top, .bottom], 12)
    .padding(.trailing, 20)
    .frame(maxWidth: .infinity)
}
.containerRelativeFrame(.horizontal, count: 3, spacing: 48)
.buttonStyle(.card)

关键点:

  • .card buttonStyle 提供圆角背景托盘,抬起和移动更温和,适合信息密集的布局
  • HStack 左图右文,左侧图片用 clipShape 加圆角
  • 右侧 VStack 堆叠标题、副标题、图标行,副标题 .lineLimit(2) 限制行数
  • containerRelativeFramecount: 3 让每行 3 张卡片,spacing: 48 与外层一致

首页促销布局与 onScrollVisibilityChange

首页顶部需要一张铺满屏幕的大图作为促销背景,滚动到下方时图片淡出。tvOS 18 新增 onScrollVisibilityChange 修饰符(08:39)。

ScrollView(.vertical) {
    LazyVStack(alignment: .leading, spacing: 26) {
        VStack(alignment: .leading) {
            Text("tvOS with SwiftUI")
                .font(.largeTitle).bold()
            Spacer(minLength: 300)
            HStack {
                Button("Show") {}
                Button("More Info…") {}
                Spacer()
            }
            .padding(.bottom, 100)
            Spacer()
        }
        .onScrollVisibilityChange { visible in
            withAnimation {
                belowFold = !visible
            }
        }
        Section("Movie Shelf") { MovieShelf() }
        Section("TV and Music Shelf") { TVMusicShelf() }
        Section("Content Cards") { CardShelf() }
    }
    .scrollTargetLayout()
}
.scrollClipDisabled()
.background(alignment: .top) {
    if !belowFold {
        Image("beach_landscape")
            .resizable()
            .aspectRatio(contentMode: .fill)
            .ignoresSafeArea()
            .mask {
                LinearGradient(stops: [
                    .init(color: .black, location: 0.0),
                    .init(color: .black, location: 0.45),
                    .init(color: .black.opacity(0), location: 0.8)
                ], startPoint: .top, endPoint: .bottom)
            }
    }
}
.scrollTargetBehavior(.viewAligned)

关键点:

  • .onScrollVisibilityChange 是 tvOS 18 新增修饰符,当 header 滚出可见区域时触发回调
  • belowFold 状态控制背景图片的显隐,配合 withAnimation 实现平滑过渡
  • 背景 Image.ignoresSafeArea() 铺满屏幕,LinearGradient mask 做底部渐隐
  • .scrollTargetBehavior(.viewAligned) 让滚动对齐到视图边界,过渡更干净

搜索:searchable + searchSuggestions

搜索是流媒体 app 的必备功能。SwiftUI 只需两个修饰符就能实现完整的搜索体验(11:50)。

@State var searchTerm: String = ""

let columns: [GridItem] = Array(repeating: .init(.flexible(), spacing: 40), count: 4)

ScrollView(.vertical) {
    LazyVGrid(columns: columns) {
        ForEach(sortedMatchingAssets) { asset in
            Button {} label: {
                asset.landscapeImage
                    .resizable()
                    .aspectRatio(16 / 9, contentMode: .fit)
                Text(asset.title)
            }
        }
    }
}
.scrollClipDisabled()
.buttonStyle(.borderless)
.searchable(text: $searchTerm)
.searchSuggestions {
    ForEach(suggestedSearchTerms, id: \.self) { suggestion in
        Text(suggestion)
    }
}

关键点:

  • @State var searchTerm 绑定到 .searchable(text: $searchTerm),输入内容自动同步
  • searchTerm 过滤 ForEach 的数据源即可筛选结果
  • .searchSuggestions 提供补全建议,选中后自动填入搜索框
  • LazyVGrid 四列布局,横向排更多内容,适合搜索结果的信息密度

侧边栏:一行代码切换

tvOS 18 引入了系统级的浮动侧边栏,外观和 TV app 里的一样。如果你的 app 已经用了 TabView,只需一行代码就能切换(14:59)。

TabView {
    Tab("Stack", systemImage: "line.3.horizontal") {
        StackView()
    }
    Tab("Search", systemImage: "magnifyingglass") {
        SearchView()
    }
}
.tabViewStyle(.sidebarAdaptable)

关键点:

  • .tabViewStyle(.sidebarAdaptable) 把 Tab 栏变成浮动侧边栏,半透明毛玻璃效果,收起后变成紧凑指示器
  • tvOS 18 新增 Tab 语法替代旧的 TabView + .tabItem 写法,类型安全且可扩展
  • 同一个修饰符在 iPadOS 18 上让用户在 Tab 栏和侧边栏之间切换

核心启发

  • 做什么:将现有 TVMLKit app 迁移到 SwiftUI。为什么值得做:TVMLKit 已正式弃用,不会再获得新功能,而 SwiftUI 每年都有平台级更新。怎么开始:从最简单的页面入手,用 .borderless buttonStyle 重建内容锁屏,用 containerRelativeFrame 重建货架,逐步替换。
  • 做什么:复用跨平台代码构建 tvOS app。为什么值得做:同一套 SwiftUI 视图在 iOS、iPadOS、tvOS 上都能运行,开发资源投入一份代码覆盖多个平台。怎么开始:抽取与平台无关的视图组件(如锁屏、货架),通过条件编译或布局参数适配不同屏幕尺寸。
  • 做什么:采用 .tabViewStyle(.sidebarAdaptable) 替换自定义侧边栏。为什么值得做:系统级浮动侧边栏有一致的交互体验、半透明毛玻璃效果和紧凑指示器,且与 iPadOS 18 的 Tab 栏 API 共享同一套代码。怎么开始:将现有 TabView 加上 .tabViewStyle(.sidebarAdaptable) 修饰符,验证在各平台上的表现。
  • 做什么:用 .searchable + .searchSuggestions 实现搜索功能。为什么值得做:两个修饰符就完成搜索框、键盘、补全建议的全部交互,代码量远少于手写实现。怎么开始:给搜索视图添加 @State 持有搜索词,绑定到 .searchable,用搜索词过滤数据源,再用 .searchSuggestions 提供补全。

关联 Session

评论

GitHub Issues · utterances