Chapters

Hide chapters

Jetpack Compose by Tutorials

Second Edition · Android 13 · Kotlin 1.7 · Android Studio Dolphin

Section VI: Appendices

Section 6: 1 chapter
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.
  • Learn more about Jetpack Compose navigation API.
  • 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 can 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 learn more about the composable that enables you to have the following layout structure.

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.

Using 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 MainActivity.kt and inspect the code inside setContent() block:

JetNotesTheme {
  val coroutineScope = rememberCoroutineScope()
  val scaffoldState: ScaffoldState = rememberScaffoldState()
  val navController = rememberNavController()

  Scaffold(
    scaffoldState = scaffoldState,
    drawerContent = {
      AppDrawer(
        currentScreen = Screen.Notes,
        onScreenSelected = { screen ->
          coroutineScope.launch {
            scaffoldState.drawerState.close()
          }
        }
      )
    },
    content = {
      NavHost(
        navController = navController,
        startDestination = Screen.Notes.route
      ) {
        composable(Screen.Notes.route) {
          NotesScreen(viewModel = viewModel)
        }
      }
    }
  )
}

The most important composable here is Scaffold. 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. Here, it allowed you to construct your screen with the main content and app drawer.

The first parameter you passed to it is scaffoldState. scaffoldState is responsible for managing basic screen state, like drawer configuration for example. You initialized it with rememberScaffoldState(). This is a new concept for you, which you’ll learn more about later. The knowledge from the previous chapter will help you understand it better.

Second, you pass 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.

Above rememberScaffoldState() call, you used rememberCoroutineScope() to retrieve a CoroutineScope. This function returns a CoroutineScope bound to this point in the composition using the optional CoroutineContext provided by getContext(). getContext() will only be called once and the same CoroutineScope instance will be returned across recompositions. This scope will be cancelled when this call leaves the composition.

You should use this scope to launch jobs in response to callback events such as clicks or other user interaction where the response to that event needs to unfold over time and be cancelled if the composable managing that process leaves the composition.

Notice you used a coroutine to call scaffoldState.drawerState.close(). If you check DrawerState documentation, you can see open() and close() are suspendable functions.

They open / close the drawer with an animation and suspend until the drawer is fully opened / closed or the animation has been canceled. Because of that, you have to call those methods within a coroutine.

The last parameter you added to the scaffold is NavHost responsible for displaying the Notes screen. For now at least. Later you’ll expand its functionality. :]

Build and run your app.

Notes Screen and App Drawer
Notes Screen and App Drawer

For now, you can open the app drawer by dragging right from the left side of the screen.

Beside app drawer, Scaffold() allows you to add other structural composables for your screen. This is the Scaffold()` signature from the Jetpack Compose documentation:

@Composable
fun Scaffold(
  modifier: Modifier = Modifier,
  scaffoldState: ScaffoldState = rememberScaffoldState(),
  topBar: @Composable () -> Unit = {},
  bottomBar: @Composable () -> Unit = {},
  snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
  floatingActionButton: @Composable () -> Unit = {},
  floatingActionButtonPosition: FabPosition = FabPosition.End,
  isFloatingActionButtonDocked: Boolean = false,
  drawerContent: @Composable (ColumnScope.() -> Unit)? = null,
  drawerGesturesEnabled: Boolean = true,
  drawerShape: Shape = MaterialTheme.shapes.large,
  drawerElevation: Dp = DrawerDefaults.Elevation,
  drawerBackgroundColor: Color = MaterialTheme.colors.surface,
  drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
  drawerScrimColor: Color = DrawerDefaults.scrimColor,
  backgroundColor: Color = MaterialTheme.colors.background,
  contentColor: Color = contentColorFor(backgroundColor),
  content: @Composable (PaddingValues) -> Unit
)

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 MainActivity.kt, you only used drawerContent and content. Scaffold() will make sure that the content you provided for the drawerContent is shown when you pull out the drawer and the content you provided for the content is below it.

Adding Scaffold to Notes Screen

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. Next, you are going to use it for top bar as well. Open NotesScreen.kt and replace Column() with Scaffold(). Also, notice that with the following code snippet you’ll add one more parameter to the NotesScreen():

@Composable
fun NotesScreen(
  viewModel: MainViewModel,
  onOpenNavigationDrawer: () -> Unit = {} // Add code here
) {

  // Observing notes state from MainViewModel
  ...

  // Add code below here

  val scaffoldState: ScaffoldState = rememberScaffoldState()

  Scaffold(
    scaffoldState = scaffoldState,
    topBar = {
      TopAppBar(
        title = "JetNotes",
        icon = Icons.Filled.List,
        onIconClick = { onOpenNavigationDrawer.invoke() }
      )
    },
    content = {
      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 replaced it with Scaffold(). You also added a second parameter onOpenNavigationDrawer that allows you to notify the parent composable that the user clicked the navigation icon, informing the parent that the app drawer should be displayed.

Now, you need to add an import for Scaffold.

import androidx.compose.material.Scaffold
import androidx.compose.material.ScaffoldState
import androidx.compose.material.rememberScaffoldState

Before building and running the app, go to MainActivity.kt and pass a function that opens app drawer to the onOpenNavigationDrawer parameter.

JetNotesTheme {
  ...

  Scaffold(
    ...
    content = {
      NavHost(
        navController = navController,
        startDestination = Screen.Notes.route
      ) {
        composable(Screen.Notes.route) {
          NotesScreen(
            viewModel = viewModel,
            onOpenNavigationDrawer = {            // add code here
              coroutineScope.launch {
                scaffoldState.drawerState.open()
              }
            }
          )   
        }
      }
    }
  )
}

With this, you defined a function that will modify scaffoldState and update drawerState. The result of that action will be the opened app drawer.

Build and run the app. You’ll notice that the behavior is the same as before, but that you can now open the drawer by clicking on the navigation icon in the Notes screen:

Notes Screen
Notes Screen

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 their state updates accordingly and is preserved during recomposition.

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

Using remember

Here’s how remember() looks in code:

@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls () -> 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().

Also notice @DisallowComposableCalls, to avoid remembering composable functions within the remember call.

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 {
  return rememberSaveable(saver = DrawerState.Saver(confirmStateChange)) {
      DrawerState(initialValue, confirmStateChange)
  }
}

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

When it comes to the DrawerState, rememberScaffoldState() relies on rememberSaveable() 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(). 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 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 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 Scaffold() already provides an API to add the FAB to the layout. To implement it, update Scaffold() in NotesScreen.kt. Also, add a third parameter to the NotesScreen():

@Composable
fun NotesScreen(
  viewModel: MainViewModel,
  onOpenNavigationDrawer: () -> Unit = {},
  onNavigateToSaveNote: () -> Unit = {}          // add code here
) {

  // Observing notes state from MainViewModel
  ...

  val scaffoldState: ScaffoldState = rememberScaffoldState()

  Scaffold(
    ...,
    floatingActionButtonPosition = FabPosition.End,
    floatingActionButton = {
      FloatingActionButton(
        onClick = {
          viewModel.onCreateNewNoteClick()
          onNavigateToSaveNote.invoke()
        },
        contentColor = MaterialTheme.colors.background,
        content = {
          Icon(
            imageVector = Icons.Filled.Add,
            contentDescription = "Add Note Button"
          )
        }
      )
    },
    ...
  )
}

Here, you used FloatingActionButton() and passed it as the floatingActionButton parameter. You then pass 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 onNavigateToSaveNote.invoke() that should open a new screen and viewModel.onCreateNewNoteClick() that should prepare your state.

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.

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

Adding an Entry Point to Save Note Screen

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 using Jetpack Compose navigation API.
  2. You’ll connect that composable to MainActivity as its content.
  3. You’ll call the navController 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,
  onNavigateBack: () -> Unit = {}
) {

}

Don’t forget to include an import for MainViewModel:

import com.yourcompany.android.jetnotes.viewmodel.MainViewModel

With this, you created a composable function that represents the root of the Save Note screen. You defined two parameters. One that will allow you to pass the MainViewModel and one that you’ll use to notify that the user performed an action to go back.

Using Jetpack Compose Navigation API 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 based on navController state.

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

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun MainActivityScreen(
  navController: NavHostController,
  viewModel: MainViewModel,
  openNavigationDrawer: () -> Unit
) {
  NavHost(
    navController = navController,
    startDestination = Screen.Notes.route
  ) {
    composable(Screen.Notes.route) {
      NotesScreen(
        viewModel,
        openNavigationDrawer,
        { navController.navigate(Screen.SaveNote.route) }
      )
    }
    composable(Screen.SaveNote.route) {
      SaveNoteScreen(
        viewModel,
        { navController.popBackStack() }
      )
    }
    composable(Screen.Trash.route) {
      TrashScreen(viewModel, openNavigationDrawer)
    }
  }
}

With this code you added a composable that manages your screens. The main actors are NavHost and NavHostController. The NavHostController is responsible for managing the back stack of composables. If you want to access the state of the back stack you can use currentBackStackEntryAsState().

Notice that to NotesScreen() you pass a function that uses navController to navigate to the Save Note screen. That function will open the Save Note screen and add that screen to the back stack. To the SaveNoteScreen() you pass a function that will pop that screen from the back stack.

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

import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.Composable
import com.yourcompany.android.jetnotes.ui.screens.SaveNoteScreen
import com.yourcompany.android.jetnotes.ui.screens.TrashScreen
import androidx.navigation.NavHostController

You might ask yourself why did you have to add @ExperimentalMaterialApi. Some of the composables that you are going to build in this section will use an experimental API. To save you some time so that you don’t have to go through each composable and add this annotation in the future, you’ll add it now. Don’t worry, you’ll be aware of when you use something from the experimental material API.

Connecting MainActivityScreen to MainActivity

Next, you’ll connect this composable to MainActivity, but also connect your app drawer with the navigation graph.

First, you’ll connect you app drawer by updating the setContent block in the MainActivity:

...

val navBackStackEntry                                   // add code here
  by navController.currentBackStackEntryAsState()

Scaffold(
  scaffoldState = scaffoldState,
  drawerContent = {
    AppDrawer(                                          // add code here
      currentScreen = Screen.fromRoute(
        navBackStackEntry?.destination?.route
      ),
      onScreenSelected = { screen ->
        navController.navigate(screen.route) {
          // Pop up to start destination to avoid building the
          // stack for every screen selection
          popUpTo(
            navController.graph.findStartDestination().id
          ) {
            saveState = true
          }

          // Prevent copies of the same destination when screen
          // is reselected
          launchSingleTop = true

          // Restore state when selecting previously selected
          // screen
          restoreState = true
        }
        coroutineScope.launch {
          scaffoldState.drawerState.close()
        }
      }
    )
  },
  content = {
    ...
  }
)

You first initialized navBackStackEntry with navController.currentBackStackEntryAsState() so that you can observe changes in the back stack. In the AppDrawer(), you listen for that state and you pass a correct Screen to the currentScreen property when back stack changes.

For the onScreenSelected action, you use navController to navigate to the correct screen, but you also added a few modifications for that action. Whenever user selects a screen in the drawer, you pop up to start destination to avoid building the stack. You also used launchSingleTop = true to prevent copies of the same destination when screen is reselected. Lastly, you used restoreState = true to restore the state if previously selected screen is selected.

Now you have your app drawer connected with your navigation graph, update content block in the Scaffold in the MainActivity:

Scaffold(
  ...
  content = {
    MainActivityScreen(
      navController = navController,
      viewModel = viewModel,
      openNavigationDrawer = {
        coroutineScope.launch {
          scaffoldState.drawerState.open()
        }
      }
    )
  }
)

Add following imports:

import androidx.compose.runtime.getValue
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState

You now have a way to change screens in the app! 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

Clicking the Back button takes you back to Notes screen. By using the Jetpack Compose navigation API you get that functionality out of the box. When you click back, navController automatically pops the back stack.

You can also try opening the app drawer and selecting the Trash screen. You’ll see that the Trash screen will open.

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,
  onNavigateBack: () -> Unit = {}
) {
  Scaffold(
    topBar = {},
    content = {}
  )
}

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: 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.Default.ArrowBack,
      contentDescription = "Save Note Button",
      tint = MaterialTheme.colors.onPrimary
    )
  }
}

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,
      contentDescription = "Save Note"
    )
  }

  // Open color picker action icon
  IconButton(onClick = onOpenColorPickerClick) {
    Icon(
      painter = painterResource(
        id = R.drawable.ic_baseline_color_lens_24
      ),
      contentDescription = "Open Color Picker Button",
      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 delete. 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,
      contentDescription = "Delete Note Button",
      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.painterResource
import com.yourcompany.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 = MutableStateFlow<NoteModel>(NoteModel())
val noteEntry: LiveData<NoteModel> = _noteEntry.asLiveData()

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,
  onNavigateBack: () -> Unit = {}
) {
  val noteEntry: NoteModel by viewModel.noteEntry
    .observeAsState(NoteModel())

  Scaffold(
    topBar = {
      val isEditingMode: Boolean = noteEntry.id != NEW_NOTE_ID
      SaveNoteTopAppBar(
        isEditingMode = isEditingMode,
        onBackClick = {
          onNavigateBack.invoke()
        },
        onSaveNoteClick = { },
        onOpenColorPickerClick = { },
        onDeleteNoteClick = { }
      ) },
    content = { }
  )
}

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.yourcompany.android.jetnotes.domain.model.NEW_NOTE_ID
import com.yourcompany.android.jetnotes.domain.model.NoteModel

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()
}

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

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

Here, you 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.

Next, open NotesScreen.kt and update NotesList() with the function to open a Save Note screen:

@SuppressLint("UnusedMaterialScaffoldPaddingParameter")
@Composable
fun NotesScreen(
  viewModel: MainViewModel,
  onOpenNavigationDrawer: () -> Unit,
  onNavigateToSaveNote: () -> Unit = {}
) {

  ...

  Scaffold(
    ...
    content = {
      if (notes.isNotEmpty()) {
        NotesList(
          notes = notes,
          onNoteCheckedChange = {
            viewModel.onNoteCheckedChange(it)
          },
          onNoteClick = {
            viewModel.onNoteClick(it)
            onNavigateToSaveNote.invoke()           // add code here
          }
        )
      }
    }
  )
}

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

Save Note Screen in Edit Mode
Save Note Screen in Edit Mode

Note 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 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 them 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 a note has been completed. Users need to mark notes as checkable if they want that feature. Your next step is to give them the 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)
        .align(Alignment.CenterVertically)
    )
    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),
    colors = TextFieldDefaults.textFieldColors(
      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, as 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() within SaveNoteContent()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 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 the user can make the note checkable. The last composable in is PickedColor() that shows the color the user picked for the note.

Next, add the following import:

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn

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().asLiveData()
}

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.yourcompany.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) {
      _noteEntry.value = NoteModel()
    }
  }
}

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

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.

moveNoteToTrash() behaves similarly to saveNote(). It moves the note to the trash.

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 = {
        onNavigateBack.invoke()
      },
      onSaveNoteClick = { // add code here
        viewModel.saveNote(noteEntry)
        onNavigateBack.invoke()
      },
      onOpenColorPickerClick = { },
      onDeleteNoteClick = { // add code here
        viewModel.moveNoteToTrash(noteEntry)
        onNavigateBack.invoke()
      }
    )
  },
  content = { // add code here
    SaveNoteContent(
      note = noteEntry,
      onNoteChange = { updateNoteEntry ->
        viewModel.onNoteEntryChange(updateNoteEntry)
      }
    )
  }
)

What you did here is you filled the content with SaveNoteContent(). That composable shows 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. You also added the code that will close this screen when the user saves or deletes a note.

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
@ExperimentalMaterialApi // add code here (BottomDrawer)
fun SaveNoteScreen(
  viewModel: MainViewModel,
  onNavigateBack: () -> Unit = {}
) {

  ...

  // add code here
  val colors: List<ColorModel> by viewModel.colors
    .observeAsState(listOf())

  // add code here
  val bottomDrawerState: BottomDrawerState =
    rememberBottomDrawerState(BottomDrawerValue.Closed)

  // add code here
  val coroutineScope = rememberCoroutineScope()

  Scaffold(
    topBar = {
      val isEditingMode: Boolean = noteEntry.id != NEW_NOTE_ID
      SaveNoteTopAppBar(
        ...,
        onOpenColorPickerClick = { // add code here
          coroutineScope.launch {
            bottomDrawerState.open()
          }
        },
        ...
      )
    },
    content = {
      BottomDrawer( // add code here
        drawerState = bottomDrawerState,
        drawerContent = {
          ColorPicker(
            colors = colors,
            onColorSelect = { color ->
              val newNoteEntry = noteEntry.copy(color = color)
              viewModel.onNoteEntryChange(newNoteEntry)
            }
          )
        },
        content = {
          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(). You also used it to open the bottom drawer when the user clicks on a color picker button in the top bar.

In Scaffold(), you wrapped SaveNoteContent() in a BottomDrawer(). BottomDrawer() is a Material Design composable that allows you to specify a modal drawer that’s anchored to the bottom of the screen. In the time of writing, this was part of an experimental material API. Because of that you had to add @ExperimentalMaterialApi annotation to SaveNoteScreen().

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

Before building the app, add following import:

import androidx.compose.runtime.rememberCoroutineScope

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 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> = rememberSaveable {
  mutableStateOf(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 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 = { ... },
  content = {
    BottomDrawer(...)

    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)
            onNavigateBack.invoke()
          }) {
            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.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable

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.

Using Material Design Composables in the Notes Screen

The Material Design composables that Jetpack Compose provide 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 = {
          onOpenNavigationDrawer.invoke()
        }) {
          Icon(
            imageVector = Icons.Filled.List,
            contentDescription = "Drawer Button"
          )
        }
      }
    )
  },
...
)

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:

val background = if (isSelected)
  Color.LightGray
else
  MaterialTheme.colors.surface

Card(
  shape = RoundedCornerShape(4.dp),
  modifier = modifier
    .padding(8.dp)
    .fillMaxWidth(),
  backgroundColor = background
) {
  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 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.

At the time of writing, ListItem() was a part of an experimental material API so add @ExperimentalMaterialApi annotation to Note(), as well as the modifier parameter:

@Composable
@ExperimentalMaterialApi // here
fun Note(
  modifier: Modifier = Modifier, // here
  note: NoteModel,
  onNoteClick: (NoteModel) -> Unit = {},
  onNoteCheckedChange: (NoteModel) -> Unit = {},
  isSelected: Boolean = false
) {
...
}

You are going to have to do that for all composables that explicitly or implicitly use Note(). Those are: NotePreview(), NotesList(), NotesScreen and NotesListPreview().

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 = green,
  primaryVariant = greenDark,
  secondary = red
)

private val DarkThemeColors = lightColors(
  primary = green,
  primaryVariant = greenDark,
  secondary = red
)

@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 = red,
  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 Jetpack Compose navigation allows you to easily navigate between your composables. Navigation is structured around back stack.
  • 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.