Introduction to Compose for TV

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:

The catalog browser displays a list of featured movies\nwith a carousel on top.\nThe screen also displays a list of movies for each category.

  • A details screen that shows the metadata of a selected video, such as the title, description, and length. It looks like the following image:

The details screen displays the movie's metadata,\nincluding its title, studio, and short description.\nThe metadata is displayed on the background image associated with the movie.

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:

$ 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 the solution.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:

  1. In Android Studio, open the starter code's CatalogBrowser.kt file, and then add a LazyColumn composable function to the CatalogBrowser composable function.
  2. Call the catalogBrowserViewModel.categoryList.collectAsStateWithLifeCycle() method to collect the flow as a State object.
  3. Declare categoryList as a delegated property of the State object that you created in the previous step.
  4. Call the items function with the categoryList variable as a parameter.
  5. 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:

  1. Add the LazyRow composable function and then pass a lambda to it.
  2. In the lambda, call the items function with the category.movieList attribute value and then pass a lambda to it.
  3. In the lambda passed to the items function, call the MovieCard composable function with a Movie 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

  1. To set the gap between categories, pass an Arrangement object to the LazyColumn composable function with the verticalArrangement parameter. The Arrangement object is created by calling the Arrangement#spacedBy method.
  2. To set the gap between movie cards, pass an Arrangement object to the LazyRow composable function with the horizontalArrangement parameter.
  3. To set an indentation to the column, pass a PaddingValue object with the contentPadding 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:

  1. Add a Column composable function, and then set 32 dp vertical and 48 dp horizontal clearance around the column with the Modifier object created by the Modifier.padding method.
  2. Add a Text composable function to display the movie title.
  3. Add a Text composable function to display the studio name.
  4. 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:

  1. Add a Box composable function as a wrapper of the Column composable function with the modifier object passed through the Details composable function.
  2. In the Box composable function, call the fillMaxSize method of the modifier object to make the Box composable function fill the maximum size that can be allocated to the Details composable function.
  3. Add an AsyncImage composable function with the following parameters to the Box composable function:
  • Set the backgroundImageUrl attribute's value of the given Movie object to a model parameter.
  • Pass null to a contentDescription parameter.
  • Pass a ContentScale.Crop object to a contentScale parameter. To see the different ContentScale options, see Content scale.
  • Pass the return value of the Modifier.fillMaxSize method to the modifier 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:

  1. Set the MaterialTheme.typography.displayMedium property to the text style of the movie title.
  2. Set the MaterialTheme.typography.bodySmall property to the text style of the second Text composable functions.
  3. Set the MaterialTheme.colorScheme.background property to the background color of the Column composable function with the Modifier.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:

  1. Set the Box composable function to use the entire available space with the fillMaxSize modifier
  2. Set the background of the Box composable function with with the background modifier to fill the background with a linear gradient that is created by calling Brush.linearGradient function with a list of Color objects containing MaterialTheme.colorScheme.background value and Color.Transparent
  3. Set the 48.dp horizontal and 24.dp vertical clearance around the Column composable function with the padding Modifier
  4. Set the with of the Column composable function with the width modifier that is created by calling Modifier.width function with 0.5f value
  5. Add the 8.dp space between the second Text composable function and the third Text composable with Spacer. The height of the Spacer composable function is specified with the height modifier that is created with Modifier.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.

  1. Open the com.example.tvcomposeintroduction.ui.screens.CatalogBrowser file.
  2. Pass a lambda function to the MovieCard composable function with an onClick parameter.
  3. Call the onMovieSelected callback with the movie object associated with the MovieCard 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:

  1. Open the com.example.tvcomposeintroduction.ui.screens.CatalogBrowser file.
  2. Call the item function to add an item to the LazyColumn composable function.
  3. Declare featuredMovieList as a delegated property in the lambda passed to the item function and then set the State object to be delegated, which is collected from the catalogBrowserViewModel.featuredMovieList attribute.
  4. Call the Carousel composable function inside the item function and then pass in the following parameters:
  • The size of the featuredMovieList variable through a slideCount parameter.
  • A Modifier object to specify carousel size with the Modifier.fillMaxWidth and Modifier.height methods. The Carousel composable function uses 376 dp of the height by passing a 376.dp value to the Modifier.height method.
  • A lambda called with an integer value that indicates the index of the visible carousel item.
  1. Retrieve the Movie object from the featuredMovieList variable and the given index value.
  2. Add a Box composable function to the Carousel composable function.
  3. Add a Text composable function to the Box 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:

  1. Call the AsyncImage composable function to load the background image associated with the Movie object before the Text composable function.
  2. Update the position and text style of the Text composable function for better visibility.
  3. 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 the R.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:

  1. Call Column composable function in the Box composable in the Carousel composable
  2. Move the Text composable in the Carouselto the Column composable function
  3. Call Button composable function after the Textcomposable function in the Column composable function
  4. Call Text composable function in the Button composable function with the return value of stringResource function called with R.string.show_details.
  5. Call the onMovieSelected function with the featuredMovie variable in the lambda passed to the onClick parameter of the Button 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:

  1. Assign backgroundColor value with MaterialTheme.colorScheme.background value in the Carousel composable function
  2. Wrap the Column composable function with a Box composable
  3. Pass Alignment.BottomStart value to the contentAlignment parameter of the Box component.
  4. Pass the fillMaxSize modifier to the modifier parameter of the Box composable function. The fillMaxSize modifier is created with Modifier.fillMaxSize() function.
  5. Call the drawBehind() method over the fillMaxSize modifier passed to the Box composable
  6. In the lambda passed to the drawBehind modifier, assign brush value with a Brush object that is created by calling Brush.linearGradient function with a list of two Color object. The list is created by calling listOf function with backgroundColor value and Color.Transparent value.
  7. Call drawRect with the brush object in the lambda passed to the drawBehind modifier to make a srim layer over the background image
  8. Specify the padding of the Column composable function with the padding modifier that is created by calling Modifier.padding with 20.dp value.
  9. Add a Spacer composable function with 20.dp value between the Text composable and the Button composable in the Column 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.