Jetpack Compose Tutorial for Android: Getting Started

In this Jetpack Compose tutorial, you’ll learn to use the new declarative UI framework being developed by the Android team by creating a cookbook app. By Joey deVilla.

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

Displaying the List of Recipes

In a Views-based project, you’d turn to a UI element like RecyclerView to display the list of recipes. The Jetpack Compose equivalent is the LazyColumn, whose name comes from “lazy loading,” which follows RecyclerView’s “don’t load it into memory until you actually need it” philosophy, but it requires a lot less code.

Create a file by right-clicking on the composecookbook package and selecting New ▸ New Kotlin Class/File. Select File and give the file the name RecipeList. Finally, add this code to the file:

import androidx.compose.foundation.lazy.items

@Composable
fun RecipeList(recipes: List<Recipe>) {
  LazyColumn {
    items(recipes) {
      RecipeCard(it)
    }
  }
}

@Composable
@Preview
fun RecipeListPreview() {
  RecipeList(defaultRecipes)
}

The items() function takes a List and a lambda with composables that defines how list items should be rendered. In the case of this app, each recipe in the list is rendered by passing a Recipe object to RecipeCard.

Because you implemented RecipeListPreview() in addition to RecipeList(), you’ll be able to see the list in the preview:

The recipe list code and preview.

It’s time to make the app show the list.

Wiring Everything Up

Only a little work is left to do. First, replace the Greeting method in MainActivity.kt with this Cookbook() method:

@Composable
fun Cookbook() {
  RecipeList(recipes = defaultRecipes)
}

You’ve done all of the work. All that’s left is to go to the Main activity, go inside onCreate() and replace the Greeting() call with the following:

Cookbook()

Although it’s not necessary, it’s nice to have a preview composable. Replace GreetingPreview() with this function:

@Preview(showBackground = true)
@Composable
fun CookbookPreview() {
  ComposeCookbookTheme {
    Cookbook()
  }
}

You can see the results of your work in the preview, but it’s more fun to see them in a running app. To do that, go to MainActivity’s onCreate() method and replace the call to Greeting() with a call to Cookbook, then build and run the app. You’ll see this:

The app running in the emulator, displaying the recipe list.

The app looks good, but you can’t differentiate the individual recipe cards. To clean this UI up, add some padding between items.

Open the RecipeCard file again and update the RecipeCard function to pass a Modifier to your Surface:

@Composable
fun RecipeCard(recipe: Recipe, modifier: Modifier) { // Added a modifier parameter here...
  Surface(
    modifier = modifier, // ...and added a modifier argument here
    color = MaterialTheme.colorScheme.surface,
    border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondary),
    shape = RoundedCornerShape(12.dp),
    tonalElevation = 2.dp,
    shadowElevation = 10.dp
  ) {
    ...
  }
}

The changes will require adding a modifier argument to any calls to RecipeCard(). You’ll need to update RecipeCardPreview()

@Composable
@Preview
fun RecipeCardPreview() {
  RecipeCard(defaultRecipes[0], Modifier.padding(16.dp)) // Added a modifier argument here
}

… as well as RecipeList() (in RecipeList.kt):

@Composable
fun RecipeList(recipes: List<Recipe>) {
  LazyColumn {
    items(recipes) {
      RecipeCard(it, Modifier.padding(16.dp)) // Added a modifier argument here
    }
  }
}

Once again, you’ll see the changes in the recipe list preview, but it’s more fun to see them in the running app:

The app running in the emulator, displaying the recipe list with padding between recipes.

Adding a Toolbar

Android apps by default have a bar at the top that identifies the app and provides options to the user. In Material Design 3, this bar is called a top app bar, and it’s the final element you’ll add to Compose Cookbook.

In MainActivity.kt, update Cookbook() to include a TopAppBar:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Cookbook() {
  Column(modifier = Modifier.fillMaxSize()) {
    TopAppBar(
      title = {
        Text(
          text = "Compose Cookbook",
          modifier = Modifier.testTag("topAppBarText")
        )
      },
      colors = TopAppBarDefaults.smallTopAppBarColors(containerColor = Color.LightGray),
      modifier = Modifier.testTag("topAppBar")
    )
    RecipeList(recipes = defaultRecipes)
  }
}

In the code you entered, you added a Column to the layout, which is the container for both the TopAppBar and RecipeList().

At the time of writing, the @OptIn(ExperimentalMaterial3Api::class) annotation is necessary. TopAppBar is an experimental UI element and subject to change. Because having a bar at the top of standard Android applications is one of the Material Design guidelines, any changes to it are likely to be small.

When you build and run the app, it’ll now look like this:

The Compose Cookbook app in its final form.

The intermediate folder in the download materials contains the version of the Compose Cookbook project that you’ve written up to this point.

Making the App a Little More “Real World”

The current implementation of Compose Cookbook has a problem. Unless you were writing it as a quick proof of concept, you wouldn’t code it this way. The list of recipes is hard-coded, which means the app isn’t taking advantage of one of Jetpack Compose’s best features: The ability for the UI to update in response to app’s state. A change in state — that is, a change to one or more variables that define the application’s data or how it works — should be reflected by any UI elements that display that state. It’s time to make the UI respond to changes in state, add a little interactivity, and do a little testing.

Add a ViewModel

The app’s state is simple — it’s the list of recipes. By moving this state into a ViewModel instance and connecting it to the composables that display the recipe information, the app will behave more like a real one, updating what it displays when the cookbook is updated by adding, modifying or removing recipes.

Create a file for the ViewModel for the list of recipes. Right-click on the composecookbook package, select New ▸ New Kotlin Class/File, select Class from the list and name the file RecipeListViewModel. Replace the empty class with the following:

import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class RecipeListViewModel: ViewModel() {

 private var recipeList = mutableStateListOf(
   Recipe(
     imageResource = R.drawable.noodles,
     title = "Ramen",
     ingredients = listOf("Noodles", "Eggs", "Mushrooms", "Carrots", "Soy sauce"),
     description = "Japan’s famous noodles-and-broth dish. It’s meant to be slurped LOUDLY."
   ),
   Recipe(
     imageResource = R.drawable.croissant,
     title = "Croissant",
     ingredients = listOf("Butter", "More butter", "A touch of butter", "Flour"),
     description = "This French pastry is packed with butter and cholesterol, as the best foods are."
   ),
   Recipe(
     imageResource = R.drawable.pizza,
     title = "Pizza",
     ingredients = listOf("Pizza dough", "Tomatoes", "Cheese", "Spinach", "Love"),
     description = "The official food of late-night coding sessions. Millions of programmers can’t be wrong!"
   ),
   Recipe(
     imageResource = R.drawable.produce,
     title = "Veggie Medley",
     ingredients = listOf("Vegetables"),
     description = "We had to put something healthy on the menu..."
   ),
   Recipe(
     imageResource = R.drawable.salad_egg,
     title = "Egg Salad",
     ingredients = listOf("Eggs", "Mayonnaise", "Paprika", "Mustard"),
     description = "It’s really just eggs in tasty, creamy goo. The vegetables in the photo are just for show."
   ),
   Recipe(
     imageResource = R.drawable.smoothie,
     title = "Fruit Smoothie",
     ingredients = listOf("Banana", "Kiwi", "Milk", "Cream", "Ice", "Flax seed"),
     description = "The healthy version of a milkshake. We’ll have a REAL milkshake later."
   )
 )

 private val _recipeListFlow = MutableStateFlow(recipeList)
 val recipeListFlow: StateFlow<List<Recipe>> get() = _recipeListFlow

}

The ViewModel contains three values:

  • recipeList: This is the list of recipes, now built using the mutableStateListOf() function to create a SnapshotStateList, a mutable list that can be observed by composables so that they can update when the list changes.
  • _recipeListFlow: This private property acts as a setter for the recipe list. The corresponding getter for the recipe list is derived from it.
  • recipeListFlow: Any composable that wants to access the contents of the recipe list and be informed when the list changes will use this public property as a getter.

With the ViewModel representing the list of recipes implemented, you can now connect it to the composable that displays the list. Update RecipeList() and RecipeListPreview() so that both now use the ViewModel as its parameter:

@Composable
fun RecipeList(viewModel: RecipeListViewModel) {
  // Convert the flow (of MutableStateList) into a State
  val recipeListState = viewModel.recipeListFlow.collectAsStateWithLifecycle()

  LazyColumn {
    items(recipeListState.value) {
      RecipeCard(it, Modifier.padding(16.dp)) // Added a modifier argument here
    }
  }
}

@Composable
@Preview
fun RecipeListPreview() {
  val viewModel = RecipeListViewModel()
  RecipeList(viewModel = viewModel)
}

RecipeList() stays apprised of changes to the list of recipes through recipeListFlow, RecipeListViewModel’s getter. The collectAsStateWithLifecycle() method returns a value that connects RecipeList() to RecipeListViewModel, causing the list to be redrawn whenever the contents of the recipe list changes. Note that the list of recipes is available through recipeListState’s value property.

Since the app no longer uses the static list of recipes, it’s useful to update the preview composable for the recipe card. Update it to use a local Recipe instance as shown below:

@Composable
@Preview
fun RecipeCardPreview() {
  val viewModel = RecipeListViewModel()
  val previewRecipe = Recipe(
    imageResource = R.drawable.noodles,
    title = "Ramen",
    ingredients = listOf("Noodles", "Eggs", "Mushrooms", "Carrots", "Soy sauce"),
    description = "Japan’s famous noodles-and-broth dish. It’s meant to be slurped LOUDLY."
  )
  RecipeCard(previewRecipe, Modifier.padding(16.dp))
}

You’ll also need to update the composables for the cookbook and its preview to use the ViewModel:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Cookbook(viewModel: RecipeListViewModel) {
  Column(modifier = Modifier.fillMaxSize()) {
    TopAppBar(
      title = {
        Text(
          text = "Compose Cookbook",
          modifier = Modifier.testTag("topAppBarText")
        )
      },
      colors = TopAppBarDefaults.smallTopAppBarColors(containerColor = Color.LightGray),
      modifier = Modifier.testTag("topAppBar")
    )
    RecipeList(viewModel)
  }
}

@Preview(showBackground = true)
@Composable
fun CookbookPreview() {
  val viewModel = RecipeListViewModel()
  ComposeCookbookTheme {
    Cookbook(viewModel = viewModel)
  }
}

Finally, you need to update MainActivity. This is where you create an instance of the ViewModel and pass it as a parameter to the Cookbook() composable:

class MainActivity : ComponentActivity() {
  private val viewModel = RecipeListViewModel()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      ComposeCookbookTheme {
      // A surface container using the 'background' color from the theme
        Surface(
          modifier = Modifier.fillMaxSize(),
          color = MaterialTheme.colorScheme.background
        ) {
          Cookbook(viewModel)
        }
      }
    }
  }
}

Finally, delete the Recipes.kt file from the project. You’re no longer using the defaultRecipes list defined in that file anymore.

Build and run the project. You shouldn’t notice any changes, because you’ve only made changes to the underlying implementation.