SwiftUI on tvOS

Build your own tvOS app while brushing up your SwiftUI skills. Get hands-on practice with tvOS lazy views and the Focus Engine. By Jordan Osterberg.

4.9 (11) ·

Download materials
Save for later

tvOS is an exciting, but mostly unexplored, platform in the Apple ecosystem for outside developers. In this tutorial, you’ll explore this platform using the power of SwiftUI.

Along the way, you’ll:

  • Make your first tvOS app with SwiftUI
  • Get reintroduced to foundational SwiftUI building blocks
  • Utilize lazy views, introduced in tvOS 14
  • Tell the Focus Engine what to focus on
  • Reuse views in your app
  • Play a local video file using SwiftUI

You’ll do this by working on a tvOS app named RickTV. The app displays a wide variety of content, only to Rickroll you regardless of what you wanted to watch. :]

Getting Started

Download the project materials using the Download Materials button at the top or bottom of this tutorial.

Open the project in Xcode and select any of the tvOS simulators, then build and run.

A thumbnail listing all videos

The starter project contains a list of video titles, which you can select to view their details. There are three tabs:

  • All Videos: This displays all the videos offered by the app.
  • Favorites: All the videos you mark as favorites go here.
  • Lots of Videos: A massive amount of videos, designed to be as intensive as possible for tvOS and SwiftUI to handle. You’ll learn more about this tab in the section about lazy views.

An important thing to note is that, unlike other simulators, you must use a Siri Remote to interact with the tvOS simulator. In the Simulator menu, select Window ▸ Show Apple TV remote. On the Siri Remote, hold Option and use your system’s pointing device to scroll in the simulator.

Reviewing SwiftUI

With tvOS 14, the SwiftUI app lifecycle changed. In prior versions, you had to rely on UIKit’s AppDelegate system to manage the app’s lifecycle. Fortunately, Apple fixed this and introduced new APIs: App, Scene and @main.

You can define an app in SwiftUI with only a few lines of code. To see this, open the starter project, then open RickTVApp.swift:

// 1
// 2
struct RickTVApp: App {
  // 3
  var body: some Scene {
    // 4
    WindowGroup {

Here’s what’s happening in the code above. This code tells SwiftUI about your app:

  1. @main is how SwiftUI knows where to find your app. There can only be one @main label in an app.
  2. You create a new struct, RickTVApp, which subclasses App. All SwiftUI apps that use the SwiftUI lifecycle must conform App.
  3. RickTVApp has a property named body, which is where SwiftUI looks to find your app’s content. This body type can be any Scene type. In this app, it’s WindowGroup. body isn’t unique to App. All views have a body as well.
  4. Finally, inside of WindowGroup‘s body, you return a new ContentView, which contains your top-level view.

Open ContentView.swift.

At the top, you’ll notice a property named dataProvider:

@ObservedObject var dataProvider = DataProvider()

DataProvider is a custom class that implements ObservableObject. This allows your view to listen for any changes to certain properties within that class — those marked with @Published.

This is a foundational principle of SwiftUI: You can observe objects or properties and your views will update automatically when they change. @ObservedObject is one of the ways to listen for changes in your app’s data.

If you don’t want or need a full class to listen for changes — for example, you have a piece of data that is relevant only to your view — create a @State property. For example:

@State var timesPressed = 0

You could use this property, marked with @State, to track the number of button presses. From there, you could show this number as text within your app:


Because you marked timesPressed with @State, your Text view will update whenever timesPressed changes.

Adding a Thumbnail Preview

To complete your starter project, you’ll add a thumbnail preview of the video along with a description. This is how the design will look when you’ve finished:

A single video's thumbnail, title and description

Open VideoThumbnailView.swift. Inside, you’ll see a VStack, with a Text view nested in it.

A VStack, or vertical stack, is a collection of views that display in a vertical layout. There’s also an HStack for horizontal layout.

Replace the Text view inside the VStack with an Image:


Build and run.

A video thumbnail on its own

It’s a great start. Next, you’ll make the image look more like a traditional video app’s thumbnail image. Replace the recently added Image with the following:

  // 1
  // 2
  // 3
  .aspectRatio(contentMode: .fill)
  // 4
  .frame(width: 450, height: 255)

These modifiers on the Image view allow you to change the behavior of a view. In the code above, you:

  1. Make the image resizable.
  2. Change the rendering mode to .original, which tells SwiftUI to use the image in its original format.
  3. Change the content mode of the image to .fill, which means the image will expand to fill the frame of the view instead of being squished.
  4. Set the width and height of the image to a predetermined size.

Next, add the following three modifiers at the end of the list:

// 1
// 2
// 3
.shadow(radius: 5)

Here, you:

  1. Use clipped() to ensure the image doesn’t go beyond the frame of the view.
  2. Set the corner radius to 10 so there aren’t any sharp edges on the image.
  3. Add a nice drop shadow for some extra depth.

Finally, add the two Text views inside another VStack to show the title and description. Place these after .shadow(radius: 5):

// 1
VStack(alignment: .leading) {
  // 2
  // 3
  Text(video.description.isEmpty ? 
    "No description provided for this video." : 
    .frame(height: 80)

Here’s what’s going on in the code above:

  1. Create a new vertical stack.
  2. Add a new Text to display the video title with modifiers to update aspects of the text such as color and font.
  3. Add another Text to display the video description with modifiers to update the appearance and to add limits to the number of lines and the height.

Build and run.

A list of video thumbnails, titles, and descriptions

Voilà! The app looks so much better now. :]

Lazy Views

Sometimes, your app needs to display a lot of data. Imagine you have thousands of pictures to display, for example. Your app would perform poorly if you tried to load all those photos into views at once.

Build and run. Navigate to the Lots of Videos tab and try to scroll down.

The "Lots of Videos" tab

Notice that the simulator becomes very slow, if not unresponsive. That’s because the video list isn’t lazy.

A lazy view is a view that only loads or renders right as it is about to display. This improves performance because your app doesn’t need to render thousands of views at once. It just renders the ones that are currently displayed. Fortunately, some of the key views used in apps, like Stacks and Grids, have lazy counterparts.

Open CategoryListView.swift. In the body, scroll to right below else in body. Replace the VStack line with the following:

LazyVStack(alignment: .leading) {}

Build and run.

Lots of Videos Tab

Navigate to the Lots of Videos tab. You’ll notice a significant performance improvement. Fantastic!

Using the Focus Engine

The Focus Engine is tvOS’s tool to show the user what’s highlighted, or focused, on the screen. Generally speaking, this means the content grows slightly and appears to float away from the background.

Users will expect your app to follow this behavior. In the starter project, you may have noticed that there is no focus behavior for any of the videos.

Open CategoryRow.swift and look for the NavigationLink view. NavigationLink is a button that brings the user to a new view in your app.

In this case, the link opens the selected video’s VideoDetailView.

Normally, using a NavigationLink in tvOS provides a default button style. RickTV disables this, because it doesn’t look great for the video browser’s use case.

Check out what SwiftUI provides by default by temporarily removing this line of code:


A video with the default navigation styling applied

Make sure to add buttonStyle‘s’ code back into the app before moving on.

buttonStyle changes the style of the NavigationLink to a custom style named PlainNavigationLinkButtonStyle. This custom style doesn’t currently support the Focus Engine out of the box.

Using Environment Values

To add the focus, open PlainNavigationLinkButtonStyle.swift, paying attention to PlainNavigationLinkButton.

At the top of the struct, above the configuration property, add this line of code:

@Environment(\.isFocused) var focused: Bool

This is an environment value, which SwiftUI provides to give you information about the current context of a view. There are other environment values, like the current theme (i.e., dark or light mode) of the user’s device, that can modify the state of a view based on that context.

In this case, you’re using isFocused. This property is true when tvOS focuses on this view, and false otherwise.

Next, inside the body of PlainNavigationLinkButton, add scaleEffect to configuration.label:

.scaleEffect(focused ? 1.1 : 1)

This ensures that the button — the video, in this case — will grow when the user focuses on it.

Finally, after adding scaleEffect, add focusable to configuration.label:


This informs tvOS that the user can focus on PlainNavigationLinkButton.

Build and run, then scroll down to a video.

A video in focus

Everything is in focus!

Reusing Views

Inside the app, select a video.

The video detail page without category recommendations

This page is good — but wouldn’t it be awesome if you included recommended videos on the detail page?

One of the most powerful SwiftUI capabilities is the option to easily reuse the views you create in multiple places. For example, you can just embed a CategoryRow inside the video detail page to recommend other videos from the same category. You’ll implement this now.

Open VideoDetailView.swift. Below the Stack that contains the Play and Favorite buttons, add this code right after .padding(.bottom, 50):

if !categoryWithCurrentVideoRemoved.videos.isEmpty {

You can use if statements inside SwiftUI views. This one checks to see if there are any videos other than the currently displayed one inside the current video’s category.

If there are other videos to display, you show a CategoryRow with that category and a custom title named Related Videos, add this inside the if:

  category: categoryWithCurrentVideoRemoved,
  customTitle: "Related Videos")

Build and run, then click a video that has more than one video in the same category.

Video detail page with category recommendations

Notice the Related Videos section at the bottom. Scroll down and click that video, and a new VideoDetailView opens, again with the CategoryRow at the bottom containing the other videos in the category. Bravo! :]

Playing Videos

Now that users have multiple ways to find videos, it’s time to actually play the video they’re looking for.

Open PlayerView.swift. Notice it’s essentially an empty view except for the text that says Video Player.

SwiftUI has a built-in VideoPlayer view, but it needs some changes to work with your app. First, add these two properties to PlayerView:

@State private var player: AVQueuePlayer?
@State private var videoLooper: AVPlayerLooper?

These properties come from AVKit, which is the Apple framework that allows developers to play video.

Next, replace the Text view inside the body with VideoPlayer:

VideoPlayer(player: player)

Build and run. Navigate to the player view by selecting a video and pressing Play:

A loading spinner against a black background

This isn’t quite right. Where’s the video?

A few things are happening here:

  1. player is nil, which means there isn’t a source to play from.
  2. You haven’t told the player which video to play.
  3. You haven’t started playing the video when the PlayerView appears on screen.

To fix this, while in PlayerView.swift, add the onAppear modifier to the VideoPlayer you added above:

.onAppear {

This block is executed every time the view appears on screen.

Inside .onAppear, add the following:

// 1
if player == nil {
  // 2
  let templateItem = AVPlayerItem(
    url: Bundle.main.url(forResource: "rick", withExtension: "mp4")!)
  // 3
  player = AVQueuePlayer(playerItem: templateItem)
  // 4
  videoLooper = AVPlayerLooper(player: player!, templateItem: templateItem)

Here’s what’s going on in the code above:

  1. The code checks if the player has been created.
  2. If there’s no player, it creates a new player item that using AVPlayerItem This item references the actual video you wish to play.
  3. Next, it must create a queue of items to play.
  4. Finally, it creates AVPlayerLooper, which will handle looping your video when it ends.

Outside of this if statement, you need to play the video if it hasn’t started playing:

if player?.isPlaying == false { player?.play() }

Build and run, then play a video:

The Rickroll video playing on the screen

Great! Everything works… but why is the video squished like that?

That’s because of the safe area. The safe area ensures that your content stays readable, no matter which device — the TV screen, in this case — it’s running on.

The video player should ignore the safe area and let the video play in full screen. To implement this, add edgesIgnoringSafeArea to the end of the VideoPlayer view:


Build and run and view a video:

The Rickroll video playing in full screen

Looks great!

Where to Go From Here?

Download the complete project using the Download Materials button at the top or bottom of this tutorial.

This project is only the beginning; there’s so much more you can add. If you’d like to add some basic user preferences to save when the user favorites a video, look into using the UserDefaults API.

To learn about tvOS best design practices, look at Apple’s Human Interface Guidelines for tvOS.

Using this API also allows for tvOS multiuser support, which you can learn how to enable in the official Apple documentation.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!