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.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

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:

.buttonStyle(PlainNavigationLinkButtonStyle())

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:

.focusable(true)

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:

CategoryRow(
  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:

.edgesIgnoringSafeArea(.all)

Build and run and view a video:

The Rickroll video playing in full screen

Looks great!