Chapters

Hide chapters

Jetpack Compose by Tutorials

First Edition · Android 11 · Kotlin 1.4 · Android Studio Canary

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

8. Applying Material Design to Compose
Written by Denis Buketa

Well done! You’ve arrived at the last chapter in this section. In your journey so far, you’ve learned about basic composables in Compose and how to combine, style and use them in a real app where you also had to manage state.

In this chapter, you’ll:

  • Learn how to use Material Design composables, which Jetpack Compose provides for you.
  • Go over state management in more depth.
  • Complete the Save Note screen.
  • Learn about Material theming.
  • Change JetNotes to support a dark theme.

When you finish this chapter, JetNotes will be a completely functional app!

Opening the Notes screen

Before you can start working on the Save Note screen, you need a way to open it. By looking at the design, you can see that you’ve planned two ways to do that:

  1. By clicking a floating action button (FAB), which will open the Save Note screen in Create mode, where the user create a new note.
  2. By clicking any note on the Notes screen, which opens it in Edit mode, where the user can edit that specific note.

You’ll start with the first case. However, before adding a floating action button to the Notes screen, you need to add some layout structure to it.

Notes Screen
Notes Screen

Take a moment to look at the different parts of the screen. You have the:

  • Top bar
  • Body content
  • Floating action button
  • App drawer

This is a common layout structure for Android apps. Most apps today follow a similar design. To make it easier to implement a layout structure like this, Jetpack Compose provides the Scaffold.

Before going into any details, you’ll add a Scaffold to the Notes screen.

Adding Scaffold

To follow along with the code examples, open this chapter’s starter project in Android Studio and select Open an existing project.

Next, navigate to 08-applying-material-design-to-compose/projects and select the starter folder as the project root. Once the project opens, let it build and sync and you’re ready to go!

Note that you can see the completed JetNotes app by skipping ahead to the final project.

For now, open NotesScreen.kt and replace Column() with Scaffold():

@Composable
fun NotesScreen(viewModel: MainViewModel) {

  // Observing notes state from MainViewModel
  ...

  Scaffold(
    topBar = {
      TopAppBar(
        title = "JetNotes",
        icon = Icons.Filled.List,
        onIconClick = {}
      )
    },
    bodyContent = {
      if (notes.isNotEmpty()) {
        NotesList(
          notes = notes,
          onNoteCheckedChange = { 
          	viewModel.onNoteCheckedChange(it) 
          },
          onNoteClick = { viewModel.onNoteClick(it) }
        )
      }
    }
  )
}

Here’s a breakdown of what you just did, You removed the Column() and its children, which you used to stack a TopAppBar and a NotesList on top of each other, and you replaced it with Scaffold().

Now, you need to add an import for Scaffold.

import androidx.compose.material.Scaffold

Build and run. You’ll notice that the behavior is the same as before:

Notes Screen
Notes Screen

Scaffold implements the basic Material Design visual layout structure. It provides an API to combine several Material composables to construct your screen by ensuring they have a proper layout strategy and by collecting necessary data so the components will work together correctly.

This is the Scaffold() signature from the Jetpack Compose documentation:

@Composable 
fun Scaffold(
  modifier: Modifier = Modifier,
  scaffoldState: ScaffoldState = rememberScaffoldState(),
  topBar: @Composable () -> Unit = emptyContent(), // Top Bar
  bottomBar: @Composable () -> Unit = emptyContent(), // Bottom Bar
  snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
  floatingActionButton: @Composable () -> Unit = emptyContent(), // FAB
  floatingActionButtonPosition: FabPosition = FabPosition.End,
  isFloatingActionButtonDocked: Boolean = false,
  drawerContent: @Composable (ColumnScope.() -> Unit)? = null, // Navigation Drawer
  drawerGesturesEnabled: Boolean = true,
  drawerShape: Shape = MaterialTheme.shapes.large,
  drawerElevation: Dp = DrawerConstants.DefaultElevation,
  drawerBackgroundColor: Color = MaterialTheme.colors.surface,
  drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
  drawerScrimColor: Color = DrawerConstants.defaultScrimColor,
  backgroundColor: Color = MaterialTheme.colors.background,
  contentColor: Color = contentColorFor(backgroundColor),
  bodyContent: @Composable (PaddingValues) -> Unit // Screen Content
)

Notice how it provides an API for the top bar, bottom bar, floating action button, drawer and content. You can pick and choose from these options, using only what you need.

In NotesScreen.kt, you only used topBar and bodyContent. Scaffold() will make sure that the content you provided for the topBar is at the top of the screen and the content you provided for the bodyContent is below the topBar content. That’s why the screen looked the same when you replaced Column() with Scaffold().

Resurrecting the app drawer

In the previous chapter, you temporarily removed the app drawer from the Notes screen. Now, it’s time to put it back, slightly improved.

As you just learned, Scaffold() allows you to add app drawer content. It also lets the user pull the drawer out by dragging it from the left side of the screen.

Add AppDrawer back to the Notes screen by updating the code:

@Composable
fun NotesScreen(viewModel: MainViewModel) {

  // Observing notes state from MainViewModel
  ...

  // here - Drawer state
  val scaffoldState: ScaffoldState = rememberScaffoldState()

  Scaffold(
    topBar = {
      TopAppBar(
        title = "JetNotes",
        icon = Icons.Filled.List,
        onIconClick = { 
          // here - Drawer open
          scaffoldState.drawerState.open() 
        }
      )
    },
    scaffoldState = scaffoldState, // here - Scaffold state
    drawerContent = { // here - Drawer UI
      AppDrawer(
        currentScreen = Screen.Notes,
        closeDrawerAction = { 
          // here - Drawer close
          scaffoldState.drawerState.close()
        }
      )
    },
    ...
  )
}

First, you passed an AppDrawer() for the drawerContent parameter.

By passing Screen.Notes to currentScreen, you made sure the notes item is selected when the user opens the app drawer. For the second parameter, you passed an action that manages the scaffoldState.

Look at the line above Scaffold(). There, you added val scaffoldState: ScaffoldState = rememberScaffoldState(). This is a new concept for you, which you’ll learn more about next. The knowledge from the previous chapter will help you understand it better. :]

Don’t forget to add all the necessary imports:

import androidx.compose.material.ScaffoldState
import androidx.compose.material.rememberScaffoldState
import com.raywenderlich.android.jetnotes.routing.Screen
import com.raywenderlich.android.jetnotes.ui.components.AppDrawer

Now that you’ve added the drawer, you can finally see if works like before. Build and run your app.

Notes screen and app drawer
Notes screen and app drawer

Again, you can open the app drawer by either clicking the icon in the top bar or dragging right from the left side of the screen.

Try pulling out the app drawer and then changing the device’s orientation. You’ll see that when the app recreates the activity, the app drawer will still be open, meaning remember() successfully preserved the state. But how exactly does that work?

Memory in composable functions

Scaffold() can manage two composables that have state: app drawer and snackbar. Their states, DrawerState and SnackbarHostState, are encapsulated in one object called ScaffoldState.

If you use one of these composables with Scaffold, you need to make sure that their state updates accordingly and is preserved during recomposition.

Compose lets you store values in the composition tree. Another way of saying this is that composable functions can access what happened the last time they were called. This is where remember() can help you.

Using remember

Here’s how remember() looks in code:

@Composable
fun <T> remember(calculation: () -> T): T

There are a couple of different variations of remember(). This one will remember the value that calculation() produces, which is evaluated during composition. During the recomposition, remember() will return the value produced by its composition().

When you added AppDrawer() to Scaffold(), you used rememberScaffoldState() to create a ScaffoldState. This is its signature in the Jetpack Compose documentation:

@Composable
fun rememberScaffoldState(
  drawerState: DrawerState = rememberDrawerState(
    DrawerValue.Closed
  ), 
  snackbarHostState: SnackbarHostState = remember { 
    SnackbarHostState() 
  }
): ScaffoldState

Notice how here, remember() creates and remembers a SnackbarHostState. For DrawerState, you use rememberDrawerState(), which will create and remember a DrawerState.

Look at that function’s implementation:

@Composable
fun rememberDrawerState(
    initialValue: DrawerValue,
    confirmStateChange: (DrawerValue) -> Boolean = { true }
): DrawerState {
  val clock = AmbientAnimationClock.current.asDisposableClock()
  return rememberSavedInstanceState(
    clock,
    saver = DrawerState.Saver(clock, confirmStateChange)
  ) {
    DrawerState(initialValue, clock, confirmStateChange)
  }
}

Here, you used rememberSavedInstanceState(), which behaves similarly to remember() except that the stored value will survive the activity or process recreation by using the saved instance state mechanism.

In NotesScreen(), you used rememberScaffoldState() to create the ScaffoldState.

When it comes to the DrawerState, rememberScaffoldState() relies on rememberSavedInstanceState() to preserve the state during the recomposition and Activity recreation. In this example, there are two times the state will change: when the user opens the app drawer and when they close it.

You added two actions, and you made sure the ScaffoldState updates when the user clicks an icon or when AppDrawer() passes up the close drawer event.

For SnackbarState, rememberScaffoldState() relies on remember() to preserve the if the snackbar is visible or not during the recomposition. However, you won’t worry about that for this app because it doesn’t use a snackbar.

Finally, you passed scaffoldState to Scaffold() by using the scaffoldState. That lets Scaffold() display the correct state when it changes. You’re reading a lot about state and Scaffold() and how it preserves its state, but it’s easier to just visualize what happens in the Jetpack Compose tree.

Remember’s effect on the composition tree

Here’s how the composition tree looks for NotesScreen().

Notes Screen - Composition Tree
Notes Screen - Composition Tree

In Chapter 5, “Combining Composables”, you learned that there can be other types of nodes in the composition tree beside UI elements. This is one example. Calling remember() will result in an additional node in the tree that stores a specific value.

This also means that values remembered in composition are forgotten as soon as their calling composable is removed from the tree. They will be re-initialized if the calling composable moves in the tree. For example, that could happen if you move items in a LazyColumn or a LazyColumnFor.

This was a nice digression to state management. But now it’s time to come back to Material Design composables. :]

Continue to the next section, where you’ll add a FloatingActionButton to the Notes screen.

Adding the FAB

A floating action button represents the primary action of a screen. In the Notes screen, the primary action is the action to create a new note.

In the previous section, you learned that Scaffold() already provides an API to add the FAB to the layout. To implement it, update Scaffold() in NotesScreen.kt:

@Composable
fun NotesScreen(viewModel: MainViewModel) {

  // Observing notes state from MainViewModel
  ...

  val scaffoldState: ScaffoldState = rememberScaffoldState()

  Scaffold(
    ...,
    floatingActionButtonPosition = FabPosition.End,
    floatingActionButton = {
      FloatingActionButton(
          onClick = { viewModel.onCreateNewNoteClick() },
          contentColor = MaterialTheme.colors.background,
          content = { Icon(Icons.Filled.Add) }
      )
    },
    ...
  )
}

Here, you used FloatingActionButton() and passed it as the floatingActionButton parameter. You then passed FabPosition.End as the floatingActionButtonPosition parameter, which positions the FAB in the bottom-right corner.

FloatingActionButton() exposes a few more parameters, but you only used what you need. Clicking the button executes viewModel.onCreateNewNoteClick(). With this, you’re passing an event up to the ViewModel, which can then decide what to do with it.

For the content, you passed an icon that renders as a plus sign. To make the content of the icon the same color as the background, you passed MaterialTheme.colors.background as the contentColor.

Android Studio will complain if you don’t add these imports as well:

import androidx.compose.material.*
import androidx.compose.material.icons.filled.Add

Some of the imports might be condensed into the import androidx.compose.material.* statement, so make sure to clean up your imports and remove any redundant statements.

Build and run the app and you’ll now see the FAB in the Notes screen.

Notes screen with floating action button
Notes screen with floating action button

Click it, but nothing will happen. So far, viewModel.onCreateNewNoteClick() doesn’t do anything.

You’ll change that once you implement an entry point to the Save Note screen.

Adding an entry point

In the previous section, you added the FAB that allows you to open the Save Note screen in the Create mode.

Before you can do that, however, you need to add an entry point composable for it. You’ll do this in three steps:

  1. You’ll setup MainActivityScreen() to show different screens based on the JetNotesRouter state.
  2. You’ll connect the composable to MainActivity as its content.
  3. You’ll call the JetNotesRouter to change the state, when the user taps on the FloatingActionButton.

Open SaveNoteScreen.kt and add the following composable at the top of the file:

@Composable
fun SaveNoteScreen(viewModel: MainViewModel) {

}

Don’t forget to include an import for MainViewModel:

import com.raywenderlich.android.jetnotes.viewmodel.MainViewModel

With this, you created a composable function that represents the root of the Save Note screen.

Using JetNotesRouter to change screens

In the previous chapter, you added the code that opens the Notes screen whenever you start MainActivity. It’s time to add logic to change screens with JetNotesRouter.

Open MainActivity.kt and add the following composable to the bottom of the file, outside MainActivity:

@Composable
private fun MainActivityScreen(viewModel: MainViewModel) {
  Surface {
    when (JetNotesRouter.currentScreen) {
      is Screen.Notes -> NotesScreen(viewModel)
      is Screen.SaveNote -> SaveNoteScreen(viewModel)
      is Screen.Trash -> TrashScreen(viewModel)
    }
  }
}

MainActivityScreen subscribes to Screen when it’s invoked. That state is held in the JetNotesRouter. Whenever the state changes, MainActivityScreen will recompose and call the correct root composable for each screen.

Here, you used Surface(), one of the most basic composables. It’s responsible for things like clipping the children to a specific shape, adding a background to the app and configuring the color of the text. It’s often used as a root composable for the app’s content.

For the code above to work, you need to add following imports as well:

import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import com.raywenderlich.android.jetnotes.routing.JetNotesRouter
import com.raywenderlich.android.jetnotes.routing.Screen
import com.raywenderlich.android.jetnotes.ui.screens.SaveNoteScreen
import com.raywenderlich.android.jetnotes.ui.screens.TrashScreen

Connecting your composable to MainActivity

Next, you’ll connect this composable to MainActivity. Update setContent() in the MainActivity:

class MainActivity : AppCompatActivity() {

  ...

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContent {
      JetNotesTheme {
        MainActivityScreen(viewModel = viewModel) // here
      }
    }
  }
}

Here, you made MainActivityScreen() the root composable for the app. You also wrapped it in JetNotesTheme() to apply the theme colors you defined in Theme.kt.

You now have a way to change screens in the app! The last thing to do before you can open the Save Note screen is to call JetNotesRouter from MainViewModel.

Calling JetNotesRouter

Open MainViewModel.kt and update onCreateNewNoteClick():

class MainViewModel(private val repository: Repository) : ViewModel() {

  ...

  fun onCreateNewNoteClick() {
    JetNotesRouter.navigateTo(Screen.SaveNote)
  }

  ...
}

For this to work, you also need to add JetNotesRouter and Screen imports.

import com.raywenderlich.android.jetnotes.routing.JetNotesRouter
import com.raywenderlich.android.jetnotes.routing.Screen

Nice! You’ve now connected your MainViewModel with the JetNotesRouter. Since you’re passing the FAB click event from the SaveNotesScreen() to the MainViewModel, you can react to it by updating the Screen state in the JetNotesRouter.

By updating that state, you trigger a recomposition of MainActivityScreen(). This removes NotesScreen from the composition tree and adds SaveNotesScreen, instead.

Excellent! Build and run your app. Click the FAB in the Notes screen and see what happens.

You’ll see that the Save Note screen opens… but it’s empty. Don’t worry, you’ll add content to that screen in the following sections.

Opening Save Notes screen
Opening Save Notes screen

Another issue is that you can’t go back to the Notes screen. Clicking the Back button just closes JetNotes.

Your first step to fix both these issues is to add a top bar.

Adding the top bar

Until now, you’ve focused on adding code to open the Save Note screen. But now that you can open it, the Save Note screen is empty. In this section, you’ll add composables to it. :]

You’ll start with the top bar. Before diving straight into the code, look at the design. Again, you’ll see that the screen has a familiar layout structure.

You can divide the Save Note screen into two parts: the top bar and the body content. Because of that, you can again use Scaffold() as your root composable.

Open SaveNoteScreen.kt, then update SaveNoteScreen():

@Composable
fun SaveNoteScreen(viewModel: MainViewModel) {
  Scaffold(
    topBar = {},
    bodyContent = {}
  )
}

With this, you added placeholders for your top bar and body content.

Don’t forget to add the Scaffold() import, too:

import androidx.compose.material.Scaffold

Now, you can start working on the actual composables for the top bar.

Save Note Screen: Top bar
Save Note Screen: Top bar

Adding SaveNoteTopAppBar

In the Save Note screen, the top bar needs to support two different modes:

  1. Create mode: This lets the user create a new note. There are two actions in the top bar that deal with this case: one to complete the note creation and one to open a color picker.
  2. Edit mode: The user selects this to edit an existing note. This mode has three actions, one to save changes, one to open the color picker and one to delete the existing note.

Now that you’ve defined what you need, think about the top bar in terms of state and events. What state should be passed to the top bar and which events should you expose for the parent composable?

In total, there’s one state that you should pass down to the top bar composable and four events that the top bar composable should expose. Next, you’ll define SaveNoteTopAppBar() to allow that.

Add the following code below SaveNoteScreen():

@Composable
private fun SaveNoteTopAppBar(
  isEditingMode: Boolean,
  onBackClick: () -> Unit,
  onSaveNoteClick: () -> Unit,
  onOpenColorPickerClick: () -> Unit,
  onDeleteNoteClick: () -> Unit
) {

}

Here are the important things to note in this code:

  • isEditingMode: Represents whether the top bar is in Edit mode.
  • onBackClick: Exposes an event when the user returns to the Notes screen.
  • onSaveNoteClick: Exposes an event when the user saves a new or existing note.
  • onOpenColorPickerClick: Exposes an event when the user opens the color picker.
  • onDeleteNoteClick: Exposes an event when the user deletes the existing note.

Displaying the top bar

Now that you’ve prepared the root composable for the top bar, you’ll add the composable that emits the top bar in the UI.

Add the following code to SaveNoteTopAppBar():

TopAppBar(
  title = {
    Text(
      text = "Save Note",
      color = MaterialTheme.colors.onPrimary
    )
  }
)

Here, you used a Material Design composable here: TopAppBar. This particular definition of TopAppBar has slots for the title, navigationIcon and actions — exactly what you need for the Save Note screen. You’ll add each of these components, but for now, you added the title.

You represented the title with a simple Text(), where you defined the screen title and text color. Next you have to define the navigationIcon that will represent the back button. Do that by adding the navigationIcon parameter to TopAppBar():

navigationIcon = {
  IconButton(onClick = onBackClick) {
    Icon(imageVector = Icons.Filled.ArrowBack)
  }
}

For the navigationIcon, you passed IconButton() and defined the onClick action and the correct asset. This icon will display as a back arrow. This is pretty straightforward. Next add the actions:

actions = {
  // Save note action icon
  IconButton(onClick = onSaveNoteClick) {
    Icon(
      imageVector = Icons.Default.Check,
      tint = MaterialTheme.colors.onPrimary
    )
  }

  // Open color picker action icon
  IconButton(onClick = onOpenColorPickerClick) {
    Icon(
      imageVector = vectorResource(
        id = R.drawable.ic_baseline_color_lens_24
      ),
      tint = MaterialTheme.colors.onPrimary
    )
  }
}

These two actions are represented by two IconButtons. The buttons will trigger onSaveNoteClick and onOpenColorPickerClick actions respectively. The final action you need to add is the delete action. Do that by adding the following code to actions:

// Delete action icon (show only in editing mode)
if (isEditingMode) {
  IconButton(onClick = onDeleteNoteClick) {
    Icon(
      imageVector = Icons.Default.Delete,
      tint = MaterialTheme.colors.onPrimary
    )
  }
}

For the last action, you defined that the app should only add IconButton() if the top bar is in Edit mode.

Even though you didn’t specify the layout structure for the IconButtons, they’re still organized in a Row. That’s because the TopAppBar defines actions like this: actions: RowScope.() -> Unit = {}. You define content that you passed for actions with a RowScope.

As usual, you need to add a couple of imports as well:

import androidx.compose.material.*
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.ui.res.vectorResource
import com.raywenderlich.android.jetnotes.R

Now, add the preview composable for SaveNoteTopAppBar:

@Preview
@Composable
fun SaveNoteTopAppBarPreview() {
  SaveNoteTopAppBar(
    isEditingMode = true,
    onBackClick = {},
    onSaveNoteClick = {},
    onOpenColorPickerClick = {},
    onDeleteNoteClick = {}
  )
}

Build your project and, in the preview panel, you’ll see something like this:

SaveNoteTopAppBar Composable (Editing mode) — Preview
SaveNoteTopAppBar Composable (Editing mode) — Preview

You can also play a little bit and pass false for isEditingMode. If you refresh your preview then, you’ll see how your top bar looks when it’s not in editing mode.

SaveNoteTopAppBar Composable (New Note mode) — Preview
SaveNoteTopAppBar Composable (New Note mode) — Preview

Awesome! Want to see your top bar in action? In the next section, you’ll add SaveNoteTopAppBar() to the Save Note screen. :]

Displaying the SaveNoteTopAppBar composable

Now that you’ve created the SaveNoteTopAppBar(), you can display it in the Save Note screen. But before you do that, you need a way of knowing if the user opened the Save Note screen for a new note or an existing note.

Open MainViewModel.kt and add the following code below notesNotInTrash:

private var _noteEntry = MutableLiveData(NoteModel())
val noteEntry: LiveData<NoteModel> = _noteEntry

With this, you added a state for a note entry that the user opened to edit in the Save Note screen. Both models will use this state, and you’ll differentiate the two modes by using NoteModel’s ID.

Now, update SaveNoteScreen():

@Composable
fun SaveNoteScreen(viewModel: MainViewModel) {

  val noteEntry: NoteModel by viewModel.noteEntry
    .observeAsState(NoteModel())

  Scaffold(
    topBar = {
      val isEditingMode: Boolean = noteEntry.id != NEW_NOTE_ID
      SaveNoteTopAppBar(
        isEditingMode = isEditingMode,
        onBackClick = { 
          JetNotesRouter.navigateTo(Screen.Notes) 
        },
        onSaveNoteClick = { },
        onOpenColorPickerClick = { },
        onDeleteNoteClick = { }
      )
    },
    bodyContent = {}
  )
}

Here, you added the code to observe viewModel.noteEntry’s state. Whenever that state changes, SaveNoteScreen() will go through a recomposition.

In Scaffold(), you passed SaveNoteTopAppBar() for the topBar slot.

With noteEntry.id, you check if the screen is in Editing mode. If NoteModel.id equals NEW_NOTE_ID, the screen is in Create mode. Otherwise, it’s in Editing mode.

For now, you just passed empty actions for the other events. You’ll add them later.

Next, add the necessary imports:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import com.raywenderlich.android.jetnotes.domain.model.NEW_NOTE_ID
import com.raywenderlich.android.jetnotes.domain.model.NoteModel
import com.raywenderlich.android.jetnotes.routing.JetNotesRouter
import com.raywenderlich.android.jetnotes.routing.Screen

Finally, build and run the app.

Adding the top bar to the Save Note screen
Adding the top bar to the Save Note screen

Now, when you open the Save Note screen, you see the top bar. You can also go back to the Notes screen by clicking the Back button in the top bar.

Opening the Save Note screen in Editing mode

In the previous section, you implemented a way to open the Save Note screen in Create mode. Now, you’ll add the logic that allows the user to edit an existing note.

Open MainViewModel.kt and update onCreateNewNoteClick() and onNoteClick() like this:

fun onCreateNewNoteClick() {
  _noteEntry.value = NoteModel()
  JetNotesRouter.navigateTo(Screen.SaveNote)
}

fun onNoteClick(note: NoteModel) {
  _noteEntry.value = note
  JetNotesRouter.navigateTo(Screen.SaveNote)
}

This is pretty simple. In the previous section, you defined that SaveNoteScreen() will subscribe to viewModel.noteEntry’s state when it executes.

Here, you simply update that state with the correct NoteModel, depending on how the user opens the Save Note screen. If the user selected a note, you update the state with the selected note before opening the Save Note screen. If the user clicked the FAB, you update the state with an empty NoteModel.

By doing that, when SaveNoteScreen() executes for the first time, viewModel.noteEntry will already contain the NoteModel state.

Build and run, then click any note in the Notes screen.

Save Note screen in edit mode
Save Note screen in edit mode

Note that there are now three actions in the top bar, which means it’s in Editing mode. That’s because isEditingMode is set to true because NoteModel.id is not equal to NEW_NOTE_ID.

But there’s still content missing, so let’s implement that next.

Creating a content composable

You need to be able to edit notes in the Save Note screen, so your next step is to create a content composable to let you do that.

Refer to the design and you’ll see that the user can use a color picker to select a color for the note. However, right now they can’t see which color they picked. So your first task will be to implement the component that shows which color you picked from the color picker.

Displaying the selected color

To do this, go to SaveNoteScreen.kt and add the following composable below SaveNoteTopAppBar():

@Composable
private fun PickedColor(color: ColorModel) {
  Row(
    Modifier
      .padding(8.dp)
      .padding(top = 16.dp)
  ) {
    Text(
      text = "Picked color",
      modifier = Modifier
        .weight(1f)
        .align(Alignment.CenterVertically)
    )
    NoteColor(
      color = Color.fromHex(color.hex),
      size = 40.dp,
      border = 1.dp,
      modifier = Modifier.padding(4.dp)
    )
  }
}

This is a pretty simple composable, and you’re already familiar with its components. You’ve even built some of the theme yourself — like NoteColor(). :]

You used a Row to organize two elements, Text() and NoteColor(), next to each other. With modifiers, you added some padding and instructed Text() to use all the available width.

PickedColor() doesn’t expose any events since its only job is to show which color the user picked, so it takes a ColorModel as the state to render.

Now, add the preview composable as well by adding the following code below SaveNoteTopAppBarPreview():

@Preview
@Composable
fun PickedColorPreview() {
  PickedColor(ColorModel.DEFAULT)
}

Great! Now, build your project and check the preview panel. You’ll see something like this:

PickedColorComponent Composable — Preview
PickedColorComponent Composable — Preview

Well done! You’ve completed one of your three tasks. Now, it’s time to work on a component that allows you to make any note checkable.

Letting users check off a note

In some cases, your users might want to check off a note — when they’ve completed a task, for example. By default, there’s no option to indicate that a note has been completed. Users need to mark notes as checkable if they want that feature. Your next step is to give them that possibility.

In SaveNoteScreen.kt, add the following composable below SaveNoteTopAppBar():

@Composable
private fun NoteCheckOption(
  isChecked: Boolean,
  onCheckedChange: (Boolean) -> Unit
) {
  Row(
    Modifier
      .padding(8.dp)
      .padding(top = 16.dp)
  ) {
    Text(
      text = "Can note be checked off?",
      modifier = Modifier.weight(1f)
    )
    Switch(
      checked = isChecked,
      onCheckedChange = onCheckedChange,
      modifier = Modifier.padding(start = 8.dp)
    )
  }
}

Just like PickedColor(), this composable’s layout structure is pretty simple. You use a Row() to align a Text() with a Switch().

Switch() is one of the Material Design composables in Jetpack Compose. It’s familiar because it behaves the same as its counterpart in the current Android UI toolkit. You also used it earlier, when you implemented the app drawer.

When it comes to state and events, NoteCheckOption() takes a Boolean value for its state and exposes onCheckedChange: (Boolean) -> Unit as an event.

The parent passes down isChecked’s state so Switch() knows how to render itself. And whenever the user interacts with Switch(), an event with the new value will be sent up to the parent composable.

For this to work, you need to add one additional import:

import androidx.compose.material.Switch

Don’t forget to give it a preview composable by adding the following code below SaveNoteTopAppBarPreview():

@Preview
@Composable
fun NoteCheckOptionPreview() {
  NoteCheckOption(false) {}
}

Here, you pass false for the isChecked state and an empty action for onCheckedChange.

Build your project and you’ll see your composable in the preview panel.

CanBeCheckedOffComponent Composable — Preview
CanBeCheckedOffComponent Composable — Preview

Great job! There’s just one more composable to add before assembling the content of the Save Note screen. :]

Adding a title and content

So far, you’ve added composables to represent the note’s color and whether the user can check the note off when they complete a task. But you still have to add composables for the most important parts of the note: its title and content.

In SaveNoteScreen.kt, add the following code below SaveNoteTopAppBar():

@Composable
private fun ContentTextField(
  modifier: Modifier = Modifier,
  label: String,
  text: String,
  onTextChange: (String) -> Unit
) {
  TextField(
    value = text,
    onValueChange = onTextChange,
    label = { Text(label) },
    modifier = modifier
      .fillMaxWidth()
      .padding(horizontal = 8.dp),
    backgroundColor = MaterialTheme.colors.surface
  )
}

You’ll use this composable for the text fields where the user enters the note’s title and content. Here, you use the Material Design composable, TextField().

TextField() lets you easily implement components to take the user’s input. For state, the parent composable will pass text and ContentTextField() will pass up the change in the text as an event using onTextChange: (String) -> Unit.

You also exposed a label to communicate what the text field is for. And you exposed a modifier, which is a good practice, allowing you to pass in custom modifiers at the call site.

Next, add a preview composable below SaveNoteTopAppBarPreview():

@Preview
@Composable
fun ContentTextFieldPreview() {
  ContentTextField(
    label = "Title", 
    text = "", 
    onTextChange = {}
  )
}

Now, build your project and check the preview panel. There, you’ll see ContentTextFieldPreview():

ContentTextField Composable — Preview
ContentTextField Composable — Preview

Excellent work! You now have all the pieces to create the content of the Save Note screen.

Building the Save Note content

The next thing you’ll do is put together all the composables that you created to make the Save Note screen content.

In SaveNoteScreen.kt, add SaveNoteContent() below SaveNoteTopAppBar():

@Composable
private fun SaveNoteContent(
  note: NoteModel,
  onNoteChange: (NoteModel) -> Unit
) {
  Column(modifier = Modifier.fillMaxSize()) {

  }
}

This composable will represent the entire logic of creating and editing notes. You’ll show the data from the note in input fields and other elements, and you’ll use onNoteChange() to notify the parent when you want to save or update a note.

Now add the input fields to the Column():

ContentTextField(
  label = "Title",
  text = note.title,
  onTextChange = { newTitle ->
    onNoteChange.invoke(note.copy(title = newTitle))
  }
)

ContentTextField(
  modifier = Modifier
    .heightIn(max = 240.dp)
    .padding(top = 16.dp),
  label = "Body",
  text = note.content,
  onTextChange = { newContent ->
    onNoteChange.invoke(note.copy(content = newContent))
  }
)

These two ContentTextFields will represent the note title and body. You also added a bit of styling and respective onTextChange handlers to the input. Through them, you update the internal state of the note and let the parent know about the update.

Finally, add the NoteCheckOption() and the PickedColor() to represent more details of the note:

val canBeCheckedOff: Boolean = note.isCheckedOff != null

NoteCheckOption(
  isChecked = canBeCheckedOff,
  onCheckedChange = { canBeCheckedOffNewValue ->
    val isCheckedOff: Boolean? = if (canBeCheckedOffNewValue) false else null
    
    onNoteChange.invoke(note.copy(isCheckedOff = isCheckedOff))
  }
)

PickedColor(color = note.color)

From the design, you see that you want to organize the components in a column where the first two components are responsible for taking the user’s input for a note’s title and content. Below those two components is a NoteCheckOption() so that the user can make the note checkable. The last composable in is PickedColor() that shows which color the user picked for the note.

Next, add the following import:

import androidx.compose.material.*

It would be awesome to preview the SaveNoteContent() as well. Add the following code below SaveNoteTopAppBarPreview():

@Preview
@Composable
fun SaveNoteContentPreview() {
  SaveNoteContent(
    note = NoteModel(title = "Title", content = "content"),
    onNoteChange = {}
  )
}

Build the project and, in the preview panel, you’ll see how SaveNoteContent looks:

Content Composable — Preview
Content Composable — Preview

Wrapping up the Save Note screen

Great job so far! You have just one more step before you’re done with the UI for the Save Note screen. You’ll now focus on MainViewModel, which you need to complete the Save Note screen.

Adding ViewModel support

In MainViewModel, you already added the code to expose the noteEntry state, but you still need to add one more state. In the Save Note screen, the user can choose a color for a note. To display the list of colors the user can choose, you need to provide them to SaveNoteScreen().

Open MainViewModel.kt and add the following code below noteEntry:

val colors: LiveData<List<ColorModel>> by lazy { 
  repository.getAllColors() 
}

The database already contains the colors you’ll need. You simply exposed them here by adding the LiveData, which you can observe in SaveNoteScreen().

Don’t forget to add the following import:

import com.raywenderlich.android.jetnotes.domain.model.ColorModel

Changing the noteEntry state

Next, you need to add support for changing the noteEntry state when the user interacts with the Save Note screen.

Add the following code to the MainViewModel:

fun onNoteEntryChange(note: NoteModel) {
  _noteEntry.value = note
}

fun saveNote(note: NoteModel) {
  viewModelScope.launch(Dispatchers.Default) {
    repository.insertNote(note)

    withContext(Dispatchers.Main) {
      JetNotesRouter.navigateTo(Screen.Notes)

      _noteEntry.value = NoteModel()
    }
  }
}

fun moveNoteToTrash(note: NoteModel) {
  viewModelScope.launch(Dispatchers.Default) {
    repository.moveNoteToTrash(note.id)

    withContext(Dispatchers.Main) { 
      JetNotesRouter.navigateTo(Screen.Notes) 
    }
  }
}

Time to break down each method:

With onNoteEntryChange(), you update the noteEntry state. You’ll call this method each time the user makes a change in the Save Note screen.

saveNote() is responsible for updating the note in the database. If the user is creating a new note, you’ll add a new entry in the database. If the user is editing an existing note, you’ll update it instead.

You use a coroutine to update the database in the background. This method also closes the Save Note screen and returns the user to the Notes screen. Note that you had to switch to the main thread to update the state in JetNotesRouter. You can only update State from the main thread.

moveNoteToTrash() behaves similarly to saveNote(). It moves the note to the trash and returns the user to the Notes screen.

Connecting the SaveNoteScreen to the MainViewModel

Now that MainViewModel is ready, you can complete the UI part of the Save Note screen.

Open SaveNoteScreen.kt and update Scaffold() in SaveNoteScreen():

Scaffold(
  topBar = {
    val isEditingMode: Boolean = noteEntry.id != NEW_NOTE_ID
    SaveNoteTopAppBar(
      isEditingMode = isEditingMode,
      onBackClick = { 
        JetNotesRouter.navigateTo(Screen.Notes) 
      },
      onSaveNoteClick = { viewModel.saveNote(noteEntry) },
      onOpenColorPickerClick = { },
      onDeleteNoteClick = { 
        viewModel.moveNoteToTrash(noteEntry) 
      }
    )
  },
  bodyContent = { // here
    SaveNoteContent(
      note = noteEntry,
      onNoteChange = { updateNoteEntry ->
        viewModel.onNoteEntryChange(updateNoteEntry)
      }
    )
  }
)

All that you did here is you filled the bodyContent with SaveNoteContent(). That composable will show all the note’s details and data, while letting you change it to update a note, or fill it in to create a new one.

Great! Build and run your app.

Save Note screen
Save Note screen

You can open the Save Note screen in Create mode to create a new note or you can click any note in the note list to open the screen in Editing mode.

Make a change in the title or body and click on the check icon in the top bar. You’ll see that your change will save.

You can also move the note to the trash by clicking the trash icon.

Changing the note’s color

There is still one thing missing: You still can’t change the color of the notes. To fix that, update SaveNoteScreen() like this:

@Composable
fun SaveNoteScreen(viewModel: MainViewModel) {

  ...

  val colors: List<ColorModel> by viewModel.colors
    .observeAsState(listOf())

  val bottomDrawerState: BottomDrawerState =
    rememberBottomDrawerState(BottomDrawerValue.Closed)

  Scaffold(
    topBar = {
      val isEditingMode: Boolean = noteEntry.id != NEW_NOTE_ID
      SaveNoteTopAppBar(
        ...,
        onOpenColorPickerClick = { bottomDrawerState.open() },
        ...
      )
    },
    bodyContent = {
      BottomDrawerLayout(
        drawerState = bottomDrawerState,
        drawerContent = {
          ColorPicker(
            colors = colors,
            onColorSelect = { color ->
              val newNoteEntry = noteEntry.copy(color = color)
              viewModel.onNoteEntryChange(newNoteEntry)
            }
          )
        },
        bodyContent = {
          SaveNoteContent(
            note = noteEntry,
            onNoteChange = { updateNoteEntry ->
              viewModel.onNoteEntryChange(updateNoteEntry)
            }
          )
        }
      )
    }
  )
}

First, check what you defined above Scaffold(): You subscribed SaveNoteScreen() to viewModel.colors’s state. That lets you pass that state to ColorPicker().

Next, you created a bottomDrawerState of type BottomDrawerState. You need this for the new Material Design composable you used in Scaffold().

In Scaffold(), you wrapped SaveNoteContent() in a BottomDrawerLayout(). BottomDrawerLayout() is a Material Design composable that allows you to specify a modal drawer that’s anchored to the bottom of the screen.

Notice that you passed ColorPicker() for the drawerContent and SaveNoteContent() for the bodyContent. The principle of state management for this drawer is similar to what you implemented for the AppDrawer() in NotesScreen().

Build and run the app. Open Save Note and swipe up from the bottom of the screen or click on the color picker icon in the top bar.

Color picker on Save Note screen
Color picker on Save Note screen

You can now change the color of any existing note or set a color for a new note. Next, you’ll add a feature to confirm that the user really wants to discard a note.

Confirming a delete action

While the Save Note screen is now functionally complete, it’s always nice to pay attention to the details.

Right now, when the user clicks the trash icon in the top bar, the note will immediately move to the trash. However, it’s a good practice to ask the user to confirm an action like that first.

In SaveNoteScreen.kt, add the following line before Scaffold():

val moveNoteToTrashDialogShownState: MutableState<Boolean> = savedInstanceState { false }

This state represents whether the dialog is visible.

Next, update the SaveNoteTopAppBar(), by changing the onDeleteNoteClick to the following:

SaveNoteTopAppBar(
  ...,
  onDeleteNoteClick = { 
    moveNoteToTrashDialogShownState.value = true 
  }
)

Now, when the user clicks the trash icon in the top bar, you’ll just update moveNoteToTrashDialogShownState’s value property to true to display the dialog. This piece of state will persist through configuration changes, using the savedInstanceState.

Finally, add the following code to the bottom of the content for Scaffolds()’s bodyContent:

Scaffold(
  topBar = { ... },
  bodyContent = {
    BottomDrawerLayout(...)

    if (moveNoteToTrashDialogShownState.value) {
      AlertDialog(
        onDismissRequest = { 
          moveNoteToTrashDialogShownState.value = false 
        },
        title = {
          Text("Move note to the trash?")
        },
        text = { 
          Text(
            "Are you sure you want to " +
                "move this note to the trash?"
          ) 
        },
        confirmButton = {
          TextButton(onClick = {
            viewModel.moveNoteToTrash(noteEntry)
          }) {
            Text("Confirm")
          }
        },
        dismissButton = {
          TextButton(onClick = {
            moveNoteToTrashDialogShownState.value = false
          }) {
            Text("Dismiss")
          }
        }
      )
    }
  }
)

Here, you used the Material Design’s AlertDialog(). It exposes parameters like onDismissRequest, confirmButton and dismissButton, which you can use to customize buttons and actions. It behaves like the standard AlertDialog, where you give the user an option to do agree to your request, or cancel or dismiss the request

Before running the app, add following imports:

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.savedinstancestate.savedInstanceState

Build and run the app. Open any note and move it to the trash to see your alert dialog.

Alert dialog in Save Note screen
Alert dialog in Save Note screen

You can even change the device’s orientation and the dialog will still display. This is a much better user experience.

Adding support for the Back button

Currently, when you open the Save Note screen and press the Back button, the app closes. Since you’re not using activities or fragments that operate on back stacks and handle basic system navigation internally, you need to handle how your app behaves if the user presses the system back button.

You’ll do so by providing an OnBackPressedDispatcherOwner. This is an interface all LifecycleOwners implement, that let you react to back presses. Through this handler, you can use a special composable to connect your navigation logic and update the state of the JetNotesRouter.

To do so, open MainActivity.kt and update setContent():

setContent {
  Providers(BackPressedDispatcher provides this) {
    JetNotesTheme {
      MainActivityScreen(viewModel = viewModel)
    }
  }
}

Briefly, you use BackPressedDispatcher to access OnBackPressedDispatcherOwner. By calling Providers() and using the syntax Ambient provides value, you pass the handler through the entire MainActivityScreen() tree. You’ll use the handler in a moment. For this to work, you need to add the following imports.

import androidx.compose.runtime.Providers
import com.raywenderlich.android.jetnotes.util.BackPressedDispatcher

Next, open SaveNoteScreen.kt and add the following code to the SaveNoteScreen(). It’s important that you add it below the line where you defined bottomDrawerState:

BackPressHandler(onBackPressed = {
  if (bottomDrawerState.isOpen) {
    bottomDrawerState.close()
  } else {
    JetNotesRouter.navigateTo(Screen.Notes)
  }
})

BackPressHandler() contains logic already prepared for you that will capture the back click so you can attach your action. You can explore the function if you want to know what it does internally, but it simply captures all back pressed actions and notifies you through onBackPressed().

Specifically, here you defined that when the user presses the Back button in the Save Note screen when the color picker is open, it closes the color picker. If the color picker isn’t open, it returns the user to the Notes screen.

Add one additional import as well:

import com.raywenderlich.android.jetnotes.util.BackPressHandler

Now, build and run the app to verify everything works. There isn’t a visual change in your app, but if you press the system back button now, you’ll either close the bottom drawer on the Save Note screen, or go back to the Notes screen. :]

Using Material Design composables in the Notes screen

The Material Design composables that Jetpack Compose provides are all built with basic composables. When you built the Notes screen, you implemented the top app bar and note cards in the same way. But since Material Design composables offer additional support for theming, it’s useful to replace the composables you built with Material Design’s.

Open NotesScreen.kt and replace TopAppBar() in Scaffold() with Material Design’s TopAppBar:

Scaffold(
  topBar = {
    TopAppBar(
      title = {
        Text(
          text = "JetNotes",
          color = MaterialTheme.colors.onPrimary
        )
      },
      navigationIcon = {
        IconButton(onClick = {
          scaffoldState.drawerState.open()
        }) {
          Icon(imageVector = Icons.Filled.List)
        }
      }
    )
  },
...
)

This is the same as the Material TopAppBar you used above.

Don’t forget to replace the import for the old TopAppBar with the Material one:

import androidx.compose.material.TopAppBar

Using a Material composable for Note

There’s one more thing you can replace with Material Design composables: your Note().

Open Note.kt and replace its entire contents with:

Card(
  shape = RoundedCornerShape(4.dp),
  modifier = Modifier
    .padding(8.dp)
    .fillMaxWidth(),
  backgroundColor = MaterialTheme.colors.surface
) {
  ListItem(
    text = { Text(text = note.title, maxLines = 1) },
    secondaryText = { Text(text = note.content, maxLines = 1) },
    icon = {
      NoteColor(
        color = Color.fromHex(note.color.hex),
        size = 40.dp,
        border = 1.dp
      )
    },
    trailing = {
      if (note.isCheckedOff != null) {
        Checkbox(
          checked = note.isCheckedOff,
          onCheckedChange = { isChecked ->
            val newNote = note.copy(isCheckedOff = isChecked)
            onNoteCheckedChange.invoke(newNote)
          },
          modifier = Modifier.padding(start = 8.dp)
        )
      }
    },
    modifier = Modifier.clickable { onNoteClick.invoke(note) })
}

Here, you used a Card() and a ListItem() to implement Note(). The Card() is a relatively simple composable. Cards are surfaces that display content and actions on a single topic. You can customize their shape, backgroundColor, contentColor, border and elevation.

The ListItem() is a Material Design implementation of list items. They represent items in a list, that have the distinct Material Design look and feel. You used its text for the title, secondaryText for content, icon for the NoteColor() and trailing for the Checkbox.

You also need to add imports for the new composables that you used:

import androidx.compose.material.Card
import androidx.compose.material.ListItem
import androidx.compose.material.*

Build and run the app. You’ll see that the Notes screen looks the same as before, but most of your composables are now Material Design composables. Nicely done!

Before wrapping up the chapter, there’s one more thing to explore: adding a theme. You’ll briefly learn about Material Design themes and how to support a dark theme for your app.

Theming in Compose

Every Android app has a specific color palette, typography and shapes. Jetpack Compose offers an implementation of the Material Design system that makes it easy to specify your app’s thematic choices.

In JetNotes, you don’t play much with typography and shapes, but the app uses a certain color palette throughout all its screens. Theme.kt contains the definitions of all JetNotes’ colors:

private val LightThemeColors = lightColors(
  primary = rwGreen,
  primaryVariant = rwGreenDark,
  secondary = rwRed
)

private val DarkThemeColors = lightColors(
  primary = rwGreen,
  primaryVariant = rwGreenDark,
  secondary = rwRed
)

@Composable
fun JetNotesTheme(content: @Composable () -> Unit) {
  val isDarkThemeEnabled = 
    isSystemInDarkTheme() || JetNotesThemeSettings.isDarkThemeEnabled
  
  val colors = if (isDarkThemeEnabled) DarkThemeColors else LightThemeColors

  MaterialTheme(colors = colors, content = content)
}

There are two color definitions: LightThemeColors and DarkThemeColors. Currently, they share the same definition because the app doesn’t support a dark theme — yet! :]

The core element to implement theming in Jetpack Compose is MaterialTheme(). JetNotesTheme() observes the state when the app should change to a dark theme. When you configure specific colors, you call MaterialTheme() and pass those colors to it, but you also pass the content that these colors apply to. Typography and shapes work the same way.

Then, you retrieve the parameters passed into this composable using MaterialTheme(). You’ve done this a few times when you were implement the app. This object exposes the properties of colors, typography and shapes.

Every Material component you used throughout the app has defined which properties to use by default. Since you used Material components and colors from MaterialTheme() when you built JetNotes, adding support for a dark theme is as easy as defining a dark color palette. :]

Open Theme.kt and replace DarkThemeColors() with this:

private val DarkThemeColors = darkColors(
  primary = Color(0xFF00A055),
  primaryVariant = Color(0xFF00F884),
  secondary = rwRed,
  onPrimary = Color.White,
)

Add the following imports:

import androidx.compose.material.darkColors
import androidx.compose.ui.graphics.Color

Build and run the app. Open the navigation drawer and turn on the dark theme.

Dark Theme
Dark Theme

Congratulations! You made it to the end of the second section! JetNotes is now a fully functional app. :]

Key points

  • Jetpack Compose provides composables that make it easy to follow Material Design.
  • With remember(), Compose lets you store values in the composition tree.
  • Using the OnBackPressedDispatcherOwner and providing it through an Ambient, you gain access to system back button handling.
  • Jetpack Compose offers a Material Design implementation that allows you to theme your app by specifying the color palette, typography and shapes.
  • Using MaterialTheme(), you define a theme for your app, that customizes colors, typography and shapes.
  • To define light and dark colors for different themes, you use lightColors() and darkColors(), respectively.

Where to go from here?

Hopefully, this was a fun ride for you. You’ve come a long way, from using just basic composables to managing states with Material Design composables. In the next section, you’ll work on a more complex app, JetReddit! There, you’ll learn more about how to build complex UI, how animations work and more.

But don’t worry, with the knowledge you’ve gained so far, you won’t have any problems taking on that challenge. :]

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.