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

Migrate your TVML app to SwiftUI

元の動画を見る

ハイライト

tvOS 18 で TVMLKit が正式に非推奨となり、SwiftUI がその全機能をカバーします。.tabViewStyle(.sidebarAdaptable) 1 行で Tab バーをフローティングサイドバーに切り替えられます。


主要内容

2015 年に Apple が tvOS をリリースしたとき、ストリーミング App のほとんどは Web 実装でした。開発チームの技術スタックと人材は Web 側に集中し、ネイティブ開発のハードルは高くありました。TVMLKit はそうしたチーム向けの架け橋——HTML に似たテンプレート言語(TVML)で UI を記述し、JavaScript でロジックを駆動すれば Apple TV 上で動きます。

9 年後の今日、ストリーミングエコシステムは大きく変わりました。ユーザーは iPhone、iPad、Mac、Vision Pro のネイティブ App でコンテンツを消費し、SwiftUI がクロスプラットフォームのネイティブ UI 構築の統一手段です。Apple は tvOS 18 で TVMLKit を正式に非推奨としました——引き続き利用可能ですが、新機能は追加されません。App が TVMLKit に依存しているなら、今が移行を始めるタイミングです。

tvOS 18 の SwiftUI は TVMLKit の全能力をカバーし、毎年新機能が追加されます。Swift でコードを書き、すべての Apple プラットフォーム共通の言語を使います。SwiftUI でビューを構築すれば、同じコンポーネントが iOS、iPadOS、tvOS で動作します。セッションは典型的な tvOS メディア App から始め、コンテンツ lockup、水平 shelf、ホームプロモレイアウト、検索 UI、新しいフローティングサイドバーを SwiftUI で段階的に実装する方法を示します——TVMLKit が担っていたことを、SwiftUI がより良く実現します。


詳細

コンテンツ lockup:borderless buttonStyle

tvOS で最も一般的な lockup レイアウトは、画像がテキストの上に浮き、角丸付きで、フォーカス時に持ち上がり、傾き、ハイライトが現れ、テキストが自動的に下に移動して遮られないようにします。SwiftUI では .borderless buttonStyle 1 つで全効果を実現(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

コンテンツ shelf には水平スクロール、セーフエリアへの整列、次の項目の端の露出が必要。中核は 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)

キーポイント:

  • LazyHStackScrollView(.horizontal) で水平スクロール、spacing: 40 で項目間隔を制御
  • 固定サイズの代わりに aspectRatio を使い、SwiftUI が画面幅に応じて自動調整
  • containerRelativeFrame(.horizontal, count: 6, spacing: 40) で 1 行 6 要素、セーフエリア整列、画面外コンテンツの露出
  • spacing: 40LazyHStackspacing: 40 と一致必須、不一致だと整列が崩れる
  • .scrollClipDisabled() は必須——フォーカス時の持ち上げでボタンが bounds を超え、ScrollView でクリップされる

Card buttonStyle:情報密度の高い lockup

検索結果など、より多くのテキスト情報が必要なシーンでは .card buttonStyle で角丸背景トレイ付き lockup を取得。持ち上げ効果はより控えめ(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 で 1 行 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 では 2 つの修飾子で完全な検集体験を実現(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) にバインド。入力内容が自動同期
  • searchTermForEach のデータソースをフィルタして結果を絞り込み
  • .searchSuggestions で補完提案。選択すると検索欄に自動入力
  • LazyVGrid 4 列レイアウトで横方向により多くのコンテンツ。検索結果の情報密度に適する

サイドバー:1 行で切り替え

tvOS 18 で TV App と同じシステムレベルのフローティングサイドバーが導入。既に TabView を使っているなら 1 行で切り替え可能(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 で lockup を再構築、containerRelativeFrame で shelf を再構築し、段階的に置き換え。
  • やること:クロスプラットフォームコードを再利用して tvOS App を構築。理由:同じ SwiftUI ビューが iOS、iPadOS、tvOS で動作。1 つのコード投資で複数プラットフォームをカバー。始め方:プラットフォーム非依存のビューコンポーネント(lockup、shelf)を抽出し、条件コンパイルやレイアウトパラメータで画面サイズに適応。
  • やること.tabViewStyle(.sidebarAdaptable) でカスタムサイドバーを置換。理由:システムのフローティングサイドバーは一貫したインタラクション、半透明ブラー、コンパクトインジケーターを提供。iPadOS 18 の Tab バー API とコード共有。始め方:既存 TabView.tabViewStyle(.sidebarAdaptable) を追加し、各プラットフォームで検証。
  • やること.searchable + .searchSuggestions で検索機能を実装。理由:2 つの修飾子で検索欄、キーボード、補完提案の全インタラクションを完了。手書きよりはるかに少ないコード。始め方@State で検索語を保持、.searchable にバインド、データソースをフィルタ、.searchSuggestions で補完を提供。

関連セッション

コメント

GitHub Issues · utterances