A Material Design 3 Theming Tutorial

This chapter will demonstrate how to migrate an Android Studio project from Material Design 2 to Material Design 3 and create a new theme using the Material Theme Builder tool. The tutorial will also demonstrate how to add support for and test dynamic theme colors.

Creating the ThemeDemo project

Launch Android Studio and create a new Empty Compose Activity project named ThemeDemo, specifying com.example.themedemo as the package name, and selecting a minimum API level of API 26: Android 8.0 (Oreo). Within the MainActivity.kt file, delete the Greeting function and add a new empty composable named MainScreen:

@Composable
fun MainScreen() {
    
}

Next, edit the onCreateActivity() method and DefaultPreview function to call MainScreen instead of Greeting and enable the system UI preview option:

@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DefaultPreview() {
.
.

Adding the Material Design 3 library

Assuming that you are running a version of Android Studio that defaults to Material Design 2, the first step in this project is to add the Material Design 3 library to the build configuration. Within the Project tool window, locate and open the module level build.gradle file (app -> build -> build.gradle (Module: ThemeDemo)) and add the library as follows (keeping in mind that a more recent version of the library may now be available):

dependencies {
    implementation "androidx.compose.material3:material3:1.0.0-alpha04"
.
.

After making the change, click on the Sync Now link to apply the new build configuration to the project.

Designing the user interface

The main activity will contain a simple layout containing some of the components provided with the MD3 alpha release that will enable us to see the effect of theming work performed later in the chapter. For the latest information on which MD3 components are available for use with Jetpack Compose, refer to the following web page:

https://developer.android.com/jetpack/androidx/releases/compose-material3

Within the MainActivity.kt file, edit the MainScreen composable so that it reads as follows, including the OptIn annotation to enable the use of the experimental MD3 API. Also, delete any MD2 import directives:

.
.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
.
.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
 
    var selectedItem by remember { mutableStateOf(0) }
    val items = listOf("Home", "Settings", "Favorites")
    val icons = listOf(Icons.Filled.Home, Icons.Filled.Settings, 
                                  Icons.Filled.Favorite)
 
    Column(
        verticalArrangement = Arrangement.SpaceBetween, 
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
 
        SmallTopAppBar(title = { Text("ThemeDemo") }, scrollBehavior = null)
 
        Button(onClick = { }) {
            Text("MD3 Button")
        }
 
        Text("A Theme Demo")
 
        FloatingActionButton(onClick = { }) {
                Text("FAB")
        }
 
        NavigationBar {
            items.forEachIndexed { index, item ->
                NavigationBarItem(
                    icon = { Icon(icons[index], contentDescription = null) },
                    label = { Text(item) },
                    selected = selectedItem == index,
                    onClick = { selectedItem = index }
                )
            }
        }
    }
}

When previewed, the MainScreen layout should appear as illustrated in Figure 50-1:

Figure 50-1

The completed design is currently using default theme colors and fonts. The next step is to build an entirely new theme for the app.

Building a new theme

The theme for the project will be designed and generated using the Material Theme Builder. Open a browser window and navigate to the following URL to access the builder tool:

https://material-foundation.github.io/material-theme-builder/

Once you have loaded the builder, select the Custom button at the top of the screen and then click on the Primary color block in the Key Colors section to display the color selector. From the color selector, choose any color you feel like using as the basis for your theme:

Figure 50-2

Review the color scheme in the My Theme panel and make any necessary color adjustments using the Color Key until you are happy with the color slots. Once the theme is ready, click on the Export button in the top right-hand corner and select the Jetpack Compose (Theme.kt) option. When prompted, save the file to a suitable location on your computer filesystem. The theme will be saved as a compressed file named material-theme.zip.

Using the appropriate tool for your operating system, unpack the theme file which should contain the following files in a folder with the path material-theme/ui/theme:

  • Color.kt
  • Theme.kt
  • Type.kt

Now that the theme files have been generated, they need to be integrated into the Android Studio project.

Adding the theme to the project

Before we can add the new theme to the project we first need to remove the old MD2 theme files. Within the Android Studio Project tool window, select and delete the Color.kt, Theme.kt, and Type.kt files from the ui.theme folder. Once the files have been removed, locate the MD3 theme files in the material-theme folder on your local filesystem and drag and drop them onto the ui.theme folder in the Project tool window:

Figure 50-3

After adding the files, edit each one in turn and change the package declaration to match the current project which, assuming you followed the steps at the start of the chapter, will read as follows:

package com.example.themedemo.ui.theme 

Next, edit the Theme.kt and change the name of the Theme composable from AppTheme to ThemeDemoTheme:

@Composable
fun ThemeDemoTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable() () -> Unit
) {
val colors = if (!useDarkTheme) {
    LightThemeColors
} else {
    DarkThemeColors
.
.

Return to the MainActivity.kt file and refresh the Preview panel to confirm that the components are rendered using the new theme. Take some time to explore the Colors.kt, Type.kt, and Theme.kt files to see the different available theme settings. Also, experiment by making changes to different typography and color values.

Enabling dynamic colors

To test dynamic colors the app will need to be run on a device or emulator running Android 12 or later with the correct Wallpaper settings. On the device or emulator, launch the Settings app and select Wallpaper & style from the list of options. On the wallpaper settings screen click the option to change the wallpaper (marked A in Figure 50-4) and select a wallpaper image containing colors that differ significantly from the colors in your theme. Once selected, assign the wallpaper to the Home screen.

Return to the Wallpaper & styles screen and make sure that the Wallpaper colors option is selected (B) before trying out the different color scheme buttons (C). As each option is clicked the wallpaper example will change to reflect the selection:

Figure 50-4

Once you have made a choice, return to Android Studio, load the Theme.kt file into the code editor and make the following changes to the ThemeDemoTheme composable to add support for dynamic colors:

.
import android.os.Build
import androidx.compose.material3.*
import androidx.compose.ui.platform.LocalContext
.
.
@Composable
fun ThemeDemoTheme(
    useDarkTheme: Boolean = isSystemInDarkTheme(),
        content: @Composable() () -> Unit
) {
 
    val useDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
 
    val colors = when {
        useDynamicColor && useDarkTheme ->
            dynamicDarkColorScheme(LocalContext.current)
              useDynamicColor && !useDarkTheme ->
                dynamicLightColorScheme(LocalContext.current)
        useDarkTheme -> DarkThemeColors
            else -> LightThemeColors
    }
 
    MaterialTheme(
        colorScheme = colors,
        typography = AppTypography,
        content = content
    )
}

Build and run the app and note that the layout is now using a theme that matches the wallpaper color. Place the ThemeDemo app into the background, return to the Wallpaper & styles settings screen and choose a different wallpaper. Bring the ThemeDemo app to the foreground again at which point it will have dynamically adapted to match the new wallpaper.

Summary

In this chapter, we have demonstrated how to migrate an Android Studio project from Material Design 2 to Material Design 3. The project also made use of the Material Theme Builder to design a new theme and explained the steps to integrate the generated theme files into a project. Finally, the chapter showed how to implement and use the Material Me dynamic colors feature of Android 12.

Custom Jetpack Compose Themes

The appearance of Android apps is intended to conform with a set of guidelines defined by Material Design. Material Design was developed by Google to provide a level of design consistency between different apps, while also allowing app developers to include their own branding in terms of color, typography, and shape choices (a concept referred to as Material theming). In addition to design guidelines, Material Design also includes a set of UI components for use when designing user interface layouts, many of which we have been using throughout this book.

In this chapter, we will provide an overview of how theming works within an Android Studio Compose project and explore how the default design configurations provided for newly created projects can be modified to meet your branding requirements.

Material Design 2 vs Material Design 3

Before beginning, it is important to note that Google is currently transitioning from Material Design 2 to Material Design 3 and that the current version of Android Studio defaults to Material Design 2. Material Design 3 provides the basis for Material You, a feature introduced in Android 12 that allows an app to automatically adjust theme elements to compliment preferences configured by the user on the device. Dynamic color support provided by Material Design 3, for example, allows the colors used in apps to automatically adapt to match the user’s wallpaper selection.

So that this book will be useful for as long as possible, this chapter will focus on color and typography theming using Material Design 3. At the time of writing, shape theming was not yet supported by Material Design 3, though the concepts covered in this chapter for color and typography will apply to shapes when support is available.

Material Design 2 Theming

Before exploring Material Design 3, we first need to look at how Material Design 2 is used in an Android Studio project created using the Empty Compose Activity template. The first point to note is that both calls to the top-level composable in the onCreate() method and the DefaultPreview function are embedded in a theme composable. The following, for example, is the code generated for a project named MyApp:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
.
.
            MyAppTheme {
.
.
                    Greeting("Android")
                }
            }
        }
    }
}
 
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MyAppTheme {
        Greeting("Android")
    }
}

All of the files associated with MyAppTheme are contained with the ui.theme sub-package of the project as shown in Figure 49-1:

Figure 49-1

The theme itself is declared in the Theme.kt file which begins by declaring different color palettes for use when the device is in light or dark mode. These palettes are created by calling the darkColors() and lightColors() builder function and specifying the colors for the different Material Theme color slots:

private val DarkColorPalette = darkColors(
    primary = Purple200,
    primaryVariant = Purple700,
    secondary = Teal200
)
 
private val LightColorPalette = lightColors(
    primary = Purple500,
    primaryVariant = Purple700,
    secondary = Teal200
}

This is just a subset of the slots available for color theming. For Material Design 3, for example, there is a total of 24 color slots available for use when designing a theme. In the absence of a slot assignment, the Material components use built-in default colors. More information about the color slots available in Material Design 2 can be found at the following URL:

https://material.io/design/color/the-color-system.html

These color slots are used by the Material components to set color attributes. For example, the primary color slot is used as the background color for the Material Button component. The actual colors assigned to the slots are declared in the Colors.kt file as follows:

val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)

Our example MyAppTheme composable is declared in the Theme.kt file as follows:

@Composable
fun MyApplicationTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }
 
    MaterialTheme(
        colors = colors,
        typography = Typography,
        shapes = Shapes,
        content = content
    )
}

Note that the theme makes use of the slot API (introduced in the chapter entitled An Overview of Jetpack Compose Slot APIs) to display the content. The declaration begins by checking whether the device is in light or dark mode by calling the isSystemInDarkTheme() function. The result of this call is then used to decide if the dark or light color palette is to be passed as a parameter to the MaterialTheme call. In addition to the color palette, MaterialTheme is also passed typography and shape settings which are declared in the Type.kt and Shape.kt files respectively.

In terms of typography, Material Design has a set of type scales, three of which are declared in the Type.kt file (albeit with two commented out):

val Typography = Typography(
    body1 = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp
    )
    /* Other default text styles to override
    button = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.W500,
        fontSize = 14.sp
    ),
    caption = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 12.sp
    )
    */
)

As with the color slots, this is only a subset of the type scales supported by Material Design. The full list can be found online at:

https://material.io/design/typography/the-type-system.html

The Shape.kt file is used to define how the corners of Material components are to be rendered:

val Shapes = Shapes(
    small = RoundedCornerShape(4.dp),
    medium = RoundedCornerShape(4.dp),
    large = RoundedCornerShape(0.dp)
)

The default rounded corners of an OutlinedTextField will, for example, be controlled by the above shape values.

Creating a custom theme simply involves editing these files to use different colors, typography, and shape settings. These changes will then be used by the Material components that make up the user interface of the app.

Material Design 3 Theming

The key difference between Material Design 2 (MD2) and Material Design 3 (MD3) is support for dynamic colors and the use of color schemes instead of palettes. Typography is implemented in the same way as with MD2 and we do not yet know how shapes will be supported. Color schemes are created via calls to the lightColorScheme() and darkColorScheme() builder functions, for example:

private val DarkColorPalette = darkColorScheme(
    primary = ...,
    onPrimary = ...,
    secondary = ....,
.
.
)
 
private val LightColorPalette = lightColorScheme(
.
.
}

When the theme is created, the color schemes are now assigned using the colorSchemes parameter instead of the colors parameter used in MD2:

@Composable
fun MyAppTheme(
    useDarkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable() () -> Unit
) {
    val colors = if (!useDarkTheme) {
        LightThemeColors
    } else {
        DarkThemeColors
    }
 
    MaterialTheme(
        colorScheme = colors,
        typography = AppTypography,
        content = content
    )
}

Although the typography and declaration of theme colors are much the same between MD2 and MD3, the color slots and typography types have different names in many cases. A full listing of MD3 color slot names can be found at:

https://developer.android.com/reference/kotlin/androidx/compose/material3/ColorScheme

Similarly, a list of typography options is available at the following URL:

https://developer.android.com/reference/kotlin/androidx/compose/material3/Typography

To add support for dynamic colors, dynamic color schemes need to be generated via calls to the dynamicDarkColorScheme() and dynamicLightColorScheme() functions passing through the current local context as a parameter. These functions will then generate color schemes that match the user’s settings on the device (for example wallpaper selection). Since dynamic colors are only supported on Android 12 (S) or later, defensive code needs to be added when creating the MaterialTheme instance:

val useDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
 
val colors = when {
    useDynamicColor && useDarkTheme -> 
                  dynamicDarkColorScheme(LocalContext.current)
    useDynamicColor && !useDarkTheme -> 
                  dynamicLightColorScheme(LocalContext.current)
    useDarkTheme -> DarkColorScheme
    else -> LightColorScheme
}

Note that dynamic colors only take effect when enabled on the device by the user within the wallpaper and styles section of the Android Settings app.

Building a Custom Theme

As we have seen so far, the coding work in implementing a theme is relatively simple. The difficult part, however, is often choosing a set of complementary colors to make up the theme. Fortunately, Google has developed a tool that makes it easy to design custom color themes for your apps. This tool is called the Material Theme Builder and is available at:

https://material-foundation.github.io/material-theme-builder

From within the builder tool, select the Custom tab (marked A in Figure 49-2) and make a color selection for the primary color key (B) by clicking on the color rectangle to display the color selection dialog. Once a color has been selected, the theme panel (C) will change to reflect the recommended colors for all of the MD3 color slots. The generated colors for the Secondary, Tertiary, and Neutral slots can be overridden by clicking on the boxes (D) and selecting different colors from the color selection panel:

Figure 49-2

To incorporate the theme into your design, click on the Export button (F) and select the Jetpack Compose (Theme.kt) option. Once downloaded, place the Color.kt, Theme.kt, and Type.kt files into the ui.themes folder of your project, replacing the existing files if they are present. Note that the package names in each file and theme composable names in the Theme.kt file will need to be changed to match your project.

Summary

Material Design provides guidelines and components that define how Android apps look and appear. Individual branding can be applied to an app by designing themes that specify the colors, fonts, and shapes that should be used when the app is displayed. Google is currently introducing Material Design 3 which replaces Material Design 2 and supports the new features of Material Me including dynamic colors. For designing your own themes, Google also provides the Material Theme Builder which eases the task of choosing complementary theme colors. Once this tool has been used to design a theme, the corresponding files can be exported and used within an Android Studio project.

Detecting Swipe Gestures in Jetpack Compose

The preceding chapter demonstrated how to detect some common types of gestures including dragging, tapping, pinching, and scrolling. Detecting swipe gestures is a little more complicated than other gesture types which is why we are dedicating an entire chapter to the subject. This chapter will explain exactly what swipe gestures are and demonstrate how they can be detected.

Swipe gestures and anchors

A swipe gesture is a horizontal or vertical motion of a point of contact on the device screen. This motion is usually associated with a user interface component that moves in coordination with the swipe motion.

In Compose, a swiping motion serves to move a component from one anchor to another where an anchor is a fixed position on the screen along the axis of the swipe. A point between two anchors is declared as the threshold. If the swipe ends before the threshold is reached, the swiped component will return to the starting anchor. If, on the other hand, the swipe ends after passing the transition point, the component will continue moving until it reaches the destination anchor. These threshold-related movements can be configured to be instant (snapped) or animated.

Detecting swipe gestures

Swipe gestures are detected by applying the swipeable() modifier to the composable in which the gesture is to be detected. The following example shows the minimum requirements when calling the swipeable() modifier:

Box(
    modifier = Modifier
        .swipeable(
            state = <swipeable state>,
            anchors = <anchors>,
            thresholds = { _, _ -> FractionalThreshold(<value>) },
            orientation = <horizontal or vertical>
        )
)

Important parameters that can be specified when calling the swipeable() modifier can be summarized as follows:

  • state: SwipeableState – Used to store the swipeable state through recompositions and obtained via a call to the rememberSwipeableState() function. This state contains the current offset of the swipe motion which can be used to change the position of the current or other composables.
  • anchors: Map – A Map declaration that pairs anchor points and states. Anchor points are specified as pixels defining positions in the horizontal or vertical plane depending on the orientation setting.
  • orientation: Orientation – The orientation of the swipe gesture. Must be set to either Orientation.Horizontal or Orientation.Vertical.
  • enabled: Boolean – An optional setting that defaults to true and controls whether swipe detection is active.
  • reverseDirection: Boolean – An optional setting that defaults to false. When set to true, this setting reverses the effect of the swipe direction. In other words, a downward swipe will behave as an upward swipe, a rightward swipe as a leftward swipe, and so on.
  • thresholds: (from, to) – Specifies the position of thresholds between anchors. Declared as a lambda containing a call to either FractionalThreshold(Float) when declaring the transition point as a percentage of the distance between anchors, or FixedThreshold(Dp) when specifying a fixed position.
  • resistance: ResistanceConfig? – An optional setting that defines the resistance that will be applied when the swiping motion passes beyond the first or last final anchor in the anchor map (referred to as the bounds). By default, the swipe will be allowed to move slightly beyond the bounds before springing back to the anchor. When set to null, the swipe cannot extend beyond the bounds.
  • velocityThreshold: Dp – An optional setting defining the speed in dp per second that the swipe velocity has to exceed to move to the next state.

Declaring the anchors map

As previously outlined, swipe anchors are declared as map objects containing pairs of anchor positions and states. The anchors are declared using floating-point pixel values that correspond to a position along either the x or y-axis relative to the composable to which the swipeable() modifier is being applied. The corresponding state can be any valid state type that is supported by the Bundle class. For example, as each anchor point is reached, the text displayed on a text component may need to change. In this case, each state in an anchor pair would be a different string value setting. The anchor map can be declared using the Kotlin mapOf() function. Consider, for example, the following anchor declaration:

val swipeableState = rememberSwipeableState("On")
val anchors = mapOf(0f to "On", 150f to "Off", 300f to "Locked")

When the swipe reaches the anchors at the 150px and 300px position, the current value of the swipeableState will be set to “Off” and “Locked” respectively. A Text composable might be configured to display this current state as follows:

Text(swipeableState.currentValue)

Declaring thresholds

Thresholds are declared as lambdas which will be passed from and to states when called and must return a ThresholdConfig value. This ThresholdConfig instance can be generated via a call to either the FractionalThreshold() or FixedThreshold() function. The following code, for example, declares a threshold at a point 50% of the distance between two anchors:

{ _, _ -> FractionalThreshold(0.5f) }

The following declaration, on the other hand, sets a threshold at a fixed point 20dp along the distance between two anchors:

{ _, _ -> FixedThreshold(20.dp) }

Moving a component in response to a swipe

As with many of the gesture detection modifiers covered in the previous chapter, a swipe does not automatically move a component. Any position changes within the layout must therefore be programmed. Fortunately, this is simply a case of using the offset value of the swipeable state with the offset() modifier of any components in the layout that need to be moved in response to the gesture. If, for example, we need the Text view in the above example to move horizontally in response to the swipe gesture, we could do so with the following code change:

Text(
    swipeableState.currentValue, 
    modifier = Modifier
          .offset { IntOffset(swipeableState.offset.value.roundToInt() , 0) 
})

When executed, the Text component will now move in concert with the swiping motion.

With the basics of Compose swipe gesture detection covered, in the rest of this chapter we will create an example project that will help to clarify the information provided so far.

About the SwipeDemo project

The project created in the remainder of this chapter will implement horizontal swipe detection designed to move a Box between three anchor positions. At each anchor, the box will display a different letter signifying left (L), center (C), and right (R). Figure 48-1 shows the completed user interface:

Figure 48-1

Creating the SwipeDemo project

Launch Android Studio and create a new Empty Compose Activity project named SwipeDemo, specifying com.example.swipedemo as the package name, and selecting a minimum API level of API 26: Android 8.0 (Oreo). Within the MainActivity.kt file, delete the Greeting function and add a new empty composable named MainScreen:

@Composable
fun MainScreen() {
    
}

Next, edit the onCreateActivity() method and DefaultPreview function to call MainScreen instead of Greeting.

Setting up the swipeable state and anchors

Before designing the user interface layout, we need to set up some size constants, create the swipeable state and declare the anchor map. With the MainActivity.kt file loaded into the editor, locate and make the following changes to the MainScreen function:

.
.
import androidx.compose.material.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
.
.
@Composable
fun MainScreen() {
    val parentBoxWidth = 320.dp
    val childBoxSides = 30.dp
 
    val swipeableState = rememberSwipeableState("L")
    val widthPx = with(LocalDensity.current) { 
               (parentBoxWidth - childBoxSides).toPx() }
 
    val anchors = mapOf(0f to "L", widthPx / 2 to "C", widthPx to "R")
}

In the above code, the parentBoxWidth value represents the width of the top-level Box within the component hierarchy we will be creating later in the tutorial. This is the component to which the swipeable() modifier will be applied. The parent box will contain a child box, the side lengths of which are defined via the childBoxSides declaration. Finally, the width in pixels of the swipeable area is calculated by taking the density of the display on which the app is running, then subtracting the width of the child box from the width of the parent box:

val widthPx = with(LocalDensity.current) { 
               (parentBoxWidth - childBoxSides).toPx() }

The child box width is subtracted above to account for the fact that the child box will be centered on the anchor points, leaving an overhang equivalent to half the width of the child on the first and last anchors (these two halves combining to create a full child box width).

Finally, anchor points are configured at the start, mid-point, and end of the swipeable area. The states for these anchors are declared as strings set to “L”, “C”, and “R”.

val anchors = mapOf(0f to "L", widthPx / 2 to "C", widthPx to "R")

At the time of writing, the rememberSwipeableState() function was an experimental feature. If the editor reports this error, add the @ExperimentalMaterialApi annotation above the @Composable directive for the MainScreen as shown below:

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun MainScreen() {

Designing the parent Box

The next step is to design the composable hierarchy that makes up the user interface layout. As previously described, the layout will consist of a parent Box on which the swipeable modifier will be applied. Remaining within the MainScreen function add this component now:

.
.
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.*
.
.
Composable
fun MainScreen() {
.
.
    val anchors = mapOf(0f to "L", widthPx / 2 to "C", widthPx to "R")
 
    Box {
        Box(
            modifier = Modifier
                .padding(20.dp)
                .width(parentBoxWidth)
                .height(childBoxSides)
                .swipeable(
                    state = swipeableState,
                    anchors = anchors,
                    thresholds = { _, _ -> FractionalThreshold(0.5f) },
                    orientation = Orientation.Horizontal
                )
        ) {
        }
    }
}

Note that the thresholds are set to the halfway points between anchors.

The next step is to add the line graphic which is, itself, comprised of four Box components:

.
.
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
.
.
Box(
    modifier = Modifier
        .padding(20.dp)
        .width(parentBoxWidth)
        .height(childBoxSides)
.
.
) {
    Box(Modifier.fillMaxWidth().height(5.dp).
         background(Color.DarkGray).align(Alignment.CenterStart))
    Box(Modifier.size(10.dp).background(Color.DarkGray, 
         shape = CircleShape).align(Alignment.CenterStart))
    Box(Modifier.size(10.dp).background(Color.DarkGray, 
         shape = CircleShape).align(Alignment.Center))
    Box(Modifier.size(10.dp).background(Color.DarkGray, 
         shape = CircleShape).align(Alignment.CenterEnd))
}
.
.

Take this opportunity to review the layout in the Preview panel where the line should now appear as shown in Figure 48-2:

Figure 48-2

Work on the parent Box implementation is now complete and we are ready to add the child box:

.
.
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.IntOffset
import kotlin.math.roundToInt
.
.
Box(
    modifier = Modifier
        .padding(20.dp)
        .width(parentBoxWidth)
        .height(childBoxSides)
.
.
    Box(Modifier.size(10.dp).background(Color.DarkGray, 
         shape = CircleShape).align(Alignment.CenterEnd))
 
        Box(
            Modifier
                .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
                .size(childBoxSides)
                .background(Color.Blue),
            contentAlignment = Alignment.Center
        ) {
            Text(
                swipeableState.currentValue, 
                color = Color.White, 
                fontSize = 22.sp
           )
        }
    }
.
.
}

Before we try out the swiping behavior, some of the above code needs some explanation. First, the offset modifier is applied to the child Box to control the horizontal position. This is achieved by using the current offset value stored in swipeableState to control the position of the Box along the x-axis.

.offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }

The child Box contains a single child in the form of a Text component. The text displayed on this component is set based on the state value for the current anchor (in other words “L”, “C” or “R”):

Text(
    swipeableState.currentValue, 
    color = Color.White, 
    fontSize = 22.sp
)

With the coding work completed, all that remains is to test that the swipe gesture detection works as intended.

Testing the project

With this phase of the project complete, we can now try out the swiping behavior. Using either the Preview panel in interactive mode or a device or emulator, click and swipe right anywhere within the bounds of the parent box. As you swipe, the child box will also move to the right. If you stop swiping before the child box reaches the mid-point between the first two anchors, it will animate back to the start anchor. Move the box beyond the midpoint, however, and the box will automatically animate to the second anchor, at which point the text will change from “L” to “C”. From this point, the box can be swiped in either direction with the same threshold behavior.

Figure 48-3

Summary

Swiping in Compose involves the movement of a component from one anchor point to another combined with a transition between different states. Swipe gestures are detected using the swipeable() modifier in conjunction with a map of anchors and state pairs. A threshold point is also declared between anchor points. If the swipe gesture ends before reaching the threshold, the target component moves back to the starting anchor, while the component will continue to the destination anchor if the swipe ends after the threshold.

Jetpack Compose Gesture Detection

The term “gesture” is used to define a contiguous sequence of interactions between the touch screen and the user. A typical gesture begins at the point that the screen is first touched and ends when the last finger or pointing device leaves the display surface. When correctly harnessed, gestures can be implemented as a form of communication between user and application. Swiping motions to turn the pages of an eBook, or a pinching movement involving two touches to zoom in or out of an image are prime examples of how gestures can be used to interact with an application.

Compose gesture detection

Jetpack Compose provides mechanisms for the detection of common gestures within an application. In this chapter, we will cover a variety of gesture types including tap (click), double-tap, long press, and dragging, as well as multi-touch gestures such as panning, zooming, and rotation. Swipe gestures are also supported but require a little extra explanation, so will be covered independently in the next chapter.

In several instances, Compose provides two ways to detect gestures. One approach involves the use of gesture detection modifiers which provide gesture detection capabilities with built-in visual effects. An alternative option is to use the functions provided by the PointerInputScope interface which require extra coding but provide more advanced gesture detection capabilities. Where available, both of these options will be covered in this chapter.

This chapter will take a practical approach to exploring gesture detection by creating an Android Studio project that includes examples of the types of gesture detection.

Creating the GestureDemo project

Launch Android Studio and create a new Empty Compose Activity project named GestureDemo, specifying com.example.gesturedemo as the package name, and selecting a minimum API level of API 26: Android 8.0 (Oreo). Within the MainActivity.kt file, delete the Greeting function and add a new empty composable named MainScreen:

@Composable
fun MainScreen() {
    
}

Next, edit the onCreateActivity() method and DefaultPreview function to call MainScreen instead of Greeting.

Detecting click gestures

Click gestures, also known as taps, can be detected on any visible composable using the clickable modifier. This modifier accepts a trailing lambda containing the code to be executed when a click is detected on the component to which it has been applied, for example:

SomeComposable(
    modifier = Modifier.clickable { /* Code to be executed */ }
)

Within the MainActivity.kt file, add a new composable named ClickDemo and call it from the MainScreen function:

.
.
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.unit.dp
.
.
@Composable
fun MainScreen() {
    ClickDemo()
}
 
@Composable
fun ClickDemo() {
 
    var colorState by remember { mutableStateOf(true)}
    var bgColor by remember { mutableStateOf(Color.Blue) }
 
    val clickHandler = {
 
        colorState = !colorState
 
        if (colorState == true) {
            bgColor = Color.Blue
        }
        else {
            bgColor = Color.DarkGray
        }
    }
 
    Box(
        Modifier
            .clickable { clickHandler() }
            .background(bgColor)
            .size(100.dp)
    )
}

The ClickDemo composable contains a Box component the background color if which is controlled by the bgColor state. The Box also has applied to it a clickable modifier configured to call clickHandler which, in turn, toggles the current value of colorState and uses it to switch the current bgColor value between blue and gray.

Use the Preview panel in interactive mode to test that clicking the Box causes the background color to change.

Detecting taps using PointerInputScope

While the clickable modifier is useful for detecting simple click gestures, it cannot distinguish between taps, presses, long presses, and double taps. For this level of precision, we need to utilize the detectTapGestures() function of PointerInputScope. This is applied to a composable via the pointerInput() modifier, which gives us access to the PointerInputScope as follows:

SomeComposable(
    Modifier
        .pointerInput(Unit) {
            detectTapGestures(
                onPress = { /* Press Detected */ },
                onDoubleTap = { /* Double Tap Detected */ },
                onLongPress = { /* Long Press Detected */ },
                onTap = { /* Tap Detected */ }
            )
        }
)

Edit the MainActivity.kt file as follows to add and call a composable named TapPressDemo:

.
.
import androidx.compose.ui.Alignment
import androidx.compose.ui.input.pointer.pointerInput
.
.
@Composable
fun MainScreen() {
    TapPressDemo()
}
 
@Composable
fun TapPressDemo() {
 
    var textState by remember {
        mutableStateOf("Waiting ....")
    }
 
    val tapHandler = { status : String ->
        textState = status
 
    }
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Box(
            Modifier
                .padding(10.dp)
                .background(Color.Blue)
                .size(100.dp)
                .pointerInput(Unit) {
                    detectTapGestures(
                        onPress = { tapHandler("onPress Detected") },
                        onDoubleTap = { tapHandler("onDoubleTap Detected") },
                        onLongPress = { tapHandler("onLongPress Detected") },
                        onTap = { tapHandler("onTap Detected") }
                    )
                }
        )
        Spacer(Modifier.height(10.dp))
        Text(textState)
    }
}

The TapPressDemo composable contains Box and Text components within a Column parent. The string displayed on the Text component is based on the current textState value. When a gesture is detected by the detectTapGestures() function, the tapHandler is called and passed a new string describing the type of gesture detected. This string is assigned to textState causing it to appear in the Text component. Refresh the Preview panel and use interactive mode to experiment with different tap and press gestures. While running, the user interface should match that shown in Figure 47-1:

Figure 47-1

Detecting drag gestures

Drag gestures on a component can be detected by applying the draggable() modifier. This modifier stores the offset (or delta) of the drag motion from the point of origin as it occurs and stores it in a state, an instance of which can be created via a call to the rememberDraggableState() function. This state can then, for example, be used to move the position of the dragged component in coordination with the gesture. The draggable() call also needs to be told whether to detect horizontal or vertical gestures.

To see the draggable() modifier in action, make the following changes to the MainActivity.kt file:

.
.
import androidx.compose.ui.unit.IntOffset
 
import kotlin.math.roundToInt
.
.
@Composable
fun MainScreen() {
    DragDemo()
}
 
@Composable
fun DragDemo() {
 
    Box(modifier = Modifier.fillMaxSize()) {
        
        var xOffset by remember { mutableStateOf(0f) }
        
        Box(
            modifier = Modifier
                .offset { IntOffset(xOffset.roundToInt(), 0) }
                .size(100.dp)
                .background(Color.Blue)
                .draggable(
                    orientation = Orientation.Horizontal,
                    state = rememberDraggableState { distance ->
                        xOffset += distance
                    }
                )
        )
    }
}

The example creates a state to store the current x-axis offset and uses it as the x-coordinate of the draggable Box:

var xOffset by remember { mutableStateOf(0f) }
.
.
Box(
    modifier = Modifier
        .offset { IntOffset(xOffset.roundToInt(), 0) }

The draggable modifier is then applied to the Box with the orientation parameter set to horizontal. The state parameter is set by calling the rememberDraggableState() function, the trailing lambda for which is used to obtain the current delta value and add it to the xOffset state. This, in turn, causes the box to move in the direction of the drag gesture:

.draggable(
    orientation = Orientation.Horizontal,
    state = rememberDraggableState { distance ->
        xOffset += distance
    }
)

Preview the design and test that the Box can be dragged horizontally left and right:

Figure 47-2

The draggable() modifier is only useful for supporting drag gestures in either the horizontal or vertical plane. To support multi-directional drag operations, the PointerInputScope detectDragGestures function needs to be used.

Detecting drag gestures using PointerInputScope

The PointerInputScope detectDragGestures function allows us to support both horizontal and vertical drag operations simultaneously and can be implemented using the following syntax:

SomeComposable() {
    .pointerInput(Unit) {
        detectDragGestures { _, distance ->
            xOffset += distance.x
            yOffset += distance.y
        }
    }

To see this in action, add and call a new function named PointerInputDrag in the MainActivity.kt file as follows:

@Composable
fun MainScreen() {
    PointerInputDrag()
}
 
@Composable
fun PointerInputDrag() {
 
    Box(modifier = Modifier.fillMaxSize()) {
 
        var xOffset by remember { mutableStateOf(0f) }
        var yOffset by remember { mutableStateOf(0f) }
 
        Box(
            Modifier
                .offset { IntOffset(xOffset.roundToInt(), yOffset.roundToInt()) }
                .background(Color.Blue)
                .size(100.dp)
                .pointerInput(Unit) {
                    detectDragGestures { _, distance ->
                        xOffset += distance.x
                        yOffset += distance.y
                    }
                }
        )
    }
}

Since we are supporting both horizontal and vertical dragging gestures, we have declared states to store both x and y offsets. The detectDragGestures lambda passes us an Offset object which we have named distance and from which we can obtain the latest drag x and y offset values. These are added to the xOffset and yOffset states respectively, causing the Box component to follow the dragging motion around the screen:

.pointerInput(Unit) {
    detectDragGestures { _, distance ->
        xOffset += distance.x
        yOffset += distance.y
    }
}

Preview the design in interactive mode and test that it is possible to drag the box in any direction on the screen:

Figure 47-3

Scrolling using the scrollable modifier

Scrolling was introduced in the chapter entitled “An Overview of Lists and Grids in Compose” in relation to scrolling through lists of items. Using the scrollable() modifier, scrolling gestures are not limited to list components. As with the draggable() modifier, scrollable() is limited to support either horizontal or vertical gestures but not both in the same modifier declaration. Scrollable state is managed using the rememberScrollableState() function, the lambda for which gives us access to the distance traveled by the scroll gesture which can, in turn, be used to adjust the offset of one or more composables in the hierarchy. Make the following changes to implement scrolling in the MainActivity.kt file:

@Composable
fun MainScreen() {
    ScrollableModifier()
}
 
@Composable
fun ScrollableModifier() {
 
    var offset by remember { mutableStateOf(0f) }
 
    Box(
        Modifier
            .fillMaxSize()
            .scrollable(
                orientation = Orientation.Vertical,
                state = rememberScrollableState { distance ->
                    offset += distance
                    distance
                }
            )
    ) {
        Box(modifier = Modifier
            .size(90.dp)
            .offset { IntOffset(0, offset.roundToInt()) }
            .background(Color.Red))
    }
}

Preview the new composable and click and drag vertically on the screen. Note that the red box scrolls up and down in response to vertical scrolling gestures.

Scrolling using the scroll modifiers

As we saw in the previous example, the scrollable() modifier can only detect scrolling in a single orientation. To detect both horizontal and vertical scrolling, we need to use the scroll modifiers. These are essentially two modifiers named verticalScroll() and horizontalScroll() both of which must be passed a scroll state created via a call to the rememberScrollState() function, for example:

SomeComposable(modifier = Modifier
    .verticalScroll(rememberScrollState())
    .horizontalScroll(rememberScrollState())) {
}

In addition to supporting scrolling in both orientations, the scroll functions also have the advantage that they handle the actual scrolling. This means that we do not need to write code to apply new offsets to implement the scrolling behavior.

To demonstrate these modifiers, we will use a Box composable containing an image. The Box will be sized to act as a “viewport” through which only part of the image can be seen at any one time. We will, instead, use scrolling to allow the image to be scrolled within the box.

The first step is to add an image resource to the project. In previous chapters, we used the Resource Manager to add an image to the project resources. As we will demonstrate in this chapter, it is also possible to copy and paste an image file directly into the drawables folder within the Project tool window.

The image that will be used for the project is named vacation.jpg and can be found in the images folder of the sample code download available from the following URL:

https://www.ebookfrenzy.com/web/compose/index.php

Locate the image in the file system navigator for your operating system and select and copy it. Right-click on the app -> res -> drawable entry in the Project tool window and select Paste from the resulting menu to add the file to the folder:

Figure 47-4

Next, modify the MainActivity.kt file as follows:

.
.
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.res.imageResource
.
.
@Composable
fun MainScreen() {
    ScrollModifiers()
}
 
@Composable
fun ScrollModifiers() {
 
    val image = ImageBitmap.imageResource(id = R.drawable.vacation)
 
    Box(modifier = Modifier
        .size(150.dp)
        .verticalScroll(rememberScrollState())
        .horizontalScroll(rememberScrollState())) {
        Canvas(
            modifier = Modifier
                .size(360.dp, 270.dp)
        )
        {
            drawImage(
                image = image,
                topLeft = Offset(
                    x = 0f,
                    y = 0f
                ),
            )
        }
    }
}

When previewed in interactive mode, only part of the image will be visible within the Box component. Clicking and dragging on the image will allow you to move the photo so that other areas of the image can be viewed:

Figure 47-5

Detecting pinch gestures

The remainder of this chapter will look at gestures that require multiple touch-points on the screen, beginning with pinch gestures. Pinch gestures are typically used to change the size (scale) of content and give the effect of zooming in and out. This type of gesture is detected using the transformable() modifier which takes as parameters a state of type TransformableState, an instance of which can be created by a call to the rememberTransformableState() function. This function accepts a trailing lambda to which are passed the following three parameters:

  • Scale change – A Float value updated when pinch gestures are performed.
  • Offset change – An Offset instance containing the current x and y offset values. This value is updated when a gesture causes the target component to move (referred to as translations).
  • Rotation change – A Float value representing the current angle change when detecting rotation gestures.

All three of these parameters need to be declared when calling the rememberTransformationState() function, even if you do not make use of them in the body of the lambda. A typical TransformableState declaration that tracks scale changes might read as follows:

var scale by remember { mutableStateOf(1f) }
 
val state = rememberTransformableState { scaleChange, offsetChange, 
                                               rotationChange ->
    scale *= scaleChange
}

Having created the state, it can then be used when calling the transformable() modifier on a composable as follows:

SomeComposable(modifier = Modifier
                           .transformable(state = state) {
}

As the pinch gesture progresses, the scale state will be updated. To reflect these changes we will need to make sure that the composable also changes in size. We can do this by accessing the graphics layer of the composable and setting the scaleX and scaleY properties to the current scale state. As we will demonstrate later, the rotation and translation transformations will also require access to the graphics layer.

Start this phase of the tutorial by making the following changes to the MainActivity.kt file to implement pinch gesture detection:

@Composable
fun MainScreen() {
    MultiTouchDemo()
}
 
@Composable
fun MultiTouchDemo() {
 
    var scale by remember { mutableStateOf(1f) }
 
    val state = rememberTransformableState { 
                  scaleChange, offsetChange, rotationChange ->
        scale *= scaleChange
    }
 
    Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
        Box(
            Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                )
                .transformable(state = state)
                .background(Color.Blue)
                .size(100.dp)
        )
    }
}

To test out the pinch gesture the app will need to be run on a device or emulator because the Preview panel does not yet appear to support multi-touch gestures). Once running, perform a pinch gesture on the blue box to zoom in and out. If you are using an emulator, hold the keyboard Ctrl key (Cmd on macOS) while clicking and dragging to simulate multiple touches.

Detecting rotation gestures

We can now add rotation support to the example with just three additional lines of code:

@Composable
fun MultiTouchDemo() {
 
    var scale by remember { mutableStateOf(1f) }
    var angle by remember { mutableStateOf(0f) }
 
    val state = rememberTransformableState { 
           scaleChange, offsetChange, rotationChange ->
        scale *= scaleChange
        angle += rotationChange
    }
 
    Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
        Box(
            Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = angle
                )
                .transformable(state = state)
                .background(Color.Blue)
                .size(100.dp)
        )
    }
}

Compile and run the app and perform both pinch and rotation gestures. Both the size and angle of the Box should now change:

Figure 47-6

Detecting translation gestures

Translation involves the change in the position of a component. As with rotation detection, we can add translation support to our example with just a few lines of code:

@Composable
fun MultiTouchDemo() {
 
    var scale by remember { mutableStateOf(1f) }
    var angle by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero)}
 
    val state = rememberTransformableState { 
                  scaleChange, offsetChange, rotationChange ->
        scale *= scaleChange
        angle += rotationChange
        offset += offsetChange
    }
 
    Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
        Box(
            Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = angle,
                    translationX = offset.x,
                    translationY = offset.y
                )
                .transformable(state = state)
                .background(Color.Blue)
                .size(100.dp)
        )
    }
}

Note that the translation gesture only works when testing on a physical device and requires two points of contact within the box to initiate. Also, since we are performing a pan gesture the box will move in the opposite direction to the gesture motion.

Summary

Gestures are a key form of interaction between the user and an app running on an Android device. Using the gesture detection features of Compose, it is possible to respond to a range of screen interactions including taps, long presses, scrolling, pinches, and rotations. Gestures are detected in Compose by applying modifiers to composables and responding to state changes.

A Jetpack Compose Bottom Navigation Bar Tutorial

Following on from the overview provided previously in the chapter entitled Screen Navigation in Jetpack Compose this chapter will create a project that integrates navigation into an activity using the Compose BottomNavigation component. The project will also provide a brief introduction to the Scaffold component and demonstrate how it can be used to create a standard screen layout that conforms to the Material theme guidelines.

Creating the BottomBarDemo project

Launch Android Studio and create a new Empty Compose Activity project named BottomBarDemo, specifying com.example.bottombardemo as the package name, and selecting a minimum API level of API 26: Android 8.0 (Oreo). Within the MainActivity.kt file, delete the Greeting function and add a new empty composable named MainScreen:

@Composable
fun MainScreen() {
    
}

Next, edit the onCreateActivity() method and DefaultPreview function to call MainScreen instead of Greeting.

Before proceeding, we will also need to add the Compose navigation library to the project build settings. Within the Project tool window, locate and open the module level Gradle build file (app -> Gradle Scripts -> build.gradle (Module: BottomBarDemo.app) file and add the following line to the dependencies section:

implementation 'androidx.navigation:navigation-compose:2.4.0'

Declaring the navigation routes

When the project is completed, it will include a bottom bar containing three items which, when clicked, will navigate to different screens, each represented by a composable. The first step we need to complete is to add the routes for the three destinations which will be declared using a sealed class. Begin by right-clicking on the app -> java -> com.example.bottombardemo entry in the Project tool window and selecting the New -> Kotlin File/ Class menu option. In the new class dialog, name the class NavRoutes, select the Sealed Class entry in the list and press the keyboard return key to generate the file. Edit the new file to add the destination routes as follows:

package com.example.bottombardemo
 
sealed class NavRoutes(val route: String) {
    object Home : NavRoutes("home")
    object Contacts : NavRoutes("contacts")
    object Favorites : NavRoutes("favorites")
}

Designing bar items

Each item in the bottom bar will need a title string, an icon image, and the route to which the app should navigate when the item is clicked. To keep the MainActivity.kt file as simple as possible, we will also declare the bar item class as a separate file. Using the steps outlined above, add a new Kotlin Class file named BarItem, this time using the Data Class option, to the project and modify it so that it reads as follows:

package com.example.bottombardemo
 
import androidx.compose.ui.graphics.vector.ImageVector
 
data class BarItem(
    val title: String,
    val image: ImageVector,
    val route: String
)

Creating the bar item list

Now that we have the BarItem class providing a template for each bar item, the next step is to create a list containing the three bar items, each configured with the appropriate string, image, and route properties. Add another Kotlin class using the Object option, this time named NavBarItems, and implement the list as follows:

package com.example.bottombardemo
 
import androidx.compose.material.icons.*
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Face
 
object NavBarItems {
    val BarItems = listOf(
        BarItem(
            title = "Home",
            image = Icons.Filled.Home,
            route = "home"
        ),
        BarItem(
            title = "Contacts",
            image = Icons.Filled.Face,
            route = "contacts"
        ),
        BarItem(
            title = "Favorites",
            image = Icons.Filled.Favorite,
            route = "favorites"
        )
    )
}

Note that the above declaration makes use of the built-in Material theme icons for the images. Although not as extensive as the Clip Art list available via the Resource Manager used in earlier chapters, these icons provide a quick and convenient way to add graphics to your project.

Adding the destination screens

Each of the three destinations now needs a composable. These will be simple functions that do nothing more than display the icon for the corresponding bar item selection. Each screen composable will be declared in a separate file, each of which will be placed in a new package named com.example.bottombardemo.screens. Create this package now by right-clicking on the com.example.bottombardemo entry in the Project tool window and selecting the New package menu option. In the resulting dialog, name the package com.example.bottombardemo. screens as shown in Figure 46-1 before tapping the keyboard enter key:

Figure 46-1

Right-click on the new package entry in the Project tool window, select the option to create a new Kotlin class named Home, and modify it so that it reads as follows:

.
.
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
 
@Composable
fun Home() {
 
    Box(
        modifier = Modifier.fillMaxSize()
    ) {
        Icon(
            imageVector = Icons.Filled.Home,
            contentDescription = "home",
            tint = Color.Blue,
            modifier = Modifier.size(150.dp)
                .align(Alignment.Center)
        )
    }
}

Repeat these steps to add class files for the two remaining screens named Contacts and Favorites using the same code as that used for the home screen above, but changing the icon import, imageVector property, and contentDescription accordingly. In the case of the Contacts composable the following changes apply:

.
.
import androidx.compose.material.icons.filled.Face
.
.
@Composable
fun Contacts() {
 
    Box(
        modifier = Modifier.fillMaxSize()
    ) {
        Icon(
            imageVector = Icons.Filled.Face,
            contentDescription = "contacts",
            tint = Color.Blue,
            modifier = Modifier.size(150.dp)
                .align(Alignment.Center)
        )
    }
}

Similarly, the following changes will be needed for the Favorites.kt file:

.
.
import androidx.compose.material.icons.filled.Favorite
.
.
@Composable
fun Favorites() {
 
    Box(
        modifier = Modifier.fillMaxSize()
    ) {
        Icon(
            imageVector = Icons.Filled.Favorite,
            contentDescription = "favorites",
            tint = Color.Blue,
            modifier = Modifier.size(150.dp)
                .align(Alignment.Center)
        )
    }
}

Creating the navigation controller and host

Now that the basic elements of the project have been created, the next step is to create both the navigation controller (NavHostController) and navigation host (NavHost) instances. Edit the MainActivity.kt file and make the following modifications:

.
.
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.NavHostController
import com.example.bottombardemo.screens.Contacts
import com.example.bottombardemo.screens.Favorites
import com.example.bottombardemo.screens.Home
.
.
@Composable
fun MainScreen() {
 
    val navController = rememberNavController()
}
 
@Composable
fun NavigationHost(navController: NavHostController) {
 
    NavHost(
        navController = navController,
        startDestination = NavRoutes.Home.route,
    ) {
        composable(NavRoutes.Home.route) {
            Home()
        }
 
        composable(NavRoutes.Contacts.route) {
            Contacts()
        }
 
        composable(NavRoutes.Favorites.route) {
            Favorites()
        }
    }
}

Designing the navigation bar

The bottom navigation bar will be implemented in a separate composable named BottomNavBar which will need to be passed the navigation controller instance created in the NavSetup function. It will, of course, consist of a BottomNavigation component and a BottomNavigationItem child for each of the three destination screens. Start by adding the BottomNavBar function to the MainActivity.kt file as follows:

.
.
import androidx.compose.material.*
import androidx.compose.runtime.getValue
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.NavGraph.Companion.findStartDestination
.
.
@Composable
fun BottomNavigationBar(navController: NavHostController) {
 
    BottomNavigation {
 
    }
}

Within the BottomNavigation call, we will need to be able to identify the route of the currently selected navigation destination. We do this by calling the currentBackStackEntryAsState() method of the navigation controller to obtain the current back stack entry from which we can access the route:

@Composable
fun BottomNavigationBar(navController: NavHostController) {
 
    BottomNavigation {
        val backStackEntry by navController.currentBackStackEntryAsState()
        val currentRoute = backStackEntry?.destination?.route
 
    }
}

All that remains is to iterate through the items located in BarItems and use the title, image, and route settings for each item to configure BottomNavigationItem instances for each destination:

@Composable
fun BottomNavigationBar(navController: NavHostController) {
 
    BottomNavigation {
        val backStackEntry by navController.currentBackStackEntryAsState()
        val currentRoute = backStackEntry?.destination?.route
 
        NavBarItems.BarItems.forEach { navItem ->
 
            BottomNavigationItem(
                selected = currentRoute == navItem.route,
                onClick = {
                    navController.navigate(navItem.route) {
                       popUpTo(navController.graph.findStartDestination().id) {
                           saveState = true
                       }
                        launchSingleTop = true
                        restoreState = true
                    }
                },
 
                icon = {
                    Icon(imageVector = navItem.image, 
                           contentDescription = navItem.title)
                },
                label = {
                    Text(text = navItem.title)
                },
            )
        }
    }
}

Working with the Scaffold component

The final task before testing the project is to complete the layout in the MainScreen function. For this, we will be making use of the Compose Scaffold component. This component provides a template layout structure for the standard Material screen layout. Scaffold includes slots for standard layout elements including a top bar, content area, bottom bar, floating action button, snackbar, and a navigation drawer. For this example, we will be using the top bar, content area, and bottom bar scaffold slots. Edit the MainScreen function and add the Scaffold call as follows:

@Composable
fun MainScreen() {
    val navController = rememberNavController()
 
    Scaffold(
        topBar = { TopAppBar(title = {Text("Bottom Navigation Demo")})  },
        content = { NavigationHost(navController = navController) },
        bottomBar = { BottomNavigationBar(navController = navController)}
    )
}

For the top bar, we are using the TopAppBar component configured to display a Text composable while our NavigationHost composable is used for the content area of the screen. Finally, the bottom bar position is occupied by our BottomNavigationBar component.

Testing the project

At the time of writing, the Compose features used in this example were not supported in the Preview panel. To test the app, therefore, you will need to compile and run the project on a device or emulator where it should match the screen shown in Figure 46-2:

Figure 46-2

Test that the navigation works by clicking on the bottom bar items and verifying that the correct screen appears in each case. Also, check that the code to prevent duplicate back stack entries is working by clicking multiple times on the Contacts bar item followed by the back button (or a rightward swipe on newer Android versions). If the code is working as intended, the app should navigate back to the Home screen.

Summary

In this chapter, we have used the Compose BottomNavigation component to implement navigation between screens within an activity. This involves creating a BottomNavigationItem child for each screen together with a navigation controller and NavHost. A key step in implementing bottom bar navigation involves keeping track of the current destination route, a task which is achieved by accessing the current back stack entry via a call to the currentBackStackEntryAsState() method of the navigation controller. The project also made use of the Scaffold composable to create a layout that conforms to Material theme standards.

A Jetpack Compose Navigation Tutorial

The previous chapter provided an overview of navigation using the Jetpack Navigation Architecture Component when developing with Compose. This chapter will build on this knowledge to create a project that uses navigation, including an example of passing an argument from one destination to another.

Creating the NavigationDemo project

Launch Android Studio and create a new Empty Compose Activity project named NavigationDemo, specifying com.example.navigationdemo as the package name, and selecting a minimum API level of API 26: Android 8.0 (Oreo). Within the MainActivity.kt file, delete the Greeting function and add a new empty composable named MainScreen:

@Composable
fun MainScreen() {
    
}

Next, edit the onCreateActivity() method and DefaultPreview function to call MainScreen instead of Greeting.

Before proceeding, we will also need to add the Compose navigation library to the project build settings. Within the Project tool window, locate and open the module level Gradle build file (app -> Gradle Scripts -> build.gradle (Module: NavigationDemo.app) file and add the following line to the dependencies section (keeping in mind that a more recent version of the library may now be available):

.
.
implementation  'androidx.navigation:navigation-compose:2.4.0'
.
.

About the NavigationDemo project

The completed project will comprise three destination screens named “home”, “welcome” and “profile”. The home screen will contain a text field into which the user will enter their name and a button which, when clicked, will navigate to the welcome screen, passing the user’s name as an argument for inclusion in a welcome message. The welcome screen will also contain a button to navigate to the profile screen, the sole purpose of which is for experimenting with the popUpTo() navigation option method.

Declaring the navigation routes

The first step in implementing the navigation in the project is to add the routes for the three destinations which will be declared using a sealed class. Begin by right-clicking on the app -> java -> com.example.navigationdemo entry in the Project tool window and selecting the New -> Kotlin File/Class menu option. In the new class dialog, name the class NavRoutes, select the Sealed Class entry in the list and press the keyboard return key to generate the file. Edit the new file to add the destination routes as follows:

package com.example.navigationdemo
 
sealed class NavRoutes(val route: String) {
    object Home : NavRoutes("home")
    object Welcome : NavRoutes("welcome")
    object Profile : NavRoutes("profile")
}

Adding the home screen

The three destinations now need a composable, each of which we will declare in a separate file placed in a new package named com.example.navigationdemo.screens. Create this package now by right-clicking on the com. example.navigationdemo entry in the Project tool window and selecting the New -> Package menu option. In the resulting dialog, name the package com.example.navigationdemo.screens as shown in Figure 45-1 before tapping the keyboard enter key:

Figure 45-1

Right-click on the new package entry in the Project tool window, select the option to create a new Kotlin class file, name it Home, and modify it so that it reads as follows:

.
.
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
 
import com.example.navigationdemo.NavRoutes
 
@Composable
fun Home(navController: NavHostController) {
 
    var userName by remember { mutableStateOf("") }
    val onUserNameChange = { text : String ->
        userName = text
    }
 
    Box(
        modifier = Modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            CustomTextField(
                title = "Enter your name",
                textState = userName,
                onTextChange = onUserNameChange
            )
            
            Spacer(modifier = Modifier.size(30.dp))
            
            Button(onClick = { }) {
                Text(text = "Register")
            }
        }
    }
}
 
@Composable
fun CustomTextField(
    title: String,
    textState: String,
    onTextChange: (String) -> Unit,
) {
    OutlinedTextField(
        value = textState,
        onValueChange = { onTextChange(it) },
        singleLine = true,
        label = { Text(title)},
        modifier = Modifier.padding(10.dp),
        textStyle = TextStyle(fontWeight = FontWeight.Bold,
            fontSize = 30.sp)
    )
}

Adding the welcome screen

Add a new class file to the screens package named Welcome.kt. Once the file has been created, edit it so that it reads as follows:

.
.
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
 
import com.example.navigationdemo.NavRoutes
 
@Composable
fun Welcome(navController: NavHostController) {
 
    Box(
        modifier = Modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text("Welcome", style = MaterialTheme.typography.h5)
 
            Spacer(modifier = Modifier.size(30.dp))
 
            Button(onClick = { }) {
                Text(text = "Set up your Profile")
            }
        }
    }
}

Adding the profile screen

The profile screen is the simplest of the composables and consists of a single Text component. Once again, add a new class file to the screens package, this time named Profile.kt, and edit it to make the following changes:

.
.
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
 
@Composable
fun Profile() {
 
    Box(
        modifier = Modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
            Text("Profile Screen", style = MaterialTheme.typography.h5)
    }
}

Creating the navigation controller and host

Now that the basic elements of the project have been created, the next step is to create the navigation controller (NavHostController) and navigation host (NavHost) instances. Edit the MainActivity.kt file and make the following modifications:

.
.
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.navigationdemo.screens.Home
import com.example.navigationdemo.screens.Profile
import com.example.navigationdemo.screens.Welcome
.
.
@Composable
fun MainScreen() {
 
    val navController = rememberNavController()
 
    NavHost(
        navController = navController,
        startDestination = NavRoutes.Home.route,
    ) {
        composable(NavRoutes.Home.route) {
            Home(navController = navController)
        }
 
        composable(NavRoutes.Welcome.route) {
            Welcome(navController = navController)
        }
 
        composable(NavRoutes.Profile.route) {
            Profile()
        }
    }
}

The above code changes to the MainScreen function begin by obtaining a navigation controller instance via a call to the rememberNavController() method. The NavHost component is called, assigning the home screen as the start destination. The composable() method is then called to add a route for each of the screens.

Implementing the screen navigation

Navigation needs to be initiated when the Button components in the home and welcome screens are clicked. Both composables have already been passed the navigation controller on which we will be calling the navigate() method. Starting with the Home.kt file, locate the Button component and add the navigation code to the onClick property using the route for the welcome screen:

.
.
Button(onClick = {
    navController.navigate(NavRoutes.Welcome.route)
}) {
    Text(text = "Register")
}
.
.

Next, edit the Welcome.kt file and add code to the Button onClick property to navigate to the profile screen:

.
.
Button(onClick = {
        navController.navigate(NavRoutes.Profile.route)
    }) {
        Text(text = "Set up your Profile")
    }
.
.

Take this opportunity to compile and run the app on a device or emulator and test that the buttons navigate to the correct screens when clicked.

Passing the user name argument

The welcome destination route in the NavHost declaration now needs to be extended so that the user name typed into the text field can be passed to the welcome screen during the navigation. First, edit the Welcome. kt file and modify the Welcome function to accept a user name String parameter and to display it in the Text component:

.
.
@Composable
fun Welcome(navController: NavHostController, userName: String?) {
.
.
       Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text("Welcome, $userName", style = MaterialTheme.typography.h5)
.
.

With the Welcome composable ready to accept and display the user name, the NavHost declaration needs to be changed to extract the parameter from the navigation back stack entry and pass it to the Welcome function. Return to the MainActivity.kt file and modify the Welcome route composable() call so that it reads as follows:

.
.
composable(NavRoutes.Welcome.route + "/{userName}") { backStackEntry ->
 
    val userName = backStackEntry.arguments?.getString("userName")
    Welcome(navController = navController, userName)
}
.
.

The final task before we test the app once again is to modify the onClick handler assigned to the home screen Button component to get the current user name state value and append it to the route in the navigate() method call. Edit the Home.kt file, locate the Button call and modify the onClick handler:

.
.
    Button(onClick = {
        navController.navigate(NavRoutes.Welcome.route + "/$userName")
    }) {
        Text(text = "Register")
    }
.
.

Testing the project

Compile and run the project on a device or emulator and enter a name into the text field on the home screen:

Figure 45-2

Click the Register button and verify that the name you entered appears in the Text component of the Welcome screen:

Figure 45-3

After clicking on the “Set up your Profile” button to reach the profile screen, the back button located in the bottom toolbar should navigate through the back stack (if you are using Android 12 or later, swipe right to navigate backwards), starting with the welcome screen followed by the home screen. If we want the backward navigation to return directly to the home screen we need to make sure everything except the home destination is popped off the navigation back stack using the popUpTo() method call. This needs to be called as an option to the navigate() method in the Button onClick handler in the Welcome composable:

.
.
    Button(onClick = {
        navController.navigate(NavRoutes.Profile.route) {
            popUpTo(NavRoutes.Home.route)
        }
.
.

When the app runs, tapping the back button (or swiping right on newer Android versions) from the profile screen should now skip the welcome screen and return directly to the home screen.

Summary

In this chapter, we have created a project which makes use of navigation to switch between screens within an activity. This included creating a navigation controller and declaring a navigation host initialized with navigation routes for each destination. The tutorial also implemented a navigation argument to pass a string value from one navigation destination to another.

Screen Navigation in Jetpack Compose

Very few Android apps today consist of just a single screen. In reality, most apps comprise multiple screens through which the user navigates using screen gestures, button clicks, and menu selections. Before the introduction of Android Jetpack, the implementation of navigation within an app was largely a manual coding process with no easy way to view and organize potentially complex navigation paths. This situation improved considerably, however, with the introduction of the Android Navigation Architecture Component which has now been extended to support navigation in Compose-based apps. This chapter will provide an overview of navigation within Compose including explanations of routes, navigation graphs, the navigation back stack, passing arguments, and the NavHostController and NavHost classes.

Understanding navigation

Every app has a home screen that appears after the app has launched and after any splash screen has appeared (a splash screen being the app branding screen that appears temporarily while the app loads). From this home screen, the user will typically perform tasks that will result in other screens appearing. These screens will usually take the form of other composables within the project. A messaging app, for example, might have a home screen listing current messages from which the user can navigate to either another screen to access a contact list, or to a settings screen. The contacts list screen, in turn, might allow the user to navigate to other screens where new users can be added or existing contacts updated. Graphically, the app’s navigation graph might be represented as shown in Figure 44-1:

Figure 44-1

Each screen that makes up an app, including the home screen, is referred to as a destination and is usually a composable or activity. The Android navigation architecture uses a navigation back stack to track the user’s path through the destinations within the app. When the app first launches, the home screen is the first destination placed onto the stack and becomes the current destination. When the user navigates to another destination, that screen becomes the current destination and is pushed onto the back stack above the home destination. As the user navigates to other screens, they are also pushed onto the stack. Figure 44-2, for example, shows the current state of the navigation stack for the hypothetical messaging app after the user has launched the app and is navigating to the “Add Contact” screen:

Figure 44-2

As the user navigates back through the screens using the system back button, each destination composable is popped off the stack until the home screen is once again the only destination on the stack. In Figure 44-3, the user has navigated back from the Add Contact screen, popping it off the stack and making the Contact List screen composable the current destination:

Figure 44-3

All of the work involved in navigating between destinations and managing the navigation stack is handled by a navigation controller which is represented by the NavHostController class. It is also possible to manually pop composables off the stack so that the app returns to a screen lower down the stack when the user navigates backward from the current screen.

Adding navigation to an Android project using the Navigation Architecture Component is a straightforward process involving a navigation host, navigation graph, navigation actions, and a minimal amount of code writing to obtain a reference to, and interact with, the navigation controller instance.

Declaring a navigation controller

The first step in adding navigation to an app project is to create a NavHostController instance. This is responsible for managing the back stack and keeping track of which composable is the current destination. So that the integrity of the back stack is maintained through recomposition, NavHostController is a stateful object and is created via a call to the rememberNavController() method as follows:

val navController = rememberNavController()

Once a navigation controller has been created it needs to be assigned to a NavHost instance.

Declaring a navigation host

The navigation host (NavHost) is a special component that is added to the user interface layout of an activity and serves as a placeholder for the destinations through which the user will navigate. Figure 44-4, for example, shows a typical activity screen and highlights the area represented by the navigation host:

Figure 44-4

When it is called, NavHost must be passed a NavHostController instance, a composable to serve as the start destination, and a navigation graph. The navigation graph consists of all the composables that are to be available as navigation destinations within the context of the navigation controller. These destinations are declared in the form of routes:

NavHost(navController = navController, startDestination = <start route>) {
    // Navigation graph destinations
}

Adding destinations to the navigation graph

Destinations are added to the navigation graph by making calls to the composable() method and providing a route and destination. The route is simply a string value that uniquely identifies the destination within the context of the current navigation controller and the destination is the composable to be called when the navigation is performed. The following NavHost declaration includes a navigation graph consisting of three destinations, with the “home” route configured as the start destination:

NavHost(navController = navController, startDestination = "home") {
 
    composable("home") {
        Home()
    }
 
    composable("customers") {
        Customers()
    }
 
    composable("purchases") {
        Purchases()
    }
}

A more flexible alternative to hard coding the route strings into the composable() method calls is to define the routes in a sealed class:

sealed class Routes(val route: String) {
    object Home : Routes("home")
    object Customers : Routes("customers")
    object Purchases : Routes("purchases")
}

With the class declared, the NavHost will now reference the routes as follows:

NavHost(navController = navController, startDestination = Routes.Home.route) {
 
    composable(Routes.Home.route) {
        Home()
    }
 
    composable(Routes.Customers.route) {
        Customers()
    }
 
    composable(Routes.Purchases.route) {
        Purchases()
    }
}

The use of the sealed class approach gives us the advantage of a single location in which to make changes to the routes, and also adds syntax validation to avoid mistyping a route string when creating a NavHost or performing navigation.

Navigating to destinations

The primary mechanism for triggering navigation is via calls to the navigate() method of the navigation controller instance, specifying the route for the destination composable. The following code, for example, configures a Button component to navigate to the Customers screen when clicked:

Button(onClick = {
    navController.navigate(Routes.Customers.route)
}) {
    Text(text = "Navigate to Customers")
}

The navigate() method also accepts a trailing lambda containing navigation options, one of which is the popUpTo() function. Consider, for example, a scenario where the user starts on the home screen and then navigates to the customer screen. The customer screen displays a list of customer names which, when clicked navigates to the purchases screen populated with a list of the selected customer’s previous purchases. At this point, the back stack contains the customer and home destinations. If the user where to tap the back button located at the bottom of the screen, the app will navigate back to the customer screen. The popUpTo() navigation option allows us to pop items off the stack back to the specific destination. We could, for example, pop all destinations off the stack before navigating to the purchases screen so that only the home destination remains on the back stack as follows:

Button(onClick = {
    navController.navigate(Routes.Customers.route) {
        popUpTo(Routes.Home.route)
    }
}) {
    Text(text = "Navigate to Customers")
}

Now when the user clicks the back button on the purchases screen, the app will navigate directly to the home screen. The popUpTo() method also accepts options. The following, for example, uses the inclusive option to also pop the home destination off the stack before performing the navigation:

Button(onClick = {
    navController.navigate(Routes.Customers.route) {
        popUpTo(Routes.Home.route) {
            inclusive = true
        }
    }
}) {
    Text(text = "Navigate to Customers")
}

By default, an attempt to navigate from the current destination to itself will push an additional instance of the destination onto the stack. In most situations, this is unlikely to be the desired behavior. To prevent the addition of multiple instances of the same destination to the top of the stack, set the launchSingleTop option to true when calling the navigate() method:

Button(onClick = {
    navController.navigate(Routes.Customers.route) {
        launchSingleTop = true
    }
}) {
    Text(text = "Navigate to Customers")
}

The saveState and restoreState options, if set to true, will automatically save and restore the state of back stack entries when the user reselects a destination that has been selected previously.

Passing arguments to a destination

It is a common requirement when navigating from one screen to another to need to pass an argument to the destination. Compose supports the passing of arguments of a wide range of types from one screen to another and involves several steps. In our hypothetical example, we would probably need to pass the name of the selected customer from the customer screen to the purchases screen so that the correct purchase history can be displayed.

The first step in navigating with arguments involves adding the argument name to the destination route. We can, for example, add an argument named “customerName” to the purchases route as follows:

NavHost(navController = navController, startDestination = Routes.Home.route) {
.
.
    composable(Routes.Purchases.route + "/{customerName}") {
        Purchases()
    }
.
.
}

When the app triggers navigation to the customer destination, the value to be assigned to the argument will be stored within the corresponding back stack entry. The back stack entry for the current navigation is passed as a parameter to the trailing lambda of the composable() method where it can be extracted and passed to the Customer composable:

composable(Routes.Purchases.route + "/{customerName}") { backStackEntry ->
    
    val customerName = backStackEntry.arguments?.getString("customerName")
    
    Purchases(customerName)
}

By default, the navigation argument is assumed to be of String type. To pass arguments of different types, the type must be specified using the NavType enumeration via the composable() method arguments parameter. In the following example, the parameter type is declared as being of type Int. Note also that the argument now needs to be extracted from the back stack entry using getInt() instead of getString():

composable(Routes.Purchases.route + "/{customerId}",
  arguments = listOf(navArgument("customerId") { type = NavType.IntType })) {     
                          navBackStack ->
    Customers(navBackStack.arguments?.getInt("customerId"))
}

The final step is to pass a value for the argument when making the navigate() method call. We do this by appending the argument value to the end of the destination route. Assuming that the value we need to pass to the purchases screen is stored as a state variable named selectedCustomer, the navigate() call would be written as follows:

@Composable
fun Customers(customerName: String?) {
.
.
}

When the button is clicked, the following sequence of events will occur:

  1. A back stack entry is created for the current destination.
  2. The current selectedCustomer state value is stored in the back stack entry.
  3. The back stack entry is pushed onto the back stack.
  4. The composable() method for the purchase route in the NavHost declaration is called.
  5. The trailing lambda of the composable() method extracts the argument value from the back stack entry and passes it to the Purchases composable.

Working with bottom navigation bars

So far in this chapter, we have focused on navigation in response to click events on Button components. Another common form of navigation involves the bottom navigation bar.

The bottom navigation bar appears at the bottom of the screen and displays a list of navigation items usually comprising an icon and a label. Clicking on an item navigates to the different screen within the current activity. An example bottom navigation bar is illustrated in Figure 44-5 below:

Figure 44-5

The core components of bottom bar navigation are the Compose BottomNavigation and BottomNavigationItem components. Implementation typically involves a parent BottomNavigationBar containing a forEach loop which iterates through a list creating each BottomNavigationItem child. Each child is configured with the label and icon to be displayed and an onClick handler to perform the navigation to the corresponding destination. Typical syntax will read as follows:

BottomNavigation {
    
    <items list>.forEach { navItem ->
 
        BottomNavigationItem(
            selected = <true | false>,
            onClick = {
                navController.navigate(navItem.route) {
                    popUpTo(navController.graph.findStartDestination().id) {
                        saveState = true
                    }
                    launchSingleTop = true
                    restoreState = true
                }
            },
 
            icon = {
                <icon>
            },
            label = {
                <text>
            },
        )
    }
}

Note that the PopUpTo() method is called to make sure that if the user clicks the back button the navigation returns to the start destination. We can identify the start destination by calling the findStartDestination() method on the navigation graph:

navController.graph.findStartDestination()

Also, the launchSingleTop, saveState, and restoreState options need to be enabled when working with bottom bar navigation.

Each BottomNavigationItem needs to be told whether it is the currently selected item via the selected property. When working with bottom bar navigation, you will need to write code to compare the route associated with the item against the current route selection. The current route selection can be obtained by gaining access to the back stack via the currentBackStackEntryAsState() method of the navigation controller and accessing the destination route property, for example:

BottomNavigation {
    val backStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = backStackEntry?.destination?.route
 
    NavBarItems.BarItems.forEach { navItem ->
 
        BottomNavigationItem(
            selected = currentRoute == navItem.route
.
.

The two routes are then compared and the result assigned to the selected property. A more detailed example of bottom bar navigation will be demonstrated in the chapter entitled “A Compose Bottom Navigation Bar Tutorial”.

Summary

This chapter has covered the addition of navigation to Android apps using the Compose support built into the Jetpack Navigation Architecture Component. Navigation is implemented by creating an instance of the NavHostController class and associating it with a NavHost instance. The NavHost instance is configured with the starting destination and the navigation routes that make up the navigation graph for the current activity. Navigation is then performed by making calls to the navigate() method of the navigation controller instance, passing through the path of the destination composable. Compose also supports the passing of arguments to the destination composable. Navigation may also be added to screens using the Compose BottomNavigation and BottomNavigationItem components.

A Jetpack Compose Room Database and Repository Tutorial

This chapter will use the knowledge gained in the chapter entitled Working with ViewModels in Jetpack Compose to provide a detailed tutorial demonstrating how to implement SQLite-based database storage using the Room persistence library. In keeping with the Android architectural guidelines, the project will make use of a view model and repository. The tutorial will also provide a demonstration of all of the elements covered in Room Databases and Jetpack Compose including entities, a Data Access Object, a Room Database, and asynchronous database queries.

About the RoomDemo project

The project created in this chapter is a rudimentary inventory app designed to store the names and quantities of products. When completed, the app will provide the ability to add, delete and search for database entries while also displaying a scrollable list of all products currently stored in the database. This product list will update automatically as database entries are added or deleted. Once completed, the app will appear as illustrated in Figure 43-1 below:

Figure 43-1

Creating the RoomDemo project

Launch Android Studio and create a new Empty Compose Activity project named RoomDemo, specifying com.example.roomdemo as the package name, and selecting a minimum API level of API 26: Android 8.0 (Oreo). Within the MainActivity.kt file, delete the Greeting function and add a new empty composable named ScreenSetup which, in turn, calls a function named MainScreen:

@Composable
fun ScreenSetup() {
    MainScreen()
}
 
@Composable
fun MainScreen() {
    
}

Next, edit the onCreateActivity() method function to call ScreenSetup instead of Greeting. Since this project will be using features that are not supported by the Preview panel, also delete the DefaultPreview composable from the file. To test the project we will be running it on a device or emulator session.

Modifying the build configuration

Before adding any new classes to the project, the first step is to add some additional libraries to the build configuration, including the Room persistence library. Locate and edit the module-level build.gradle file (app -> Gradle Scripts -> build.gradle (Module: RoomDemo.app)) and modify it as follows before clicking on the Sync Now link:

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'kotlin-kapt'
}
.
.
dependencies {
.
.
    implementation "androidx.room:room-runtime:2.4.0"
    implementation 'androidx.room:room-ktx:2.4.0'
    implementation "androidx.compose.runtime:runtime-livedata:1.0.5"
    annotationProcessor "androidx.room:room-compiler:2.4.0"
    kapt "androidx.room:room-compiler:2.4.0"
.
.
}

Building the entity

This project will begin by creating the entity which defines the schema for the database table. The entity will consist of an integer for the product id, a string column to hold the product name, and another integer value to store the quantity. The product id column will serve as the primary key and will be auto-generated. Table 43-1 summarizes the structure of the entity:

ColumnData Type
productid
Integer / Primary Key / Auto Increment
productname

String

productquantity
Integer

Table 43-1

Add a class file for the entity by right-clicking on the app -> java -> com.example.roomdemo entry in the Project tool window and selecting the New -> Kotlin File/Class menu option. In the new class dialog, name the class Product, select the Class entry in the list and press the keyboard return key to generate the file. When the Product.kt file opens in the editor, modify it so that it reads as follows:

package com.example.roomdemo
 
class Product {
 
    var id: Int = 0
    var productName: String = ""
    var quantity: Int = 0
 
    constructor() {}
 
    constructor(id: Int, productname: String, quantity: Int) {
        this.productName = productname
        this.quantity = quantity
    }
    constructor(productname: String, quantity: Int) {
        this.productName = productname
        this.quantity = quantity
    }
}

The class now has variables for the database table columns and matching getter and setter methods. Of course, this class does not become an entity until it has been annotated. With the class file still open in the editor, add annotations and corresponding import statements:

package com.example.roomdemo
 
import androidx.annotation.NonNull
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
 
@Entity(tableName = "products")
class Product {
 
    @PrimaryKey(autoGenerate = true)
    @NonNull
    @ColumnInfo(name = "productId")
    var id: Int = 0
 
    @ColumnInfo(name = "productName")
    var productName: String = ""
    var quantity: Int = 0
 
    constructor() {}
 
    constructor(id: Int, productname: String, quantity: Int) {
        this.id = id
        this.productName = productname
        this.quantity = quantity
    }
    constructor(productname: String, quantity: Int) {
        this.productName = productname
        this.quantity = quantity
    }
}

These annotations declare this as the entity for a table named products and assign column names for both the id and name variables. The id column is also configured to be the primary key and auto-generated. Since a primary key can never be null, the @NonNull annotation is also applied. Since it will not be necessary to reference the quantity column in SQL queries, a column name has not been assigned to the quantity variable.

Creating the Data Access Object

With the product entity defined, the next step is to create the DAO interface. Referring once again to the Project tool window, right-click on the app -> java -> com.example.roomdemo entry and select the New -> Kotlin File/ Class menu option. In the new class dialog, enter ProductDao into the Name field and select Interface from the list as highlighted in Figure 43-2:

Figure 43-2

Click on OK to generate the new interface and, with the ProductDao.kt file loaded into the code editor, make the following changes:

package com.example.roomdemo
 
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
 
@Dao
interface ProductDao {
 
    @Insert
    fun insertProduct(product: Product)
 
    @Query("SELECT * FROM products WHERE productName = :name")
    fun findProduct(name: String): List<Product>
 
    @Query("DELETE FROM products WHERE productName = :name")
    fun deleteProduct(name: String)
 
    @Query("SELECT * FROM products")
    fun getAllProducts(): LiveData<List<Product>>
}

The DAO implements methods to insert, find and delete records from the products database. The insertion method is passed a Product entity object containing the data to be stored while the methods to find and delete records are passed a string containing the name of the product on which to operate. The getAllProducts() method returns a LiveData object containing all of the records within the database. This method will be used to keep the product list in the user interface layout synchronized with the database.

Adding the Room database

The last task before adding the repository to the project is to implement the Room Database instance. Add a new class to the project named ProductRoomDatabase, this time with the Class option selected.

Once the file has been generated, modify it as follows using the steps outlined in the Room Databases and Jetpack Compose chapter:

package com.example.roomdemo
 
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
 
@Database(entities = [(Product::class)], version = 1)
abstract class ProductRoomDatabase: RoomDatabase() {
 
abstract fun productDao(): ProductDao
 
    companion object {
 
        private var INSTANCE: ProductRoomDatabase? = null
 
        fun getInstance(context: Context): ProductRoomDatabase {
            synchronized(this) {
                var instance = INSTANCE
 
                if (instance == null) {
                    instance = Room.databaseBuilder(
                        context.applicationContext,
                        ProductRoomDatabase::class.java,
                        "product_database"
                    ).fallbackToDestructiveMigration()
                        .build()
 
                    INSTANCE = instance
                }
                return instance
            }
        }
    }
}

Adding the repository

Add a new class named ProductRepository to the project, with the Class option selected.

The repository class will be responsible for interacting with the Room database on behalf of the ViewModel and will need to provide methods that use the DAO to insert, delete and query product records. Except for the getAllProducts() DAO method (which returns a LiveData object) these database operations will need to be performed on separate threads from the main thread.

Remaining within the ProductRepository.kt file, make the following changes :

package com.example.roomdemo
 
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
 
class ProductRepository(private val productDao: ProductDao) {
 
    val searchResults = MutableLiveData<List<Product>>()
}

The above declares a MutableLiveData variable named searchResults into which the results of a search operation are stored whenever an asynchronous search task completes (later in the tutorial, an observer within the ViewModel will monitor this live data object). When an instance of the class is created, it will need to be passed a reference to a ProductDao object.

The repository class now needs to provide some methods that can be called by the ViewModel to initiate database operations. To avoid performing database operations on the main thread, the repository will make use of coroutines where necessary. As such, some additional libraries need to be added to the project before work on the repository class can continue. Start by editing the Gradle Scripts -> build.gradle (Module: RoomDemo.app) file to add the following lines to the dependencies section:

dependencies {
.
.
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
.
.
}

After making the change, click on the Sync Now link at the top of the editor panel to commit the changes.

With a reference to the DAO stored and the appropriate libraries added, the methods are ready to be added to the ProductRepository class file as follows:

.
.
    val searchResults = MutableLiveData<List<Product>>()
    private val coroutineScope = CoroutineScope(Dispatchers.Main)
 
    fun insertProduct(newproduct: Product) {
        coroutineScope.launch(Dispatchers.IO) {
            productDao.insertProduct(newproduct)
        }
    }
 
    fun deleteProduct(name: String) {
        coroutineScope.launch(Dispatchers.IO) {
            productDao.deleteProduct(name)
        }
    }
 
    fun findProduct(name: String) {
        coroutineScope.launch(Dispatchers.Main) {
            searchResults.value = asyncFind(name).await()
        }
    }
 
    private fun asyncFind(name: String): Deferred<List<Product>?> =
        coroutineScope.async(Dispatchers.IO) {
            [email protected] productDao.findProduct(name)
        }
.
.

In the case of the find operation, the asyncFind() method makes use of a deferred value to return the search results to the findProduct() method. Because the findProduct() method needs access to the searchResults variable, the call to the asyncFind() method is dispatched to the main thread which, in turn, performs the database operation using the IO dispatcher.

One final task remains to complete the repository class. The LazyColumn which will be added to the user interface layout later will need to be able to keep up to date with the current list of products stored in the database. The ProductDao class already includes a method named getAllProducts() which uses a SQL query to select all of the database records and return them wrapped in a LiveData object. The repository needs to call this method once on initialization and store the result within a LiveData object that can be observed by the ViewModel and, in turn, by the main activity. Once this has been set up, each time a change occurs to the database table the activity observer will be notified and the LazyColumn recomposed with the latest product list. Remaining within the ProductRepository.kt file, add a LiveData variable and call to the DAO getAllProducts() method:

.
.
class ProductRepository(private val productDao: ProductDao) {
 
    val allProducts: LiveData<List<Product>> = productDao.getAllProducts()
    val searchResults = MutableLiveData<List<Product>>()
.
.

Adding the ViewModel

The ViewModel will be responsible for the creation of the database, DOA, and repository instances and for providing methods and LiveData objects that can be utilized by the UI controller to handle events.

Begin by adding a ViewModel class to the project by right-clicking on the app -> java -> com.example.roomdemo entry in the Project tool window and selecting the New -> Kotlin File/Class menu option. In the New Class dialog, name the class MainViewModel, select the Class entry in the list and press the keyboard return key to generate the file.

Within the MainViewModel.kt file, modify the class declaration to accept an application context instance together with some properties and an initializer block as outlined below. The application context, represented by the Android Context class, is used in application code to gain access to the application resources at runtime. In addition, a wide range of methods may be called on an application’s context to gather information and make changes to the application’s environment. In this case, the application context is required when creating a database and will be passed into the view model from within the activity later in the chapter:

.
.
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
.
.
class MainViewModel(application: Application) {
 
    val allProducts: LiveData<List<Product>>
    private val repository: ProductRepository
    val searchResults: MutableLiveData<List<Product>>
 
    init {
        val productDb = ProductRoomDatabase.getInstance(application)
        val productDao = productDb.productDao()
        repository = ProductRepository(productDao)
 
        allProducts = repository.allProducts
        searchResults = repository.searchResults
    }
}

The initializer block creates a database which is used to create a DAO instance. We then use the DAO to initialize the repository:

val productDb = ProductRoomDatabase.getInstance(application)
val productDao = productDb.productDao()
repository = ProductRepository(productDao) 

Finally, the repository is used to store references to the search results and allProducts live data objects so that they can be converted to states later within the main activity:

allProducts = repository.allProducts
searchResults = repository.searchResults

All that now remains within the ViewModel is to implement the methods that will be called from within the activity in response to button clicks. These need to be placed after the init block as follows:

.
.
init {
.
.
}
 
fun insertProduct(product: Product) {
    repository.insertProduct(product)
}
 
fun findProduct(name: String) {
    repository.findProduct(name)
}
 
fun deleteProduct(name: String) {
    repository.deleteProduct(name)
}
.
.

Designing the user interface

With the database, DOA, repository, and ViewModel completed, we are now ready to design the user interface. Start by editing the MainActivity.kt file and adding three composables to be used as the input text fields, column rows, and column title:

.
.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
.
.
class MainActivity : ComponentActivity() {
.
.
@Composable
fun TitleRow(head1: String, head2: String, head3: String) {
    Row(
        modifier = Modifier
            .background(MaterialTheme.colors.primary)
            .fillMaxWidth()
            .padding(5.dp)
    ) {
        Text(head1, color = Color.White,
            modifier = Modifier
            .weight(0.1f))
        Text(head2, color = Color.White,
            modifier = Modifier
                .weight(0.2f))
        Text(head3, color = Color.White,
            modifier = Modifier.weight(0.2f))
    }
}
 
@Composable
fun ProductRow(id: Int, name: String, quantity: Int) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(5.dp)
    ) {
        Text(id.toString(), modifier = Modifier
            .weight(0.1f))
        Text(name, modifier = Modifier.weight(0.2f))
        Text(quantity.toString(), modifier = Modifier.weight(0.2f))
    }
}
 
@Composable
fun CustomTextField(
    title: String,
    textState: String,
    onTextChange: (String) -> Unit,
    keyboardType: KeyboardType
) {
    OutlinedTextField(
        value = textState,
        onValueChange = { onTextChange(it) },
        keyboardOptions = KeyboardOptions(
            keyboardType = keyboardType
        ),
        singleLine = true,
        label = { Text(title)},
        modifier = Modifier.padding(10.dp),
        textStyle = TextStyle(fontWeight = FontWeight.Bold,
            fontSize = 30.sp)
    )
}

Next, edit the ScreenSetup function to create a ViewModel instance and use it to convert the allProducts and searchResults live data objects to state values initialized with empty lists. These states, together with the view model also need to be passed to the MainScreen composable:

.
.
import android.app.Application
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
.
.
@Composable
fun ScreenSetup(
viewModel: MainViewModel = 
        MainViewModel(LocalContext.current.applicationContext as Application)
) {
    val allProducts by viewModel.allProducts.observeAsState(listOf())
    val searchResults by viewModel.searchResults.observeAsState(listOf())
 
    MainScreen(
        allProducts = allProducts,
        searchResults = searchResults,
        viewModel = viewModel
    )
}
 
@Composable
fun MainScreen(
    allProducts: List<Product>, 
    searchResults: List<Product>, 
    viewModel: MainViewModel
) {
 
}

When creating the ViewModel instance above, note that we used the LocalContext object to obtain a reference to the application context and passed it to the view model so that it can be used when creating the database.

Completing the MainScreen function

Within the MainScreen function, add some state and event handler declarations as follows:

@Composable
fun MainScreen(
    allProducts: List<Product>,
    searchResults: List<Product>,
    viewModel: MainViewModel
) {
    var productName by remember { mutableStateOf("") }
    var productQuantity by remember { mutableStateOf("") }
    var searching by remember { mutableStateOf(false) }
 
    val onProductTextChange = { text : String ->
        productName = text
    }
 
    val onQuantityTextChange = { text : String ->
        productQuantity = text
    }
}

Continue modifying the MainScreen function to add a Column containing two CustomTextField composables and a Row containing four Button components as follows:

.
.
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
.
.
@Composable
fun MainScreen(
    allProducts: List<Product>, 
    searchResults: List<Product>, 
    viewModel: MainViewModel
) {
.
.
    Column(
        horizontalAlignment = CenterHorizontally,
        modifier = Modifier
            .fillMaxWidth()
    ) {
        CustomTextField(
            title = "Product Name",
            textState = productName,
            onTextChange = onProductTextChange,
            keyboardType = KeyboardType.Text
        )
 
        CustomTextField(
            title = "Quantity",
            textState = productQuantity,
            onTextChange = onQuantityTextChange,
            keyboardType = KeyboardType.Number
        )
 
        Row(
            horizontalArrangement = Arrangement.SpaceEvenly,
            modifier = Modifier
                .fillMaxWidth()
                .padding(10.dp)
        ) {
            Button(onClick = {
                viewModel.insertProduct(Product(productName, 
                                          productQuantity.toInt()))
                searching = false
            }) {
                Text("Add")
            }
 
            Button(onClick = {
                searching = true
                viewModel.findProduct(productName)
            }) {
                Text("Search")
            }
 
            Button(onClick = {
                searching = false
                viewModel.deleteProduct(productName)
            }) {
                Text("Delete")
            }
 
            Button(onClick = {
                searching = false
                productName = ""
                productQuantity = ""
            }) {
                Text("Clear")
            }
        }
    }
}

Finally, add a LazyColumn to the parent Column immediately after the row of Button components. This will display a single instance of the TitleRow followed by a ProductRow for each product. The searching state will be used to decide whether the list is to include all products or only those products that match the search criteria:

.
.
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
.
.
@Composable
fun MainScreen(allProducts: List<Product>, searchResults: List<Product>, viewModel: MainViewModel) {
.
.
        LazyColumn(
            Modifier
                .fillMaxWidth()
                .padding(10.dp)
        ) {
            val list = if (searching) searchResults else allProducts
 
            item {
                TitleRow(head1 = "ID", head2 = "Product", head3 = "Quantity")
            }
 
            items(list) { product ->
                ProductRow(id = product.id, name = product.productName, 
                                 quantity = product.quantity)
            }
        }
    }
}

Testing the RoomDemo app

Compile and run the app on a device or emulator where it should appear as illustrated in Figure 43-1 above. Add some products and make sure that they appear automatically in the LazyColumn. Perform a search for an existing product and verify that the matching result is listed. Click the Clear button to reset the list, then enter the name for an existing product, delete it from the database and confirm that it is removed from the product list.

Using the Database Inspector

As previously outlined in Room Databases and Jetpack Compose, the Database Inspector tool may be used to inspect the content of Room databases associated with a running app and to perform minor data changes. After adding some database records using the RoomDemo app, display the Database Inspector tool using the View -> Tool Windows -> App Inspection menu option:

From within the inspector window, select the running app from the menu marked A in Figure 43-3 below:

Figure 43-3

From the Databases panel (B) double-click on the products table to view the table rows currently stored in the database. Enable the Live updates option (C) and then use the running app to add more records to the database. Note that the Database Inspector updates the table data (D) in real-time to reflect the changes.

Turn off Live updates so that the table is no longer read-only, double-click on the quantity cell for a table row, and change the value before pressing the keyboard Enter key. Return to the running app and search for the product to confirm the change made to the quantity in the inspector was saved to the database table.

Finally, click on the table query button (indicated by the arrow in Figure 43-4 below) to display a new query tab (A), make sure that product_database is selected (B), and enter a SQL statement into the query text field (C) and click the Run button(D):

Figure 43-4

The list of rows should update to reflect the results of the SQL query (E).

Summary

This chapter has demonstrated the use of the Room persistence library to store data in an SQLite database. The finished project made use of a repository to separate the ViewModel from all database operations and demonstrated the creation of entities, a DAO, and a room database instance, including the use of asynchronous tasks when performing some database operations.

Room Databases and Jetpack Compose

Included with the Android Architecture Components, the Room persistence library is designed specifically to make it easier to add database storage support to Android apps in a way that is consistent with the Android architecture guidelines. With the basics of SQLite databases covered in the previous chapter, this chapter will explore the concepts of Room-based database management, the key elements that work together to implement Room support within an Android app, and how these are implemented in terms of architecture and coding. Having covered these topics, the next chapter will put this theory into practice in the form of an example Room database project.

Revisiting modern app architecture

The chapter entitled A Jetpack Compose ViewModel Tutorial introduced the concept of modern app architecture and stressed the importance of separating different areas of responsibility within an app. The diagram illustrated in Figure 42-1 outlines the recommended architecture for a typical Android app:

Figure 42-1

With the top three levels of this architecture covered in some detail in earlier chapters of this book, it is now time to begin an exploration of the repository and database architecture levels in the context of the Room persistence library.

Key elements of Room database persistence

Before going into greater detail later in the chapter, it is first worth summarizing the key elements involved in working with SQLite databases using the Room persistence library:

Repository

The repository module contains all of the code necessary for directly handling all data sources used by the app. This avoids the need for the UI controller and ViewModel to contain code that directly accesses sources such as databases or web services.

Room database

The room database object provides the interface to the underlying SQLite database. It also provides the repository with access to the Data Access Object (DAO). An app should only have one room database instance which may then be used to access multiple database tables.

Data Access Object (DAO)

The DAO contains the SQL statements required by the repository to insert, retrieve and delete data within the SQLite database. These SQL statements are mapped to methods that are then called from within the repository to execute the corresponding query.

Entities

An entity is a class that defines the schema for a table within the database and defines the table name, column names, and data types, and identifies which column is to be the primary key. In addition to declaring the table schema, entity classes also contain getter and setter methods that provide access to these data fields. The data returned to the repository by the DAO in response to the SQL query method calls will take the form of instances of these entity classes. The getter methods will then be called to extract the data from the entity object. Similarly, when the repository needs to write new records to the database, it will create an entity instance, configure values on the object via setter calls, then call insert methods declared in the DAO, passing through entity instances to be saved.

SQLite database

The SQLite database is responsible for storing and providing access to the data. The app code, including the repository, should never make direct access to this underlying database. All database operations are performed using a combination of the room database, DAOs, and entities.

The architecture diagram in Figure 42-2 illustrates how these different elements interact to provide Room-based database storage within an Android app:

Figure 42-2

The numbered connections in the above architecture diagram can be summarized as follows:

  1. The repository interacts with the Room Database to get a database instance which, in turn, is used to obtain references to DAO instances.
  2. The repository creates entity instances and configures them with data before passing them to the DAO for use in search and insertion operations.
  3. The repository calls methods on the DAO passing through entities to be inserted into the database and receives entity instances back in response to search queries.
  4. When a DAO has results to return to the repository it packages those results into entity objects.
  5. The DAO interacts with the Room Database to initiate database operations and handle results.
  6. The Room Database handles all of the low-level interactions with the underlying SQLite database, submitting queries and receiving results.

With a basic outline of the key elements of database access using the Room persistence library covered, it is now time to explore entities, DAOs, room databases, and repositories in more detail.

Understanding entities

Each database table will have associated with it an entity class. This class defines the schema for the table and takes the form of a standard Kotlin class interspersed with some special Room annotations. An example Kotlin class declaring the data to be stored within a database table might read as follows:

class Customer {
 
    var id: Int = 0
    var name: String? = null
    var address: String? = null
 
    constructor() {}
 
    constructor(id: Int, name: String, address: String) {
        this.id = id
        this.name = name
        this.address = address
    }
    constructor(name: String, address: String) {
        this.name = name
        this.address = address
    }
}

As currently implemented, the above code declares a basic Kotlin class containing several variables representing database table fields and a collection of getter and setter methods. This class, however, is not yet an entity. To make this class into an entity and to make it accessible within SQL statements, some Room annotations need to be added as follows:

@Entity(tableName = "customers")
class Customer {
 
    @PrimaryKey(autoGenerate = true)
    @NonNull
    @ColumnInfo(name = "customerId")
    var id: Int = 0
 
    @ColumnInfo(name = "customerName")
    var name: String? = null
    var address: String? = null
 
    constructor() {}
 
    constructor(id: Int, name: String, address: String) {
        this.id = id
        this.name = name
        this.address = address
    }
 
    constructor(name: String, address: String) {
        this.name = name
        this.address = address
    }
}

The above annotations begin by declaring that the class represents an entity and assigns a table name of “customers”. This is the name by which the table will be referenced in the DAO SQL statements:

@Entity(tableName = "customers")

Every database table needs a column to act as the primary key. In this case, the customer id is declared as the primary key. Annotations have also been added to assign a column name to be referenced in SQL queries and to indicate that the field cannot be used to store null values. Finally, the id value is configured to be auto-generated. This means that the id assigned to new records will be automatically generated by the system to avoid duplicate keys.

@PrimaryKey(autoGenerate = true)
@NonNull
@ColumnInfo(name = "customerId")
var id: Int = 0

A column name is also assigned to the customer name field. Note, however, that no column name was assigned to the address field. This means that the address data will still be stored within the database, but that it is not required to be referenced in SQL statements. If a field within an entity is not required to be stored within a database, simply use the @Ignore annotation:

@Ignore
var MyString: String? = null

Annotations may also be included within an entity class to establish relationships with other entities using a relational database concept referred to as foreign keys. Foreign keys allow a table to reference the primary key in another table. For example, a relationship could be established between an entity named Purchase and our existing Customer entity as follows:

@Entity(foreignKeys = arrayOf(ForeignKey(entity = Customer::class,
    parentColumns = arrayOf("customerId"),
    childColumns = arrayOf("buyerId"),
    onDelete = ForeignKey.CASCADE,
    onUpdate = ForeignKey.RESTRICT)))
 
class Purchase {
 
    @PrimaryKey(autoGenerate = true)
    @NonNull
    @ColumnInfo(name = "purchaseId")
    var purchaseId: Int = 0
 
    @ColumnInfo(name = "buyerId")
    var buyerId: Int = 0
.
.
}

Note that the foreign key declaration also specifies the action to be taken when a parent record is deleted or updated. Available options are CASCADE, NO_ACTION, RESTRICT, SET_DEFAULT, and SET_NULL.

Data Access Objects

A Data Access Object provides a way to access the data stored within an SQLite database. A DAO is declared as a standard Kotlin interface with some additional annotations that map specific SQL statements to methods that may then be called by the repository.

The first step is to create the interface and declare it as a DAO using the @Dao annotation:

@Dao
interface CustomerDao {
}

Next, entries are added consisting of SQL statements and corresponding method names. The following declaration, for example, allows all of the rows in the customers table to be retrieved via a call to a method named getAllCustomers():

@Dao
interface CustomerDao {
   @Query("SELECT * FROM customers")
   fun getAllCustomers(): LiveData<List<Customer>>
}

Note that the getAllCustomers() method returns a List object containing a Customer entity object for each record retrieved from the database table. The DAO is also making use of LiveData so that the repository can observe changes to the database.

Arguments may also be passed into the methods and referenced within the corresponding SQL statements. Consider the following DAO declaration which searches for database records matching a customer’s name (note that the column name referenced in the WHERE condition is the name assigned to the column in the entity class):

@Query("SELECT * FROM customers WHERE name = :customerName")
fun findCustomer(customerName: String): List<Customer>

In this example, the method is passed a string value which is, in turn, included within an SQL statement by prefixing the variable name with a colon (:).

A basic insertion operation can be declared as follows using the @Insert convenience annotation:

@Insert
fun addCustomer(Customer customer)

This is referred to as a convenience annotation because the Room persistence library can infer that the Customer entity passed to the addCustomer() method is to be inserted into the database without the need for the SQL insert statement to be provided. Multiple database records may also be inserted in a single transaction as follows:

@Insert
fun insertCustomers(Customer... customers)

The following DAO declaration deletes all records matching the provided customer name:

@Query("DELETE FROM customers WHERE name = :name")
fun deleteCustomer(String name)

As an alternative to using the @Query annotation to perform deletions, the @Delete convenience annotation may also be used. In the following example, all of the Customer records that match the set of entities passed to the deleteCustomers() method will be deleted from the database:

@Delete
fun deleteCustomers(Customer... customers)

The @Update convenience annotation provides similar behavior when updating records:

@Update
fun updateCustomers(Customer... customers)

The DAO methods for these types of database operations may also be declared to return an int value indicating the number of rows affected by the transaction, for example:

@Delete
fun deleteCustomers(Customer... customers): int

The Room database

The Room database class is created by extending the RoomDatabase class and acts as a layer on top of the actual SQLite database embedded into the Android operating system. The class is responsible for creating and returning a new room database instance and for providing access to the DAO instances associated with the database.

The Room persistence library provides a database builder for creating database instances. Each Android app should only have one room database instance, so it is best to implement defensive code within the class to prevent more than one instance from being created.

An example Room Database implementation for use with the example customer table is outlined in the following code listing:

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
 
@Database(entities = [(Customer::class)], version = 1)
abstract class CustomerRoomDatabase: RoomDatabase() {
 
abstract fun customerDao(): CustomerDao
 
    companion object {
 
        private var INSTANCE: CustomerRoomDatabase? = null
 
        fun getInstance(context: Context): CustomerRoomDatabase {
            synchronized(this) {
                var instance = INSTANCE
 
                if (instance == null) {
                    instance = Room.databaseBuilder(
                        context.applicationContext,
                        CustomerRoomDatabase::class.java,
                        "customer_database"
                    ).fallbackToDestructiveMigration()
                        .build()
 
                    INSTANCE = instance
                }
                return instance
            }
        }
    }
}

Important areas to note in the above example are the annotation above the class declaration declaring the entities with which the database is to work, the code to check that an instance of the class has not already been created, and the assignment of the name “customer_database” to the instance.

The Repository

The repository contains the code that makes calls to DAO methods to perform database operations. An example repository might be partially implemented as follows:

class CustomerRepository(private val customerDao: CustomerDao) {
 
    private val coroutineScope = CoroutineScope(Dispatchers.Main)
.
.
    fun insertCustomer(customer: Customer) {
        coroutineScope.launch(Dispatchers.IO) {
            customerDao.insertCustomer(customer)
        }
    }
 
    fun deleteCustomer(name: String) {
        coroutineScope.launch(Dispatchers.IO) {
            customerDao.deleteCustomer(name)
        }
    }
.
.
}

Once the repository has access to the DAO, it can make calls to the data access methods. The following code, for example, calls the getAllCustomers() DAO method:

val allCustomers: LiveData<List<Customer>>?
customerDao.getAllCustomers()

When calling DAO methods, it is important to note that unless the method returns a LiveData instance (which automatically runs queries on a separate thread), the operation cannot be performed on the app’s main thread. In fact, attempting to do so will cause the app to crash with the following diagnostic output:

Cannot access database on the main thread since it may potentially lock the UI for a long period of time

Since some database transactions may take a longer time to complete, running the operations on a separate thread avoids the app appearing to lock up. As will be demonstrated in the chapter entitled “A Compose Room Database and Repository Tutorial”, this problem can be easily resolved by making use of coroutines.

With all of the classes declared, instances of the database, DAO and repository need to be created and initialized, the code for which might read as follows:

private val repository: CustomerRepository
val customerDb = CustomerRoomDatabase.getInstance(application)
val customerDao = customerDb.customerDao()
repository = CustomerRepository(customerDao)

In-Memory databases

The examples outlined in this chapter involved the use of an SQLite database that exists as a database file on the persistent storage of an Android device. This ensures that the data persists even after the app process is terminated.

The Room database persistence library also supports in-memory databases. These databases reside entirely in memory and are lost when the app terminates. The only change necessary to work with an in-memory database is to call the Room.inMemoryDatabaseBuilder() method of the Room Database class instead of Room. databaseBuilder(). The following code shows the difference between the method calls (note that the in-memory database does not require a database name):

// Create a file storage-based database
instance = Room.databaseBuilder(
                   context.applicationContext,
                   CustomerRoomDatabase::class.java,
                   "customer_database"
                ).fallbackToDestructiveMigration()
                .build()
 
// Create an in-memory database
instance = Room.inMemoryDatabaseBuilder(
                   context.applicationContext,
                   CustomerRoomDatabase::class.java,
                ).fallbackToDestructiveMigration()
                .build()

Database Inspector

Android Studio includes a Database Inspector tool window which allows the Room databases associated with running apps to be viewed, searched, and modified as shown in Figure 42-3:

Figure 42-3

Use of the Database Inspector will be covered in the chapter entitled “A Compose Room Database and Repository Tutorial”.

Summary

The Android Room persistence library is bundled with the Android Architecture Components and acts as an abstract layer above the lower-level SQLite database. The library is designed to make it easier to work with databases while conforming to the Android architecture guidelines. This chapter has introduced the different elements that interact to build Room-based database storage into Android app projects including entities, repositories, data access objects, annotations, and Room Database instances.

With the basics of SQLite and the Room architecture component covered, the next step is to create an example app that puts this theory into practice.

A Jetpack Compose ViewModel Tutorial

As outlined in the previous chapter, ViewModels are used to separate the data and associated logic used by an activity from the code responsible for rendering the user interface. Having covered the theory of modern Android app architecture, this chapter will create an example project that demonstrates the use of a ViewModel within an example project.

About the project

The project created in this chapter involves a simple app designed to perform temperature conversions between Celsius and Fahrenheit. Once the app is complete, it will appear as illustrated in Figure 40-1 below:

Figure 40-1

When a temperature value is entered into the OutlinedTextField and the button clicked, the converted value will appear in a result Text component. The Switch component is used to indicate whether the entered temperature is Fahrenheit or Celsius. The current switch setting, conversion result, and conversion logic will all be contained within a ViewModel.

Creating the ViewModelDemo project

Launch Android Studio and create a new Empty Compose Activity project named ViewModelDemo, specifying com.example.viewmodeldemo as the package name, and selecting a minimum API level of API 26: Android 8.0 (Oreo). Within the MainActivity.kt file, delete the Greeting function and add a new empty composable named ScreenSetup which, in turn, calls a function named MainScreen:

@Composable
fun ScreenSetup() {
    MainScreen()
}
 
@Composable
fun MainScreen() {
    
}

Next, edit the onCreateActivity() method function to call ScreenSetup instead of Greeting (we will modify the DefaultPreview composable later).

Adding the ViewModel

Within the Android Studio Project tool window, locate and right-click on the app -> java -> com.example. viewmodeldemo entry and select the New -> Kotlin Class/File menu option. In the resulting dialog, name the class DemoViewModel before tapping the keyboard Enter key.

The ViewModel needs to contain state values in which to store the conversion result and current switch position as follows:

package com.example.viewmodeldemo
 
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
 
class DemoViewModel {
 
    var isFahrenheit by mutableStateOf(true)
    var result by mutableStateOf("")
}

The class also needs to contain the logic for the model, starting with a function to perform the temperature unit conversion. Since the temperature is entered by the user into a text field it is passed to the function as a String. In addition to performing the calculation, code is also needed to convert between string and integer types. This code also needs to ensure that the user has entered a valid number. Remaining in the DemoViewModel.kt file, add a new function named convertTemp() so that it reads as follows:

.
.
import java.lang.Exception
import kotlin.math.roundToInt
 
class ViewModel {
.
.
   fun convertTemp(temp: String) {
 
        try {
            val tempInt = temp.toInt()
 
            if (isFahrenheit) {
                result = ((tempInt - 32) * 0.5556).roundToInt().toString()
            } else {
                result = ((tempInt * 1.8) + 32).roundToInt().toString()
            }
        } catch (e: Exception) {
            result = "Invalid Entry"
        }
    }
.
.

The above function begins by converting the temperature string value to an integer. This is performed within the context of a try… catch statement which reports invalid input if the text does not equate to a valid number. Next, the appropriate conversion is performed depending on the current isFahrenheit setting and the result is rounded to a whole number and converted back to a string before being assigned to the result state variable.

The other function that needs to be added to the view model will be called when the switch setting changes and simply inverts the current isFahrenheit state setting:

fun switchChange() {
    isFahrenheit = !isFahrenheit
}

The implementation of the view model is now complete and is ready to be used from within the main activity.

Accessing DemoViewModel from MainActivity

Now that we have declared a view model class, the next step is to create an instance and integrate it with the composables that make up our MainActivity. This project will involve creating a DemoViewModel instance as a parameter to the ScreenSetup function and then passing through the state variables and function references to the MainScreen function. Open the MainActivity.kt file in the code editor and make the following changes:

.
.
@Composable
fun ScreenSetup(viewModel: DemoViewModel = DemoViewModel()) {
    MainScreen(
        isFahrenheit = viewModel.isFahrenheit,
        result = viewModel.result,
        convertTemp = { viewModel.convertTemp(it) },
        switchChange = { viewModel.switchChange() }
    )
}
 
@Composable
fun MainScreen(
    isFahrenheit: Boolean,
    result: String,
    convertTemp: (String) -> Unit,
    switchChange: () -> Unit
) {
 
}
.
.

Before starting work on the user interface design, the DefaultPreview function also needs to be modified to make use of the view model:

@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DefaultPreview(model: DemoViewModel = DemoViewModel()) {
    ViewModelDemoTheme {
        MainScreen(
            isFahrenheit = model.isFahrenheit,
            result = model.result,
            convertTemp = { model.convertTemp(it) },
            switchChange = { model.switchChange() }
        )
    }
}

Designing the temperature input composable

A closer look at the completed user interface screenshot shown in Figure 40-1 above will reveal the presence of a snowflake icon on the right-hand side of the OutlinedTextField component. Before writing any more code, this icon first needs to be added to the project. Within Android Studio, select the Tools -> Resource Manager menu option to display the Resource Manager tool window. Within the tool window click on the `+` button indicated by the arrow in Figure 40-2 and select the Vector Asset menu option to add a new resource to the project:

Figure 40-2

In the resulting dialog, click on the Clip Art box as shown in Figure 40-3 below:

Figure 40-3

When the icon selection dialog appears, enter “ac unit” into the search field to locate the clip art icon to be used in the project:

Figure 40-4

Select the icon and click on the OK button to return to the vector asset configuration dialog where the selected icon will now appear. Click Next followed by Finish to complete the addition of the icon to the project resources.

Double-click on the ic_baseline_ac_unit_24.xml vector asset file in the Project tool window to load it into the code editor and modify the android:tint property as follows:

.
.
android:tint="@color/purple_700">
.
.

Designing the temperature input composable

In the interests of avoiding the MainScreen function becoming cluttered, the Switch, OutlinedTextField, and unit indicator Text component will be placed in a separate composable named InputRow which can now be added to the MainActivity.kt file:

.
.
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
.
.
@Composable
fun InputRow(
    isFahrenheit: Boolean,
    textState: String,
    switchChange: () -> Unit,
    onTextChange: (String) -> Unit
) {
    Row(verticalAlignment = Alignment.CenterVertically) {
 
        Switch(
            checked = isFahrenheit,
            onCheckedChange = { switchChange() }
        )
 
        OutlinedTextField(
            value = textState,
            onValueChange = { onTextChange(it) },
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Number
            ),
            singleLine = true,
            label = { Text("Enter temperature")},
            modifier = Modifier.padding(10.dp),
            textStyle = TextStyle(fontWeight = FontWeight.Bold, 
                                     fontSize = 30.sp),
            trailingIcon = {
                Icon(
                    painter = painterResource(R.drawable.ic_baseline_ac_unit_24),
                    contentDescription = "frost",
                    modifier = Modifier
                        .size(40.dp)
                )
            }
        )
 
        Crossfade(
            targetState = isFahrenheit,
            animationSpec = tween(2000)
        ) { visible ->
            when (visible) {
                true -> Text("\u2109", style = MaterialTheme.typography.h4)
                false -> Text("\u2103", style = MaterialTheme.typography.h4)
            }
        }
    }
}

The InputRow function expects as parameters the state values and functions contained within the view model together with a textState state variable and onTextChange event handler. These last two parameters are used to display the text typed by the user into the text field and will be “hoisted” to the MainScreen function later in the chapter. The current textState value is also what gets passed to the convertTemp() function when the user clicks the button.

The composables that make up this section of the layout are contained within a Row which is configured to center its children vertically. The first child, the Switch component, simply calls the switchChange() function on the model to toggle the isFahrenheit state.

While many of the properties applied to the OutlinedTextField will be familiar from previous chapters, some require additional explanation. For example, since the temperature can only be entered as a number, the keyboardOptions keyboard type property is set to KeyboardNumber. This ensures that when the user taps within the text field, only the numeric keyboard will appear on the screen:

keyboardOptions = KeyboardOptions(
    keyboardType = KeyboardType.Number
)

Other keyboard type options include email address, password, phone number, and URI inputs.

The input is also limited to a single line of text using the singleLine property. As the name suggests, the OutlinedTextField component draws an outline around the text input area. When the component is not selected by the user (in other words it does not have “focus”) the text assigned to the label property appears in slightly faded text within the text field as shown in Figure 40-5:

Figure 40-5

When the field has focus, however, the label appears as a title positioned within the outline:

Figure 40-6

The result of a call to the TextStyle function is assigned to the textStyle property of the OutlinedTextField. TextStyle is used to group style settings into a single object that can be applied to other composables in a single operation. In this instance, we are only setting font weight and font style, but TextStyle may also be used to configure style settings including color, background, font family, shadow, text alignment, letter spacing, and text indent.

The trailingIcon property is used to position the previously added icon at the end of the text input area:

trailingIcon = {
    Icon(
        painter = painterResource(R.drawable.ic_baseline_ac_unit_24),
        contentDescription = "frost",
        modifier = Modifier
            .size(40.dp)
    )
}

Finally, crossfade animation (covered in the chapter titled “Compose Visibility Animation”) is used when switching the unit Text field between °F and °C (represented by Unicode values \u2109 and \u2103 respectively) based on the current isFahrenheit setting.

Completing the user interface design

The final task before testing the app is to complete the MainScreen function which now needs to read as follows:

.
.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
.
.
@Composable
fun MainScreen(
    isFahrenheit: Boolean,
    result: String,
    convertTemp: (String) -> Unit,
    switchChange: () -> Unit
) {
    Column(horizontalAlignment = Alignment.CenterHorizontally, 
         modifier = Modifier.fillMaxSize()) {
 
        var textState by remember { mutableStateOf("") }
 
        val onTextChange = { text : String ->
            textState = text
        }
 
        Text("Temperature Converter",
            modifier = Modifier.padding(20.dp),
            style = MaterialTheme.typography.h4
        )
 
        InputRow(
            isFahrenheit = isFahrenheit,
            textState = textState,
            switchChange = switchChange,
            onTextChange = onTextChange
        )
 
        Text(result,
            modifier = Modifier.padding(20.dp),
            style = MaterialTheme.typography.h3
        )
 
        Button(
            onClick = { convertTemp(textState) }
        )
        {
            Text("Convert Temperature")
        }
    }
}

The MainScreen composable declares the textState state variable and an onTextChange event handler. The first child of the Column layout is a static Text component displaying a title. Next, the InputRow is called and passed the necessary parameters. The third child is another Text component, this time configured to display the content of the view model result state variable. Finally, a Button composable is configured to call the view model convertTemp() function, passing it textState. The convertTemp() function will calculate the converted temperature and assign it to the result state variable, thereby triggering a recomposition of the composable hierarchy.

Testing the app

Test the activity by running the app on a device or emulator, and tapping on the OutlinedTextField component. Note that the “Enter temperature” label moves to the outline leaving the input field clear to enter a temperature value. Verify that when the keyboard appears it only allows numerical selections. Enter a number and click on the Button at which point the converted temperature should be displayed.

Use the Switch to change from Fahrenheit to Centigrade and note the unit text to the right of the text field changes using cross-fade animation. Finally, test that attempting a conversion with a blank text field causes the Invalid Entry text to appear.

Summary

This chapter has demonstrated the use of a view model to separate the data and logic of an application from the code responsible for displaying the user interface. The chapter also introduced the OutlinedTextField component and covered customization options including adding an icon, restricting keyboard input to numerical values, and setting style attributes using the TextStyle function.