1. Before you begin
Compose for TV is the latest UI framework to develop apps that run on Android TV. It unlocks all the benefits of Jetpack Compose for TV apps, which makes it easier to build beautiful and functional UIs for your app. Some specific benefits of Compose for TV include the following:
- Flexibility. Compose can be used to create any type of UI from simple layouts to complex animations. Components work out of the box, but can also be customized and styled to fit your app's needs.
- Simplified and accelerated development. Compose is compatible with existing code and lets developers build apps with less code.
- Intuitiveness: Compose uses a declarative syntax that makes it intuitive to change your UI, and debug, understand, and review your code.
A common use case for TV apps is media consumption. Users browse content catalogs and select the content they want to watch. The content can be a movie, TV show, or podcast. After users select a piece of content, they might want to see more information about it, such as a short description, the playback length, and the names of the creators. In this codelab, you learn how to implement a catalog-browser screen and a details screen with Compose for TV.
Prerequisites
- Experience with Kotlin syntax, including lambdas.
- Basic experience with Compose. If you're unfamiliar with Compose, complete the Jetpack Compose basics codelab.
- Basic knowledge of composables and modifiers.
- Any of the following devices to run the sample app:
- An Android TV device
- An Android virtual device with a profile in the TV device definition category
What you build
- A video-player app with a catalog-browser screen and a details screen.
- A catalog-browser screen that shows a list of videos for users to choose. It looks like the following image:
- A details screen that shows the metadata of a selected video, such as the title, description, and length. It looks like the following image:
What you need
- The latest version of Android Studio
- An Android TV device or a virtual device in TV device category
2. Get set up
To get the code that contains theming and basic setup for this codelab, do one of the following:
- Clone the code from this GitHub repository:
$ git clone https://github.com/android/tv-codelabs.git
The main
branch contains the starter code and the solution
branch contains the solution code.
- Download the
main.zip
file, which contains the starter code, and thesolution.zip
file, which contains the solution code.
Now that you've downloaded the code, open the IntroductionToComposeForTV project folder in Android Studio. You're now ready to get started.
3. Implement the catalog-browser screen
The catalog-browser screen lets users browse movie catalogs. You implement the catalog browser as a composable function. You can find the CatalogBrowser
composable function in the CatalogBrowser.kt
file. You implement the catalog-browser screen in this composable function.
The starter code has a ViewModel called the CatalogBrowserViewModel
class that has several attributes and methods to retrieve Movie
objects that describe movie content. You implement a catalog browser with retrieved Movie
objects.
CatalogBrowser.kt
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun CatalogBrowser(
modifier: Modifier = Modifier,
catalogBrowserViewModel: CatalogBrowserViewModel = hiltViewModel(),
onMovieSelected: (Movie) -> Unit = {}
) {
}
Display the category names
You can access a list of categories with the catalogBrowserViewModel.categoryList
attribute, which is a flow of a Category
list. The flow is collected as a Compose State
object by calling its collectAsStateWithLifecycle
method. A Category
object has the name
attribute, which is a String
value that represents the category name.
To display the category names, follow these steps:
- In Android Studio, open the starter code's
CatalogBrowser.kt
file, and then add aLazyColumn
composable function to theCatalogBrowser
composable function. - Call the
catalogBrowserViewModel.categoryList.collectAsStateWithLifeCycle()
method to collect the flow as aState
object. - Declare
categoryList
as a delegated property of theState
object that you created in the previous step. - Call the
items
function with thecategoryList
variable as a parameter. - Call the
Text
composable function with the category name as the parameter that's passed as an argument of the lambda.
CatalogBrowser.kt
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun CatalogBrowser(
modifier: Modifier = Modifier,
catalogBrowserViewModel: CatalogBrowserViewModel = hiltViewModel(),
onMovieSelected: (Movie) -> Unit = {}
) {
val categoryList by catalogBrowserViewModel.categoryList.collectAsStateWithLifecycle()
LazyColumn(modifier = modifier) {
items(categoryList) { category ->
Text(text = category.name)
}
}
}
Display the content list for each category
A Category
object has another attribute called movieList
. The attribute is a list of Movie
objects that represent movies that belong to the category.
To display the content list for each category, follow these steps:
- Add the
LazyRow
composable function and then pass a lambda to it. - In the lambda, call the
items
function with thecategory
.movieList
attribute value and then pass a lambda to it. - In the lambda passed to the
items
function, call theMovieCard
composable function with aMovie
object.
CatalogBrowser.kt
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun CatalogBrowser(
modifier: Modifier = Modifier,
catalogBrowserViewModel: CatalogBrowserViewModel = hiltViewModel(),
onMovieSelected: (Movie) -> Unit = {}
) {
val categoryList by
catalogBrowserViewModel.categoryList.collectAsStateWithLifecycle()
LazyColumn(modifier = modifier) {
items(categoryList) { category ->
Text(text = category.name)
LazyRow {
items(category.movieList) {movie ->
MovieCard(movie = movie)
}
}
}
}
}
Optional: Adjust the layout
- To set the gap between categories, pass an
Arrangement
object to theLazyColumn
composable function with theverticalArrangement
parameter. TheArrangement
object is created by calling theArrangement#spacedBy
method. - To set the gap between movie cards, pass an
Arrangement
object to theLazyRow
composable function with thehorizontalArrangement
parameter. - To set an indentation to the column, pass a
PaddingValue
object with thecontentPadding
parameter.
CatalogBrowser.kt
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun CatalogBrowser(
modifier: Modifier = Modifier,
catalogBrowserViewModel: CatalogBrowserViewModel = hiltViewModel(),
onMovieSelected: (Movie) -> Unit = {}
) {
val categoryList by
catalogBrowserViewModel.categoryList.collectAsStateWithLifeCycle()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 32.dp)
) {
items(categoryList) { category ->
Text(text = category.name)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(category.movieList) { movie ->
MovieCard(movie = movie)
}
}
}
}
}
4. Implement the details screen
The details screen shows the details of the selected movie. There's a Details
composable function in the Details.kt
file. You add code to this function to implement the details screen.
Details.kt
@Composable
fun Details(movie: Movie, modifier: Modifier = Modifier) {
}
Display the movie title, studio name, and description
A Movie
object has the following three string attributes as metadata of the movie:
title
. The movie title.studio
. The name of the studio that produced the movie.description
. A short summary of the movie.
To show this metadata on the details screen, follow these steps:
- Add a
Column
composable function, and then set 32 dp vertical and 48 dp horizontal clearance around the column with theModifier
object created by theModifier.padding
method. - Add a
Text
composable function to display the movie title. - Add a
Text
composable function to display the studio name. - Add a
Text
composable function to display the movie description.
Details.kt
@Composable
fun Details(movie: Movie, modifier: Modifier = Modifier) {
Column(
modifier = Modifier
.padding(vertical = 32.dp, horizontal = 48.dp)
) {
Text(text = movie.title)
Text(text = movie.studio)
Text(text = movie.description)
}
}
The Modifier
object specified in the parameter of the Details
composable function is used in the next task.
Display the background image associated with a given Movie
object
A Movie
object has a backgroundImageUrl
attribute that indicates the location of the background image for the movie described by the object.
To display the background image for a given movie, follow these steps:
- Add a
Box
composable function as a wrapper of theColumn
composable function with themodifier
object passed through theDetails
composable function. - In the
Box
composable function, call thefillMaxSize
method of themodifier
object to make theBox
composable function fill the maximum size that can be allocated to theDetails
composable function. - Add an
AsyncImage
composable function with the following parameters to theBox
composable function:
- Set the
backgroundImageUrl
attribute's value of the givenMovie
object to amodel
parameter. - Pass
null
to acontentDescription
parameter.
- Pass a
ContentScale.Crop
object to acontentScale
parameter. To see the differentContentScale
options, see Content scale. - Pass the return value of the
Modifier.fillMaxSize
method to themodifier
parameter.
Details.kt
@Composable
fun Details(movie: Movie, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize()) {
AsyncImage(
model = movie.cardImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Column {
Text(
text = movie.title,
)
Text(
text = movie.studio,
)
Text(text = movie.description)
}
}
}
Refer to the MaterialTheme
object for consistent theming
The MaterialTheme
object contains functions to reference current theme values, such as those in the Typography
and ColorScheme
classes.
To refer to the MaterialTheme
object for consistent theming, follow these steps:
- Set the
MaterialTheme.typography.displayMedium
property to the text style of the movie title. - Set the
MaterialTheme.typography.bodySmall
property to the text style of the secondText
composable functions. - Set the
MaterialTheme.colorScheme.background
property to the background color of theColumn
composable function with theModifier.background
method.
Details.kt
@Composable
fun Details(movie: Movie, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize()) {
AsyncImage(
model = movie.cardImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.background),
) {
Text(
text = movie.title,
style = MaterialTheme.typography.displayMedium,
)
Text(
text = movie.studio,
style = MaterialTheme.typography.bodySmall,
)
Text(text = movie.description)
}
}
}
Optional: Adjust layout
To adjust layout of the Details
composable function, follow these steps:
- Set the
Box
composable function to use the entire available space with thefillMaxSize
modifier - Set the background of the
Box
composable function with with thebackground
modifier to fill the background with a linear gradient that is created by callingBrush.linearGradient
function with a list ofColor
objects containingMaterialTheme.colorScheme.background
value andColor.Transparent
- Set the
48.dp
horizontal and24.dp
vertical clearance around theColumn
composable function with thepadding
Modifier - Set the with of the
Column
composable function with thewidth
modifier that is created by callingModifier.width
function with0.5f
value - Add the
8.dp
space between the secondText
composable function and the thirdText
composable withSpacer
. The height of theSpacer
composable function is specified with theheight
modifier that is created withModifier.height
function
Details.kt
@Composable
fun Details(movie: Movie, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize()) {
AsyncImage(
model = movie.cardImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.background(
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.background,
Color.Transparent
)
)
)
.fillMaxSize()
) {
Column(
modifier = Modifier
.padding(horizontal = 48.dp, vertical = 24.dp)
.fillMaxWidth(0.5f)
) {
Text(
text = movie.title,
style = MaterialTheme.typography.displayMedium,
)
Text(
text = movie.studio,
style = MaterialTheme.typography.bodySmall,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = movie.description,
)
}
}
}
}
5. Add navigation between the screens
Now you have the catalog-browser and details screens. After a user selects content on the catalog-browser screen, the screen must transition to the details screen. To make this possible, you use the clickable
modifier to add an event
listener to the MovieCard
composable function. When the center button of the directional pad is pressed, the CatalogBrowserViewModel#showDetails
method is called with the movie object associated with the MovieCard
composable function as an argument.
- Open the
com.example.tvcomposeintroduction.ui.screens.CatalogBrowser
file. - Pass a lambda function to the
MovieCard
composable function with anonClick
parameter. - Call the
onMovieSelected
callback with the movie object associated with theMovieCard
composable function.
CatalogBrowser.kt
@Composable
fun CatalogBrowser(
modifier: Modifier = Modifier,
catalogBrowserViewModel: CatalogBrowserViewModel = hiltViewModel(),
onMovieSelected: (Movie) -> Unit = {}
) {
val categoryList by
catalogBrowserViewModel.categoryList.collectAsStateWithLifecycle()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 32.dp)
) {
items(categoryList) { category ->
Text(text = category.name)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(category.movieList) { movie ->
MovieCard(movie = movie, onClick = { onMovieSelected(movie) })
}
}
}
}
}
6. Add a carousel to the catalog-browser screen to highlight featured content
Carousel is a commonly adapted UI component that automatically updates its slides after a specified duration. It's commonly used to highlight featured content.
To add a carousel to the catalog-browser screen to highlight movies in the featured content list, follow these steps:
- Open the
com.example.tvcomposeintroduction.ui.screens.CatalogBrowser
file. - Call the
item
function to add an item to theLazyColumn
composable function. - Declare
featuredMovieList
as a delegated property in the lambda passed to theitem
function and then set theState
object to be delegated, which is collected from thecatalogBrowserViewModel.featuredMovieList
attribute. - Call the
Carousel
composable function inside theitem
function and then pass in the following parameters:
- The size of the
featuredMovieList
variable through aslideCount
parameter. - A
Modifier
object to specify carousel size with theModifier.fillMaxWidth
andModifier.height
methods. TheCarousel
composable function uses 376 dp of the height by passing a376.dp
value to theModifier.height
method. - A lambda called with an integer value that indicates the index of the visible carousel item.
- Retrieve the
Movie
object from thefeaturedMovieList
variable and the given index value. - Add a
Box
composable function to theCarousel
composable function. - Add a
Text
composable function to theBox
composable function to display the movie title.
CatalogBrowser.kt
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun CatalogBrowser(
modifier: Modifier = Modifier,
catalogBrowserViewModel: CatalogBrowserViewModel = hiltViewModel(),
onMovieSelected: (Movie) -> Unit = {}
) {
val categoryList by
catalogBrowserViewModel.categoryList.collectAsStateWithLifecycle()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 32.dp)
) {
item {
val featuredMovieList by catalogBrowserViewModel.featuredMovieList.collectAsStateWithLifecycle()
Carousel(
slideCount = featuredMovieList.size,
modifier = Modifier
.fillMaxWidth()
.height(376.dp)
) { indexOfCarouselSlide ->
val featuredMovie =
featuredMovieList[indexOfCarouselSlide]
Box {
Text(text = featuredMovie.title)
}
}
}
items(categoryList) { category ->
Text(text = category.name)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(category.movieList) { movie ->
MovieCard(movie = movie, onClick = { onMovieSelected(movie) })
}
}
}
}
}
Display background images
Box
composable function puts one component on top of another. Refer to Layout basics for details.
To display background images, follow these steps:
- Call the
AsyncImage
composable function to load the background image associated with theMovie
object before theText
composable function. - Update the position and text style of the
Text
composable function for better visibility. - Set a placeholder to the
AsyncImage
composable function to avoid layout shift. The starter code has a placeholder as a drawable that you can reference with theR.drawable.placeholder
.
CatalogBrowser.kt
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun CatalogBrowser(
modifier: Modifier = Modifier,
catalogBrowserViewModel: CatalogBrowserViewModel = hiltViewModel(),
onMovieSelected: (Movie) -> Unit = {}
) {
val categoryList by
catalogBrowserViewModel.categoryList.collectAsStateWithLifecycle()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 32.dp)
) {
item {
val featuredMovieList by catalogBrowserViewModel.featuredMovieList.collectAsStateWithLifecycle()
Carousel(
slideCount = featuredMovieList.size,
modifier = Modifier
.fillMaxWidth()
.height(376.dp),
) { indexOfCarouselItem ->
val featuredMovie = featuredMovieList[indexOfCarouselItem]
Box{
AsyncImage(
model = featuredMovie.backgroundImageUrl,
contentDescription = null,
placeholder = painterResource(
id = R.drawable.placeholder
),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Text(text = featuredMovie.title)
}
}
}
items(categoryList) { category ->
Text(text = category.name)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(category.movieList) { movie ->
MovieCard(movie = movie, onClick = { onMovieSelected(movie) })
}
}
}
}
}
Add a screen transition to the details screen
You can add a Button
to the carousel so that users can trigger a screen transition to the details screen by clicking the button.
To let users see the details of the movie in the visible carousel on the details screen, follow these steps:
- Call
Column
composable function in theBox
composable in theCarousel
composable - Move the
Text
composable in theCarousel
to theColumn
composable function - Call
Button
composable function after theText
composable function in theColumn
composable function - Call
Text
composable function in theButton
composable function with the return value ofstringResource
function called withR.string.show_details
. - Call the
onMovieSelected
function with thefeaturedMovie
variable in the lambda passed to theonClick
parameter of theButton
composable function
CatalogBrowser.kt
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun CatalogBrowser(
modifier: Modifier = Modifier,
catalogBrowserViewModel: CatalogBrowserViewModel = hiltViewModel(),
onMovieSelected: (Movie) -> Unit = {}
) {
val categoryList by
catalogBrowserViewModel.categoryList.collectAsStateWithLifecycle()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 32.dp)
) {
item {
val featuredMovieList by catalogBrowserViewModel.featuredMovieList.collectAsStateWithLifecycle()
Carousel(
slideCount = featuredMovieList.size,
modifier = Modifier
.fillMaxWidth()
.height(376.dp),
) { indexOfCarouselItem ->
val featuredMovie = featuredMovieList[indexOfCarouselItem]
Box {
AsyncImage(
model = featuredMovie.backgroundImageUrl,
contentDescription = null,
placeholder = painterResource(
id = R.drawable.placeholder
),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Column {
Text(text = featuredMovie.title)
Button(onClick = { onMovieSelected(featuredMovie) }) {
Text(text = stringResource(id = R.string.show_details))
}
}
}
}
}
items(categoryList) { category ->
Text(text = category.name)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(category.movieList) { movie ->
MovieCard(movie = movie, onClick = { onMovieSelected(movie) })
}
}
}
}
}
Optional: Adjust the layout
To adjust the layout of the carousel, follow these steps:
- Assign
backgroundColor
value withMaterialTheme.colorScheme.background
value in theCarousel
composable function - Wrap the
Column
composable function with aBox
composable - Pass
Alignment.BottomStart
value to thecontentAlignment
parameter of theBox
component. - Pass the
fillMaxSize
modifier to the modifier parameter of theBox
composable function. ThefillMaxSize
modifier is created withModifier.fillMaxSize()
function. - Call the
drawBehind()
method over thefillMaxSize
modifier passed to theBox
composable - In the lambda passed to the
drawBehind
modifier, assignbrush
value with aBrush
object that is created by callingBrush.linearGradient
function with a list of twoColor
object. The list is created by callinglistOf
function withbackgroundColor
value andColor.Transparent
value. - Call
drawRect
with thebrush
object in the lambda passed to thedrawBehind
modifier to make a srim layer over the background image - Specify the padding of the
Column
composable function with thepadding
modifier that is created by callingModifier.padding
with20.dp
value. - Add a
Spacer
composable function with20.dp
value between theText
composable and theButton
composable in theColumn
composable function
CatalogBrowser.kt
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun CatalogBrowser(
modifier: Modifier = Modifier,
catalogBrowserViewModel: CatalogBrowserViewModel = hiltViewModel(),
onMovieSelected: (Movie) -> Unit = {}
) {
val categoryList by catalogBrowserViewModel.categoryList.collectAsStateWithLifecycle()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(32.dp),
contentPadding = PaddingValues(horizontal = 58.dp, vertical = 36.dp)
) {
item {
val featuredMovieList by
catalogBrowserViewModel.featuredMovieList.collectAsStateWithLifecycle()
Carousel(
itemCount = featuredMovieList.size,
modifier = Modifier
.fillMaxWidth()
.height(376.dp),
) { indexOfCarouselItem ->
val featuredMovie = featuredMovieList[indexOfCarouselItem]
val backgroundColor = MaterialTheme.colorScheme.background
Box {
AsyncImage(
model = featuredMovie.backgroundImageUrl,
contentDescription = null,
placeholder = painterResource(
id = R.drawable.placeholder
),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Box(
contentAlignment = Alignment.BottomStart,
modifier = Modifier
.fillMaxSize()
.drawBehind {
val brush = Brush.horizontalGradient(
listOf(backgroundColor, Color.Transparent)
)
drawRect(brush)
}
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = featuredMovie.title,
style = MaterialTheme.typography.displaySmall
)
Spacer(modifier = Modifier.height(28.dp))
Button(onClick = { onMovieSelected(featuredMovie) }) {
Text(text = stringResource(id = R.string.show_details))
}
}
}
}
}
}
items(categoryList) { category ->
Text(text = category.name)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.height(200.dp)
) {
items(category.movieList) { movie ->
MovieCard(
movie,
onClick = {
onMovieSelected(it)
}
)
}
}
}
}
}
7. Get the solution code
To download the solution code for this codelab, do one of the following:
- Click the following button to download it as a zip file, and then unzip it and open it in Android Studio.
- Retrieve it with Git:
$ git clone https://github.com/android/tv-codelabs.git $ cd tv-codelabs $ git checkout solution $ cd IntroductionToComposeForTV
8. Congratulations.
Congratulations! You learned the basics of Compose for TV:
- How to implement a screen to show a content list by combining LazyColumn and LazyLow.
- Basic screen implementation to show content details.
- How to add screen transitions between the two screens.