Streaming is available in most browsers,
and in the Developer app.
-
Migrate your TVML app to SwiftUI
SwiftUI helps you build great apps on all Apple platforms and is the preferred toolkit for bringing your content into the living room with tvOS 18. Learn how to use SwiftUI to create familiar layouts and controls from TVMLKit, and get tips and best practices.
Chapters
- 0:00 - Introduction
- 3:54 - Lockups
- 5:12 - Shelves
- 7:35 - Catalogs
- 11:07 - Search
Resources
Related Videos
WWDC24
-
Download
Hello, and welcome to “Migrate your TVML app to SwiftUI.” I’m Jim, an engineer working on SwiftUI for tvOS, and I’m excited to show you the great features for tvOS apps in SwiftUI, and explain how you can use it to build apps just like those you’ve built using TVML.
In tvOS 18, SwiftUI has everything you need to build rich, fully-featured media catalog and streaming apps for your favorite set-top platform. With that in mind, Apple is officially deprecating TVMLKit. It’ll remain available as part of tvOS, but won’t be growing with the platform any more. Back in 2015, when Apple launched tvOS, the world of streaming media was quite different from the form it has today. Streaming apps were most often implemented as web pages, and content providers development resources were focused on the web. Apple provided TVMLKit as a way for these same resources and expertise to create great apps for Apple TV.
Today we consume content on our devices, like our iPhones or iPads, on our TVs through set-top boxes, from our computers and devices like the Apple Vision Pro. Streaming media catalogs are accessed through native apps built using tools tailored to those platforms. These apps provide a more personal and familiar experience for your customers and allow you to bring forth your own unique style while keeping the natural interaction and presentation styles of these devices.
SwiftUI provides a toolkit that produces native code and UI tailored for each of these platforms, all in the same language, all built using the same components. Development resources and expertise used on any one of these translates cleanly to any of the others.
On the Apple TV, it produces native tvOS UI, with design cues aimed specifically at producing great tvOS apps. Everything that TVMLKit could do is now possible in SwiftUI, with even more flexibility and great new features year-on-year. Your apps are written in Swift, the same language you use for building apps for iOS, iPadOS, macOS, watchOS and now visionOS.
It thus makes use of your existing native development resources: the same tools and techniques that build apps for iPad can be used to build those apps for the Apple TV. And more than ever before, tvOS apps can be built from the exact same code you use to make those other apps.
I’ll going to walk through the basics of assembling a content catalog app in SwiftUI. You’ll learn how the same components you use to build apps for any of Apple’s platforms can be used to create an interface and behavior that will be instantly familiar to anyone using Apple TV. Let’s start by examining the typical design language of a tvOS media app.
Upon first opening, a media catalog will likely show a home page targeted to their viewer. The upper part of the page is usually given over to some promoted content, flowing to the edges of the screen.
This promoted content will likely provide some quick actions to allow the user fast access. Further content is displayed in shelves below the promoted content area.
Each item is shown with a vibrant and colorful lockup that showcases its artwork.
Lastly, a tab bar across the top of the screen provides direct access to different sections of the app, including a search function.
In this talk, I’ll teach you how you can build the familiar lockups used by Apple apps like Music and TV. I’ll introduce some great techniques to assemble flowing shelves of these content lockups. I’ll provide an easy way to put together an impressive layout for your app’s landing page, and reveal how to create a smooth search experience in just a few lines of code.
Let’s start by investigating how to build that lockup design we described a moment ago.
The most common type of lockup on tvOS consists of an image floating above some text. The image has gently rounded corners, and when focused this lifts and tilts with a specular lighting effect, and any nearby text slides to avoid being occluded by the raised image. We can do all of this using SwiftUI.
The basic layout is simple to achieve, it’s an image above some text. This is already quite close to our design. However, we want this to be interactive, so we’ll put it in a button.
By default, buttons on tvOS use the bordered buttonStyle. They’re given a background platter that lifts and changes color when focused. To achieve the appearance we want, we’ll use the borderless buttonStyle.
Now the image and title are in the right orientation, and the image has rounded corners and a slight drop shadow. And when it’s focused, the image lifts, the text moves down, and a specular highlight appears. The whole image tilts as you move your finger across the remote’s touch surface. This code also works as-is on other platforms, with the appropriate platform-specific interaction mechanics.
Now that we have our basic content lockup, we can move on to create our shelves. Let’s revisit the standard appearance of a content shelf to determine what we want to implement.
Our content should flow horizontally, and should align itself with the safe area bounds of the screen. This will keep it nicely in-line with its surroundings.
It should also allow offscreen content to peek in from the edge of the screen, to indicate that more content is available in that direction.
We’ll start building this using a horizontally-scrolling stack, ideally this will be a LazyHStack embedded in a ScrollView. We’ll use the borderless buttonStyle to get our lockups, and make sure to disable scroll clipping on the ScrollView so that our focused lockups can grow and cast their drop shadows outside the bounds of the ScrollView itself.
Each item within the shelf is going to be represented by a Button.
While you can directly specify an exact size for an item’s poster image, you may be better served using just an aspectRatio derived from that size. This way, SwiftUI can make adjustments to help provide you with an ideal layout. In this case, my aspectRatio provides the shape of a movie poster, while the actual point dimensions will be determined by the number of items on the screen.
The layout magic happens here, in the containerRelativeFrame modifier. This tells SwiftUI that it should determine the frame of this item for us, and provides some helpful information. This will automatically adjust the content so that it aligns with the leading and trailing safe area insets, and will provide enough room for additional items beyond the current scroll bounds to peek into view on either side of the screen.
Firstly, the item’s frame should be determined relative to the horizontal bounds of the closest ancestor container view, in this case, that’s the ScrollView. Alone, it would make this button’s image stretch to the width of the ScrollView’s bounds, but two more attributes change this behavior.
Here we ask to have six items layout within the container’s width, and we provide a little assistance by telling it how much spacing is being placed between the items. This matches the spacing provided to the horizontal stack, and it’s important that these two values do match. Once again, you can use this exact same code to create a similar experience on other platforms, adjusting things like spacing and item count as appropriate.
With just a few short lines of code, you’ve now replicated the look and feel of Apple’s own media apps, just as you would have using TVML back in tvOS 9. This is a flexible component that can be put to work in a variety of ways with just some very small changes.
Remove the buttons titles and make a small adjustment to the containerRelativeFrame modifier for instance, and you have a carousel of hero-sized lockups.
Change the aspect ratio of your artwork and the number of items per containerRelativeFrame, and you have the perfect size for album artwork.
For cases where you need greater information density, you can use the .card buttonStyle to build the same type of lockups that you’ll find in the TV app’s search results.
The card buttonStyle provides a rounded platter to back your content, which lifts and moves in a more subtle manner than the borderless buttonStyle. Once again, all you need to do is attach the .card buttonStyle to get this behavior.
Now that we have our shelves, we’re ready to assemble our starting page.
A section view will provide each shelf with a title, and that title will automatically move out of the way as your lockups gain focus to avoid being occluded, just like the titles on the buttons themselves.
To properly introduce the home view we’d like that wide background image, expanding to fill the very edges of the screen with gorgeous imagery. We can start by adding a simple view containing a title and a couple of buttons, separated by some space to allow imagery to show through.
Now we can add a background to this, attaching it to the outer ScrollView. We’ll make sure the image is resizable so it’ll fit, but it’s not extending to the edges of the screen. This can be quickly remedied by ignoring the safe area.
This looks much better already. The content is flowing all the way out to the edges of the display, exactly as we want. It’s a little distracting to have it appear behind the shelf below though.
A simple gradient mask will fade it out behind our buttons. A plain linear gradient applied with the mask modifier is all you need here. Now the image is fading out to reveal the background behind it.
This is much better, and we have something a lot closer to our final application. Ideally we’d like the image to fade away completely when it’s not the center of attention.
New in tvOS 18.0 are a number of view modifiers specific to ScrollView. We can make use of these to remove the background image when the header section scrolls off screen.
When the content moves belowFold.
Here I’ve attached the new onScrollVisibilityChange modifier to the header view, and I’m changing some state when it moves offscreen.
I then choose to display the background based on that state.
I also set the scrollTargetBehavior to be viewAligned, just to help make that transition more definite.
Put it all together and voilà, here’s our landing page. You can download the sample code for this session to find some other examples of how you might implement this style of view with some more advanced techniques.
To learn more about the new ScrollView modifiers in tvOS 18 and aligned releases, see "Demystify SwiftUI containers".
This is going great so far, but there’s one must-have feature for any good content delivery service, and that’s search. Let’s find out how to quickly use what we’ve already built to provide a great search interface. Before anything else, you’ll need provide some means to access your search pane.
The typical approach is to use a TabView, and there’s a new expressive and type-safe syntax for assembling TabViews in tvOS 18. Here I’ve put the existing content stack into a new view called StackView, and I’ve added a SearchView to a new tab. Let’s figure out what we ought to have in our SearchView.
A search view typically allows for searching through a large amount of content at once, so here the shelf layout doesn’t really seem appropriate. Instead, we’ll use a grid layout to display our results.
We’ll start with the same basic structure as our other views, but inside we’ll place a LazyVGrid.
Here I’ve set it to create four columns, with 40-point spacing and flexible size. Once again, I’m going to let the aspect-ratio modifier and SwiftUI's layout machinery do the work of figuring out the actual sizes for my items.
The lockup itself is the by-now-familiar Button, though now that it’s scrolling vertically I’ve used an image with landscape orientation, to fit more content along that axis. To make this content searchable, I’m doing to add two things. First I’ll add a state property to the view. This will update to contain any search term as it’s being entered, and we can use it to filter the content passed to the for-each view to narrow down what we show in the results.
Next, I’ll add the .searchable modifier, passing it a binding to our searchTerm state property.
This one modifier is all you need to present a search view. From here your app’s consumers can start typing and you can use the value of the search-term state property to filter out any non-matching items.
They can search for videos containing their favorite robotic botanist, or they could indulge their own botanical aspirations directly.
You might have a lot of content for them to search through, and some of the potential search terms and keywords on your content items may be quite long. It would be helpful to suggest some likely completions. That’s also easy to do: just add a .searchSuggestions modifier. Here I’ve taken a list of keywords that contain the current search term, and I’m creating a series of text views from them. And here are the results. Selecting any of these completions will place their value into the search field automatically.
Focusing on a suggestion provides an onscreen preview within the search field, indicating both the action the suggestion will perform, as well as how this term matches the current input. Once again, this is all the same code that you’d use on other platforms, you’d add a search field to an app for iPad, Mac, or Apple Vision Pro in the exact same way.
It should now be clear just how easy it is to put together a great-looking app for the Apple TV using SwiftUI, and how everything we’ve built also runs on Apple’s other platforms practically unchanged. It’s never been easier to take your native SwiftUI apps and bring them into the living room.
We’re dedicated to providing you with clean and easy ways to adopt the latest platform features from SwiftUI. In fact, I’d like to show you, just one more.
The floating sidebar from the TV app is now available system-wide in tvOS 18 through SwiftUI. It has the same gorgeous translucent appearance, and shrinks down to reveal the same compact indicator from the TV app. If you’ve been building out your own sidebar, consider adopting this new appearance in your app. You can build it using the existing navigation split view APIs. If your app is currently using a tab bar however, you can try out this appearance today with just one line of code. Just set a tabViewStyle of sidebarAdaptable, and your tab bar will instead appear as a sidebar. This is the same modifier you would use on iPad to get the new tab bar appearance in iPadOS 18, where it will also allow the user to switch between tab bar and sidebar representations. On tvOS, it simply gives you a sidebar, with all its associated behavior, in a single line of code.
The new TabView APIs allow for all sorts of different types of content, allowing you to build a fully featured sidebar while maintaining a minimal tab bar appearance. This means your iPad app can take a simple and sparse tab bar representation and expand it to a detailed and functional sidebar, and that same sidebar content looks just great on tvOS. To learn more, see "Improve your tab and sidebar experience on iPad", where you can learn all about the new tab bar APIs.
If you’re considering bringing your app to Apple TV, this is the perfect time. SwiftUI makes it easy to build apps for tvOS that look great and share lots of their code with your apps for the iPhone, iPad, Mac, and Apple Vision Pro.
And if you currently have a TVML app, now’s the time to think about making the change.
You’ve learned how easy it is to create great apps for the Apple TV with SwiftUI. The borderless buttonStyle provides everything you need to obtain the platform’s standard content lockup appearance and interaction, and using container relative frames will take all the guesswork out of building content shelves.
For more information, see "What’s new in SwiftUI" to learn about the great new features arriving in tvOS 18 and aligned releases, find out more about new container APIs in "Demystify SwiftUI containers", and learn how to build great cross-platform top-level app navigation UI with "Improve your tab and sidebar experience on iPad".
Check out the sample project from this session to see this talk’s example code fully fleshed out with a few extra examples and suggestions, and for an example of a full-featured cross-platform app that runs great on Apple TV, look no further than the Destination Video sample project.
We on the SwiftUI for tvOS team are all eager to see what you build for the Apple TV with tvOS 18.
-
-
4:18 - Borderless button lockup
Button {} label: { Image("discovery_landscape") .resizable() .frame(width: 250, height: 375) Text("Borderless Portrait") } .buttonStyle(.borderless)
-
5:38 - Standard content shelf
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)
-
8:19 - Card button
ScrollView(.horizontal) { LazyHStack(spacing: 48) { ForEach(Asset.allCases) { asset in 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) } } } .scrollClipDisabled() .buttonStyle(.card)
-
8:39 - Landing page
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)
-
11:13 - Tab view
TabView { Tab("Stack", systemImage: "line.3.horizontal") { StackView() } // Other Tabs... Tab("Search", systemImage: "magnifyingglass") { SearchView() } }
-
11:50 - Search page
@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) } }
-
14:59 - Sidebar adaptable tab view style
TabView { Tab("Stack", systemImage: "line.3.horizontal") { StackView() } // Other Tabs... Tab("Search", systemImage: "magnifyingglass") { SearchView() } } .tabViewStyle(.sidebarAdaptable)
-
-
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.