Jetpack Compose

Oct 11 2022 · Kotlin 1.7.10, Android 13, Android Studio Chipmunk

Part 1: Jetpack Compose Basics

06. Build Common UI Components - Part 1

Episode complete

Play next episode

Next
About this episode

Leave a rating/review

See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 05. Decouple Composables Next episode: 07. Build Common UI Components - Part 2

Get immediate access to this and 4,000+ other videos and books.

Take your career further with a Kodeco Personal Plan. With unlimited access to over 40+ books and 4,000+ professional videos in a single subscription, it's simply the best investment you can make in your development career.

Learn more Already a subscriber? Sign in.

Notes: 06. Build Common UI Components - Part 1

The student materials have been reviewed and are updated as of September 2022.

Transcript: 06. Build Common UI Components - Part 1

Intro

[Slide 1 - Material Design]

Android is famous for its simplistic and beautiful design, also known as Material Design.

It’s also well known for how all the simple UI components such as input fields, radio buttons and groups, check boxes and such, are all following Material design by default, and how they are easy to set up. At least for the most part.

[Slide 2 - Jetpack Compose]

And with Jetpack Compose, this carries over! Compose lets you apply Material without having to do any extra work! You also get to apply special modifiers and types of backgrounds, to make you apps look especially beautiful.

[Slide 3 - Lists of items]

Also, most applications show lists of data. This is because data is generally dynamic, and you may have only three items in a list, but you may also have a hundred.

For this reason, it’s important to know how to build lists in Compose!


There’s quite a lot of code to write and many features left to build, so let’s see how to add some of these components to your app! :]

Demo

Let’s start off by making more of your components reusable. Open the AddBookActivity. Now let’s move the DropdownMenu from the class to a separate file in the composeUi package, to make it reusable:

@Composable
fun GenrePicker(
  genres: List<Genre>,
  selectedGenreId: String,
  onItemPicked: (Genre) -> Unit
) {
  ...
}

Now that you have the signature and code in place, it’s time to implement the state handling.

  DropdownMenu(expanded = isGenresPickerOpen.value,
    onDismissRequest = { isGenresPickerOpen.value = false }) {
    for (genre in genres) {
      DropdownMenuItem(onClick = {
        onItemPicked(genre)
        isGenresPickerOpen.value = false
      }) {
        Text(text = genre.name)
      }
    }
  }

Also notice the changes you need to make, to make this fully reusable. You have to delegate the genre selection using a lambda function, and pass in the currently selected genre.

Now change the code in the AddBookActivity, to the following:

GenrePicker(genres = genres, onItemPicked = {
  _addBookState.value = _addBookState.value.copy(genreId = it.id)
}, selectedGenreId = _addBookState.value.genreId ?: "")

Also change the _addBookState and _genresState creation to the following:

private val _addBookState = mutableStateOf(AddBookState())
private val _genresState = mutableStateOf(emptyList<Genre>())	

This will ensure that the state of the picker is updated in Compose, any time you change your selected genre. Now build & run the app, and the picker should still work!

[Build & run the app]


Now that you have the Picker pulled out as a reusable component, you can proceed to build other components you’ll need to wrap up this episode.

[Slide 4 - Next things]

To wrap up the project for this episode, you need to finish the AddBookReviewActivity, the BookFilter feature, and the BooksFragment list of items.

That sounds like a lot, but it’s actually not! You just have to write a few more components. Let’s get to it. :]

[Switch to demo]

Let’s first finish the BooksFragment and all of its UI features. You’ll add a Bottom drawer, which is a special type of a modal view, where you can show the user extra options to filter their data or something similar.

In your case, you’ll build the modal drawer for the BookFilter, to be able to filter books by rating and by genre. This is also why you’ve had to pull out the GenrePicker to a separate component - you’ll re-use it in the Filter.

Open the BooksFragment if you haven’t already. Change the code to the following:

@Composable
fun BooksContent() {
    val bookFilterDrawerState = rememberBottomDrawerState(initialValue = BottomDrawerValue.Closed)

  Scaffold(topBar = { BooksTopBar(bookFilterDrawerState) },
    floatingActionButton = { AddNewBook(bookFilterDrawerState) }) {
    BookFilterModalDrawer(bookFilterDrawerState = bookFilterDrawerState)
  }
}

@Composable
fun BooksTopBar(bookFilterDrawerState: BottomDrawerState) {
  TopBar(
    actions = { FilterButton(bookFilterDrawerState) })
}

So far you’ve added the BottomDrawerState to different functions. Because the API is experimental, you have to add necessary annotations. Now add the actions to open and close the drawer.

@Composable
fun FilterButton(bookFilterDrawerState: BottomDrawerState) {
  val scope = rememberCoroutineScope()

  IconButton(onClick = {
    scope.launch {
      if (!bookFilterDrawerState.isClosed) {
        bookFilterDrawerState.close()
      } else {
        bookFilterDrawerState.expand()
      }
    }
  }) {
    Icon(Icons.Default.Edit, tint = Color.White, contentDescription = "Filter")
  }
}
               
@Composable
fun AddNewBook(bookFilterDrawerState: BottomDrawerState) {
  val scope = rememberCoroutineScope()

  FloatingActionButton(
    content = { Icon(Icons.Filled.Add, contentDescription = "Add Book") },
    onClick = {
      scope.launch {
        bookFilterDrawerState.close()
        showAddBook()
      }
    },
  )
}

The important part here is that you’re creating a BottomDrawerValue state, which will tell you if the drawer is open or closed, and give you the ability to open or close it. It’s just like any state value, with some extra functions!

However, because the functions to close or open the drawer are suspend functions, you need to use coroutines. You did so by preparing a coroutine scope, using rememberCoroutineScope() and launching a coroutine!

Now add the BookFilterModalDrawer function:

  @ExperimentalMaterialApi
  @Composable
  fun BookFilterModalDrawer(bookFilterDrawerState: BottomDrawerState) {
    val books = _booksState.value ?: emptyList()

    BottomDrawer(
      drawerState = bookFilterDrawerState,
      drawerContent = {
        BookFilterModalDrawerContent(Modifier.align(CenterHorizontally), bookFilterDrawerState)
      },
      content = { BooksList(books) })
  }

To build a drawer, you just use the BottomDrawer component, pass in the state, and the drawerContent and content composable functions.

This lets you differentiate between the content that is shown at all times, and the drawer content you show only when the drawer is open or expanded.

Now add the drawer composable function, like so:

@ExperimentalMaterialApi
@Composable
fun BookFilterModalDrawerContent(
  modifier: Modifier,
  bookFilterDrawerState: BottomDrawerState
) {
  val scope = rememberCoroutineScope()
  val genres = _genresState.value ?: emptyList()

  BookFilter(modifier, filter, genres, onFilterSelected = { newFilter ->
    scope.launch {
      bookFilterDrawerState.close()
      filter = newFilter
      loadBooks()
    }
  })
}

This function lets you build the BookFilter composable, and you pass in the data it will need to display filters, and let you select a filter to change the state.

Notice how this function doesn’t yet exist, so you’ll have to build it. You’re slowly getting there!

Create a new file called BookFilter in the ui package within books. Create the ui package, if it’s missing.

Now start slowly by adding the following code:

@Composable
fun BookFilter(
  modifier: Modifier,
  filter: Filter?,
  genres: List<Genre>,
  onFilterSelected: (Filter?) -> Unit
) {
  val currentFilter = remember {
    mutableStateOf(
      when (filter) {
        null -> 0
        is ByGenre -> 1
        is ByRating -> 2
      }
    )
  } // 0 - no filter, 1 - ByGenre, 2 - By Rating

  val currentGenreFilter = remember { mutableStateOf<Genre?>(null) }
  val currentRatingFilter = remember { mutableStateOf(0) }

This code is the base state setup for the BookFilter. You can have a previous filter, which is why that’s one of the parameters in the function. Then you need to receive a list of genres, to be able to filter by them. Finally, you need to pass in a lambda function to update the filter once you select it.

The state you’re storing is what the current filter is and what the genre and rating selection are.

Now add the following code:

Column(
  modifier = modifier,
  horizontalAlignment = Alignment.CenterHorizontally
) {

  Column {
  }
}

The filter will be a set of three radio buttons, that you’ll build in a moment, a special selection for the genre or rating depending on your filter choice, at the end, and a button to confirm the filter.

You passed in the modifier here to center the container horizontally, and the horizontalAlignment, to center each specific item.

Now add the following code:

Row {
  RadioButton(
    selected = currentFilter.value == 0,
    onClick = { currentFilter.value = 0 },
    modifier = Modifier.padding(8.dp)
  )
  Text(
    text = stringResource(id = R.string.no_filter),
    modifier = Modifier.align(Alignment.CenterVertically)
  )
}

Each radio button will be similar, as you’ll either select or unselect it and add an onClick listener to the component. You also need to add a text that represents the option, and add remaining options.

Row {
  RadioButton(
    selected = currentFilter.value == 1,
    onClick = { currentFilter.value = 1 },
    modifier = Modifier.padding(8.dp)
  )

  Text(
    text = stringResource(id = R.string.filter_by_genre),
    modifier = Modifier.align(Alignment.CenterVertically)
  )
}
Row {
  RadioButton(
    selected = currentFilter.value == 2,
    onClick = { currentFilter.value = 2 },
    modifier = Modifier.padding(8.dp)
  )

  Text(
    text = stringResource(id = R.string.filter_by_rating),
    modifier = Modifier.align(Alignment.CenterVertically)
  )
}

This is quite a lot of code, but it’s fairly simple. You want each radio button to have a text next to it, which is why each Radio button is wrapped in a Row - a horizontal container, followed by a Text element.

Each of the buttons changes the current filter type, and has a specific text describing what type of a filter it represents, such as By Genre, By Rating and None.

You also select the radio group based on the currentFilter.value integer. Now add the next piece of code for specific options when filtering books:

val currentlySelectedGenre = currentGenreFilter.value

if (currentFilter.value == 2) {
  RatingBar(
    range = 1..5,
    currentRating = currentRatingFilter.value,
    isLargeRating = true,
    onRatingChanged = { newRating -> currentRatingFilter.value = newRating })
}

The rating bar is just one of the components of the filter that you’ll build later on, now add the GenrePicker.

if (currentFilter.value == 1) {
  GenrePicker(
    genres = genres,
    selectedGenreId = currentlySelectedGenre?.id ?: "",
    onItemPicked = {
      currentGenreFilter.value = it
    }
  )
}

A great thing about compose and changing the UI state, showing and hiding components, is that all you need to do is put an if check, and your condition when you want the component to be shown.

If the if check fails, the component function won’t be called, and it won’t show up in the UI tree. Pretty cool!

These two checks show either the GenrePicker or the RatingBar, depending on the filter you’ve selected. You already have the GenrePicker decoupled, so everything works there, but you don’t have the RatingBar. You’ll build that in the next episode, let’s add the finishing action button to wrap up this filter:

ActionButton(
  modifier = Modifier.fillMaxWidth(),
  text = stringResource(id = R.string.confirm_filter),
  onClick = {
    val newFilter = when (currentFilter.value) {
      0 -> null
        ...
    }
  }
)

Each number corresponds to one filter type, like you defined before. Now add the other two options and an else clause to handle illegal states.

      1 -> ByGenre(currentGenreFilter.value?.id ?: "")
      2 -> ByRating(currentRatingFilter.value)
      else -> throw IllegalArgumentException("Unknown filter!")
    }

    onFilterSelected(newFilter)

Within this button, you basically just confirm the filter, and send it back to the user and BooksFragment, when you’re ready to filter the data. Depending on the integer for the currentFilter, you build a different type of a filter.

Now that’s quite a lot of code you’ve written, but there’s still a lot more to go through! You’ll continue by building the RatingBar, in the next episode. See you there! :]