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.2"
    implementation "androidx.room:room-ktx:2.4.2"
    implementation "androidx.compose.runtime:runtime-livedata:1.1.1"
    annotationProcessor "androidx.room:room-compiler:2.4.2"
    kapt "androidx.room:room-compiler:2.4.2"
.
.
}

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:

Column

Data 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(productname: String, quantity: Int) {
        this.id = id
        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 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.6.1'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
.
.
}

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.

Start by editing the build.gradle (Module RoomDemo.app) file to add the view model lifecycle library:

.
.
dependencies {
.
.
    implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1'
.
.

Sync the project before 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
import androidx.lifecycle.ViewModel
.
.
class MainViewModel(application: Application) : ViewModel() {
 
    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)
    )
}

Writing a ViewModelProvider Factory class

The view model we have created in this chapter is slightly more complex than earlier examples because it expects to be passed a reference to the Application instance. Previously we have used the viewModel() function to create view models. Unfortunately, the viewModel() function will not allow us to simply pass through the Application reference as an argument when we call it. Instead, we need to pass the function a custom ViewModelProvider Factory class designed to accept an Application reference and return an initialized MainViewModel instance.

Within the MainActivity.kt file, add the following factory class at the end of the file after the last closing brace (}):

.
.
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
.
.
class MainViewModelFactory(val application: Application) : 
                                    ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return MainViewModel(application) as T
    }
}

In addition to the factory, the viewModel() function also requires a reference to the current ViewModelStoreOwner. The view model store can be thought of as a container in which all currently active view models are stored together with an identifying string for each model (which also needs to be passed to the viewModel() call). Remaining the MainActivity.kt file, locate the onCreate() method, and modify it so that it reads as follows:

.
.
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.viewModel
.
.
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        RoomDemoTheme {
            // A surface container using the 'background' color from the theme
            Surface(
                modifier = Modifier.fillMaxSize(),
                color = MaterialTheme.colors.background
            ) {
 
                val owner = LocalViewModelStoreOwner.current

                owner?.let {
                    val viewModel: MainViewModel = viewModel(
                        it,
                        "MainViewModel",
                        MainViewModelFactory(
                          LocalContext.current.applicationContext 
                                                      as Application)
                    )
 
                    ScreenSetup(viewModel)
                }
            }
        }
    }
}

The added code begins by obtaining a reference to the current local view model store owner. After checking the owner is not null, the viewModel() function is called and passed the owner, an identifying string, and view model factory (to which is passed the Application reference). The view model returned by the viewModel() call is then passed to ScreenSetup.

Next, modify ScreenSetup to accept the ViewModel 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 androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
.
.

@Composable
fun ScreenSetup(viewModel: MainViewModel) {
 
    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:

    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 = {
                if (productQuantity.isNotEmpty()) {
                    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 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() {
    
}

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

Next, modify the build.gradle (Module: ViewModelDemo.app) file to add the Compose view model library to the dependencies section:

.
.
dependencies {
.
.
    implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1'
.
.

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
import androidx.lifecycle.ViewModel
 
class DemoViewModel  : ViewModel() {
 
    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  : 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:

.
.
import androidx.lifecycle.viewmodel.compose.viewModel
.
.
@Composable
fun ScreenSetup(viewModel: DemoViewModel = viewModel()) {
    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 = viewModel()) {
    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.

Working with ViewModels in Jetpack Compose

Until a few years ago, Google did not recommend a specific approach to building Android apps other than to provide tools and development kits while letting developers decide what worked best for a particular project or individual programming style. That changed in 2017 with the introduction of the Android Architecture Components which became part of Android Jetpack when it was released in 2018. Jetpack has of course, since been expanded with the addition of Compose.

This chapter will provide an overview of the concepts of Jetpack, Android app architecture recommendations, and the ViewModel component.

What is Android Jetpack?

Android Jetpack consists of Android Studio, the Android Architecture Components, Android Support Library, and the Compose framework together with a set of guidelines that recommend how an Android App should be structured. The Android Architecture Components were designed to make it quicker and easier both to perform common tasks when developing Android apps while also conforming to the key principle of the architectural guidelines. While many of these components have been superseded by features built into Compose, the ViewModel architecture component remains relevant today. Before exploring the ViewModel component, it first helps to understand both the old and new approaches to Android app architecture.

The “old” architecture

In the chapter entitled An Example Jetpack Compose Project, an Android project was created consisting of a single activity that contained all of the code for presenting and managing the user interface together with the back-end logic of the app. Up until the introduction of Jetpack, the most common architecture followed this paradigm with apps consisting of multiple activities (one for each screen within the app) with each activity class to some degree mixing user interface and back-end code.

This approach led to a range of problems related to the lifecycle of an app (for example an activity is destroyed and recreated each time the user rotates the device leading to the loss of any app data that had not been saved to some form of persistent storage) as well as issues such as inefficient navigation involving launching a new activity for each app screen accessed by the user.

Modern Android architecture

At the most basic level, Google now advocates single activity apps where different screens are loaded as content within the same activity.

Modern architecture guidelines also recommend separating different areas of responsibility within an app into entirely separate modules (a concept Google refers to as “separation of concerns”). One of the keys to this approach is the ViewModel component.

The ViewModel component

The purpose of ViewModel is to separate the user interface-related data model and logic of an app from the code responsible for displaying and managing the user interface and interacting with the operating system.

When designed in this way, an app will consist of one or more UI Controllers, such as an activity, together with ViewModel instances responsible for handling the data needed by those controllers.

A ViewModel is implemented as a separate class and contains state values containing the model data and functions that can be called to manage that data. The activity containing the user interface observes the model state values such that any value changes trigger a recomposition. User interface events relating to the model data such as a button click are configured to call the appropriate function within the ViewModel. This is, in fact, a direct implementation of the unidirectional data flow concept described in the chapter entitled Jetpack Compose State and Recomposition. The diagram in Figure 39-1 illustrates this concept as it relates to activities and ViewModels:

Figure 39-1

This separation of responsibility addresses the issues relating to the lifecycle of activities. Regardless of how many times an activity is recreated during the lifecycle of an app, the ViewModel instances remain in memory thereby maintaining data consistency. A ViewModel used by an activity, for example, will remain in memory until the activity finishes which, in the single activity app, is not until the app exits.

In addition to using ViewModels, the code responsible for gathering data from data sources such as web services or databases should be built into a separate repository module instead of being bundled with the view model. This topic will be covered in detail beginning with the chapter entitled Room Databases and Jetpack Compose.

ViewModel implementation using state

The main purpose of a ViewModel is to store data that can be observed by the user interface of an activity. This allows the user interface to react when changes occur to the ViewModel data. There are two ways to declare the data within a ViewModel so that it is observable. One option is to use the Compose state mechanism which has been used extensively throughout this book. An alternative approach is to use the Jetpack LiveData component, a topic that will be covered later in this chapter.

Much like the state declared within composables, ViewModel state is declared using the mutableStateOf group of functions. The following ViewModel declaration, for example, declares a state containing an integer count value with an initial value of 0:

class MyViewModel : ViewModel() {
 
    var customerCount by mutableStateOf(0)
}

With some data encapsulated in the model, the next step is to add a function that can be called from within the UI to change the counter value:

class MyViewModel : ViewModel() {
 
    var customerCount by mutableStateOf(0)
 
    fun increaseCount() {
        customerCount++
    }
}

Even complex models are nothing more than a continuation of these two basic state and function building blocks.

Connecting a ViewModel state to an activity

A ViewModel is of little use unless it can be used within the composables that make up the app user interface. All this requires is to pass an instance of the ViewModel as a parameter to a composable from which the state values and functions can be accessed. Programming convention recommends that these steps be performed in a composable dedicated solely for this task and located at the top of the screen’s composable hierarchy. The model state and event handler functions can then be passed to child composables as necessary. The following code shows an example of how a ViewModel might be accessed from within an activity:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ViewModelWorkTheme {
                Surface(color = MaterialTheme.colors.background) {
                    TopLevel()
                }
            }
        }
    }
}
 
@Composable
fun TopLevel(model: MyViewModel = viewModel()) {
    MainScreen(model.customerCount) { model.increaseCount() }
}
 
@Composable
fun MainScreen(count: Int, addCount: () -> Unit = {}) {
    Column(horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxWidth()) {
        Text("Total customers = $count",
        Modifier.padding(10.dp))
        Button(
            onClick = addCount,
        ) {
            Text(text = "Add a Customer")
        }
    }
}

In the above example, the first function call is made by the onCreate() method to the TopLevel composable which is declared with a default ViewModel parameter initialized via a call to the viewModel() function:

@Composable
fun TopLevel(model: MyViewModel = viewModel()) {
.
.

The viewModel() function is provided by the Compose view model lifecycle library which needs to be added to the build dependencies of a project when working with view models as follows:

dependencies {
.
.
    implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1'
.
.

With access to the ViewModel instance, the TopLevel function is then able to obtain references to the view model customerCount state variable and increaseCount() function which it passes to the MainScreen composable:

MainScreen(model.customerCount) { model.increaseCount() }

As implemented, Button clicks will result in calls to the view model increaseCount() function which, in turn, increments the customerCount state. This change in state triggers a recomposition of the user interface, resulting in the new customer count value appearing in the Text composable. The use of state and view models will be demonstrated in the chapter entitled A Jetpack Compose ViewModel Tutorial.

ViewModel implementation using LiveData

The Jetpack LiveData component predates the introduction of Compose and can be used as a wrapper around data values within a view model. Once contained in a LiveData instance, those variables become observable to composables within an activity. LiveData instances can be declared as being mutable using the MutableLiveData class, allowing the ViewModel functions to make changes to the underlying data value. An example view model designed to store a customer name could, for example, be implemented as follows using MutableLiveData instead of state:

class MyViewModel : ViewModel() {
 
    var customerName: MutableLiveData<String> = MutableLiveData("")
 
    fun setName(name: String) {
        customerName.value = name
    }
}

Note that new values must be assigned to the live data variable via the value property.

Observing ViewModel LiveData within an activity

As with state, the first step when working with LiveData is to obtain an instance of the view model within an initialization composable:

@Composable
fun TopLevel(model: MyViewModel = viewModel()) {
 
}

Once we have access to a view model instance, the next step is to make the live data observable. This is achieved by calling the observeAsState() method on the live data object:

@Composable
fun TopLevel(model: MyViewModel = viewModel()) {
    var customerName: String by model.customerName.observeAsState("")
}

In the above code, the observeAsState() call converts the live data value into a state instance and assigns it to the customerName variable. Once converted, the state will behave in the same way as any other state object, including triggering recompositions whenever the underlying value changes.

The use of LiveData and view models will be demonstrated in the chapter entitled A Jetpack Compose Room Database and Repository Tutorial.

Summary

Until recently, Google has tended not to recommend any particular approach to structuring an Android app. That changed with the introduction of Android Jetpack which consists of a set of tools, components, libraries, and architecture guidelines. These architectural guidelines recommend that an app project be divided into separate modules, each being responsible for a particular area of functionality, otherwise known as “separation of concerns”. In particular, the guidelines recommend separating the view data model of an app from the code responsible for handling the user interface. This is achieved using the ViewModel component. In this chapter, we have covered ViewModel-based architecture and demonstrated how this is implemented when developing with Compose. We have also explored how to observe and access view model data from within an activity using both state and LiveData.

Jetpack Compose Canvas Graphics Drawing Tutorial

In this chapter, we will be introducing 2D graphics drawing using the Compose Canvas component. As we explore the capabilities of Canvas it will quickly become clear that, as with just about everything else in Compose, impressive results can typically be achieved with just a few lines of code.

Introducing the Canvas component

The Canvas component provides a surface on which to perform 2D graphics drawing. Behind the scenes, however, Canvas does much more than just providing a drawing area, including making sure that the state of the graphical content is maintained and managed automatically. Canvas also has its own scope (DrawScope) which gives us access to properties of the canvas area including the size dimensions and center point of the current bounds area, in addition to a set of functions that can be used to draw shapes, lines, and paths, define insets, perform rotations, and much more.

Given the visual nature of this particular Compose feature, the rest of this chapter will make use of a project to demonstrate many of the features of the Canvas component in action.

Creating the CanvasDemo project

Launch Android Studio and create a new Empty Compose Activity project named CanvasDemo, specifying com.example.canvasdemo 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.

Drawing a line and getting the canvas size

The first drawing example we will look at involves drawing a straight diagonal line from one corner of the Canvas bounds to the other. To achieve this, we need to obtain the dimensions of the canvas by accessing the size properties provided by DrawScope. Edit the MainActivity.kt file to add a new function named DrawLine and add a call to this new function from within the MainScreen composable:

.
.
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.unit.dp
.
.
@Composable
fun MainScreen() {
    DrawLine()
}
 
@Composable
fun DrawLine() {
    Canvas(modifier = Modifier.size(300.dp)) {
        val height = size.height
        val width = size.width
    }
}

The DrawLine composable creates a fixed size Canvas and extracts the height and width properties from the DrawScope. All that remains is to draw a line via a call to the DrawScope drawLine() function:

@Composable
fun DrawLine() {
    Canvas(modifier = Modifier.size(300.dp)) {
        val height = size.height
        val width = size.width
 
        drawLine(
            start = Offset(x= 0f, y = 0f),
            end = Offset(x = width, y = height),
            color = Color.Blue,
            strokeWidth = 16.0f
        )
    }
}

The drawLine() API function needs to know the x and y coordinates of the start and endpoints of the line, keeping in mind that the top left-hand corner of the Canvas is position 0, 0. In the above example, these coordinates are packaged into an Offset instance via a call to the Offset() function. The drawLine() function also needs to know the thickness and color of the line to be drawn. After making the above changes, refresh the Preview panel where the drawing should be rendered as shown in Figure 38-1:

Figure 38-1

Drawing dashed lines

Any form of line drawing performed on a Canvas can be configured with dash effects by configuring a PathEffect instance and assigning it to the pathEffect argument of the drawing function call. To create a dashed line, we need to call the dashPathEffect() method of the PathEffect instance and pass it an array of floating-point numbers. The floating-point numbers indicate the “on” and “off” intervals in the line in pixels. There must be an even number of interval values with a minimum of 2 values. Modify the DrawLine composable to add a dashed line effect as follows:

@Composable
fun DrawLine() {
    Canvas(modifier = Modifier.size(300.dp)) {
        val height = size.height
        val width = size.width
 
        drawLine(
            start = Offset(x= 0f, y = 0f),
            end = Offset(x = width, y = height),
            color = Color.Blue,
            strokeWidth = 16.0f,
            pathEffect = PathEffect.dashPathEffect(
                      floatArrayOf(30f, 10f, 10f, 10f), phase = 0f)
        )
    }
}

The above path effect will draw a line beginning with a 30px dash and 10px space, followed by 10px dash and a 10px space, repeating this sequence until the end of the line as shown in Figure 38-2:

Figure 38-2

Drawing a rectangle

Rectangles are drawn on a Canvas using the drawRect() function which can be used in several different ways. The following code changes draw a rectangle of specific dimensions at the default position (0, 0) within the canvas area:

@Composable
fun MainScreen() {
    DrawRect()
}
 
@Composable
fun DrawRect() {
    Canvas(modifier = Modifier.size(300.dp)) {
        val size = Size(600f, 250f)
        drawRect(
            color = Color.Blue,
            size = size
        )
    }
}

When rendered within the Preview panel, the rectangle will appear as shown in Figure 38-3:

Figure 38-3

Note that the dimensions of the Canvas are 300 x 300 while the rectangle is sized to 600 x 250. At first glance, this suggests that the rectangle should be much wider than it appears in the above figure relative to the Canvas. In practice, however, the Canvas size is declared in density-independent pixels (dp) while the rectangle size is specified in pixels (px). Density independent pixels are an abstract measurement that is calculated based on the physical density of the screen defined in dots per inch (dpi). Pixels, on the other hand, refer to the actual physical pixels on the screen. To work solely in pixels, start with dp values and then convert them to pixels as follows:

@Composable
fun DrawRect() {
    Canvas(modifier = Modifier.size(300.dp)) {
        val size = Size(200.dp.toPx(), 100.dp.toPx())
        drawRect(
            color = Color.Blue,
            size = size
        )
    }
}

Instead of specifying dimensions, the size of the rectangle can also be defined relative to the size of the Canvas. For example, the following code draws a square that is half the size of the Canvas:

Composable
fun DrawRect() {
    Canvas(modifier = Modifier.size(300.dp)) {
        drawRect(
            color = Color.Blue,
            size = size / 2f
        )
    }
}

The above changes will result in the following drawing output:

The position of the rectangle within the Canvas area can be specified by providing the coordinates of the top left-hand corner of the drawing:

@Composable
fun DrawRect() {
    Canvas(modifier = Modifier.size(300.dp)) {
        drawRect(
            color = Color.Blue,
            topLeft = Offset(x=350f, y = 300f),
            size = size / 2f
        )
    }
}

Figure 38-4

Alternatively, the inset() function may be used to modify the bounds of the Canvas component:

.
.
import androidx.compose.ui.graphics.drawscope.inset
.
.
@Composable
fun DrawRect() {
    Canvas(modifier = Modifier.size(300.dp)) {
        inset(100f, 200f) {
            drawRect(
                color = Color.Blue,
                size = size / 2f
            )
        }
    }
}

The inset() function can be called with a wide range of settings affecting different sides of the canvas. The function is also particularly useful because multiple drawing functions can be called from within the trailing lambda, with each adopting the same inset values.

The drawRoundRect() function is also available for drawing rectangles with rounded corners. In addition to size and position, this function also needs to be passed an appropriately configured CornerRadius component. It is also worth noting that rectangles (both with and without rounded corners) can be drawn in outline only by specifying a Stroke for the style property, for example:

.
.
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.drawscope.Stroke
.
.
@Composable
fun DrawRect() {
    Canvas(modifier = Modifier.size(300.dp)) {
 
        val size = Size(
            width = 280.dp.toPx(),
            height = 200.dp.toPx())
 
        drawRoundRect(
            color = Color.Blue,
            size = size,
            topLeft = Offset(20f, 20f),
            style = Stroke(width = 8.dp.toPx()),
            cornerRadius = CornerRadius(
                x = 30.dp.toPx(),
                y = 30.dp.toPx()
            )
        )
    }
}

The above code produces an outline of a rectangle with rounded corners:

Figure 38-5

Applying rotation

Any element drawn on a Canvas component can be rotated via a call to the scope rotate() function. The following code, for example, rotates a rectangle drawing by 45°:

.
.
import androidx.compose.ui.graphics.drawscope.rotate
.
.
@Composable
fun DrawRect() {
    Canvas(modifier = Modifier.size(300.dp)) {
        rotate(45f) {
            drawRect(
                color = Color.Blue,
                topLeft = Offset(200f, 200f),
                size = size / 2f
            )
        }
    }
}

The above changes will render the drawing as shown in Figure 38-6 below:

Figure 38-6

Drawing circles and ovals

Circles are drawn in Compose using the drawCircle() function. The following code draws a circle centered within a Canvas. Note that we find the center of the canvas by referencing the DrawScope center property:

@Composable
fun MainScreen() {
    DrawCircle()
}
 
@Composable
fun DrawCircle() {
    Canvas(modifier = Modifier.size(300.dp)) {
        val canvasWidth = size.width
        val canvasHeight = size.height
 
        drawCircle(
            color = Color.Blue,
            center = center,
            radius = 120.dp.toPx()
        )
    }
}

When previewed, the canvas should appear as shown in Figure 38-7 below:

Figure 38-7

Oval shapes, on the other hand, are drawn by calling the drawOval() function. The following composable, for example, draws the outline of an oval shape:

@Composable
fun MainScreen() {
    DrawOval()
}
 
@Composable
fun DrawOval() {
    Canvas(modifier = Modifier.size(300.dp)) {
        val canvasWidth = size.width
        val canvasHeight = size.height
        drawOval(
            color = Color.Blue,
            topLeft = Offset(x = 25.dp.toPx(), y = 90.dp.toPx()),
            size = Size(
                width = canvasHeight - 50.dp.toPx(),
                height = canvasHeight / 2 - 50.dp.toPx()
            ),
            style = Stroke(width = 12.dp.toPx())
        )
    }
}

The above code will render in the Preview panel as illustrated in Figure 38-8:

Figure 38-8

Drawing gradients

Shapes can be filled using gradient patterns by making use of the Brush component which can, in turn, paint horizontal, vertical, linear, radial, and sweeping gradients. For example, to fill a rectangle with a horizontal gradient, we need a Brush initialized with a list of colors together with the start and end positions along the x-axis and an optional tile mode setting. The following example draws a rectangle that occupies the entire canvas and fills it with a horizontal gradient:

@Composable
fun MainScreen() {
    GradientFill()
}
 
@Composable
fun GradientFill() {
 
    Canvas(modifier = Modifier.size(300.dp)) {
        val canvasSize = size
        val colorList: List<Color> = listOf(Color.Red, Color.Blue, 
                   Color.Magenta, Color.Yellow, Color.Green, Color.Cyan)
 
        val brush = Brush.horizontalGradient(
            colors = colorList,
            startX = 0f,
            endX = 300.dp.toPx(),
            tileMode = TileMode.Repeated
        )
 
        drawRect(
            brush = brush,
            size = canvasSize
        )
    }
}

Try out the above example within the Preview panel where it should appear as follows:

Figure 38-9

The following example, on the other hand, uses a radial gradient to fill a circle:

@Composable
fun MainScreen() {
    RadialFill()
}
 
@Composable
fun RadialFill() {
    Canvas(modifier = Modifier.size(300.dp)) {
 
        val canvasWidth = size.width
        val canvasHeight = size.height
        val radius = 150.dp.toPx()
        val colorList: List<Color> = listOf(Color.Red, Color.Blue, 
                 Color.Magenta, Color.Yellow, Color.Green, Color.Cyan)
 
        val brush = Brush.radialGradient(
            colors = colorList,
            center = center,
            radius = radius,
            tileMode = TileMode.Repeated
        )
        
        drawCircle(
            brush = brush,
            center = center,
            radius = radius
        )
    }
}

Note that the center parameter in the above drawCircle() call is optional in this example. In the absence of this parameter, the function will automatically default to the center of the canvas. When previewed, the circle will appear as shown in Figure 38-10:

Figure 38-10

Gradients are particularly useful for adding shadow effects to drawings. Consider, for example, the following horizontal gradient applied to a circle drawing:

@Composable
fun MainScreen() {
    ShadowCircle()
}
 
@Composable
fun ShadowCircle() {
    Canvas(modifier = Modifier.size(300.dp)) {
        val radius = 150.dp.toPx()
        val colorList: List<Color> =
            listOf(Color.Blue, Color.Black)
 
        val brush = Brush.horizontalGradient(
            colors = colorList,
            startX = 0f,
            endX = 300.dp.toPx(),
            tileMode = TileMode.Repeated
        )
 
        drawCircle(
            brush = brush,
            radius = radius
        )
    }
}

When previewed, the circle will appear with a shadow effect on the right-hand side as illustrated in Figure 38-11:

Figure 38-11

Drawing arcs

The drawArc() DrawScope function is used to draw an arc to fit within a specified rectangle and requires either a Brush or Color setting together with the start and sweep angles. The following code, for example, draws an arc starting at 20° with a sweep of 90° within a 250dp by 250dp rectangle:

@Composable
fun MainScreen() {
    DrawArc()
}
 
@Composable
fun DrawArc() {
    Canvas(modifier = Modifier.size(300.dp)) {
        drawArc(
            Color.Blue,
            startAngle = 20f,
            sweepAngle = 90f,
            useCenter = true,
            size = Size(250.dp.toPx(), 250.dp.toPx())
        )
    }
}

The above code will render the arc as shown in Figure 38-12:

Figure 38-12

Drawing paths

So far in this chapter, we have focused on drawing predefined shapes such as circles and rectangles. DrawScope also supports the drawing of paths. Paths are essentially lines drawn between a series of coordinates within the canvas area. Paths are stored in an instance of the Path class which, once defined, is passed to the drawPath() function for rendering on the Canvas.

When designing a path, the moveTo() function is called first to define the start point of the first line. A line is then drawn to the next position using either the lineTo() or relativeLineTo() functions. The lineTo() function accepts the x and y coordinates of the next position relative to the top left-hand corner of the parent Canvas. The relativeLineTo() function, on the other hand, assumes that the coordinates passed to it are relative to the previous position and can be negative or positive. The Path class also includes functions for drawing non-straight lines including Cubic and Quadratic Bézier curves.

Once the path is complete, the close() function must be called to end the drawing.

Within the MainActivity.kt file, make the following modifications to draw a custom shape using a combination of straight lines and Quadratic Bézier curves:

@Composable
fun MainScreen() {
    DrawPath()
}
 
@Composable
fun DrawPath() {
    Canvas(modifier = Modifier.size(300.dp)) {
 
        val path = Path().apply {
            moveTo(0f, 0f)
            quadraticBezierTo(50.dp.toPx(), 200.dp.toPx(), 
                        300.dp.toPx(), 300.dp.toPx())
            lineTo(270.dp.toPx(), 100.dp.toPx())
            quadraticBezierTo(60.dp.toPx(), 80.dp.toPx(), 0f, 0f)
            close()
        }
 
        drawPath(
            path = path,
            Color.Blue,
        )
    }
}

Refresh the Preview panel where the drawing should appear as illustrated below:

Figure 38-13

Drawing points

The drawPoints() function is used to draw individual points at the locations specified by a list of Offset instances. The pointMode parameter of the drawPoints() function is used to control whether each point is plotted separately (using Points mode) or connected by lines using the Lines and Polygon modes. The drawPoints() function in Points mode is particularly useful for algorithm-driven drawing. The following code, for example, plots a sine wave comprised of individual points:

.
.
import java.lang.Math.PI
import java.lang.Math.sin
.
.
@Composable
fun MainScreen() {
    DrawPoints()
}
 
@Composable
fun DrawPoints() {
    Canvas(modifier = Modifier.size(300.dp)) {
 
        val height = size.height
        val width = size.width
        val points = mutableListOf<Offset>()
 
        for (x in 0..size.width.toInt()) {
            val y = (sin(x * (2f * PI /   width)) 
                * (height / 2) + (height / 2)).toFloat()
            points.add(Offset(x.toFloat(), y))
        }
        drawPoints(
            points = points,
            strokeWidth = 3f,
            pointMode = PointMode.Points,
            color = Color.Blue
        )
    }
}

After making the above changes, the Canvas should appear as illustrated below:

Figure 38-14

Drawing an image

An image resource can be drawn onto a canvas via a call to the drawImage() function. To see this function in action, we first need to add an image resource to the project. The image is named vacation.jpg and can be found in the images folder of the sample code archive which can be downloaded from the following web page:

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

Within Android Studio, display the Resource Manager tool window (View -> Tool Windows -> Resource Manager). Locate the vacation.png image in the file system navigator for your operating system and drag and drop it onto the Resource Manager tool window. In the resulting dialog, click Next followed by the Import button to add the image to the project. The image should now appear in the Resource Manager as shown in Figure 38-15 below:

Figure 38-15

The image will also appear in the res -> drawables section of the Project tool window:

Figure 38-16

With the image added to the project, return to the MainActivity.kt file and make the following modifications:

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

The DrawImage composable begins by creating an ImageBitmap version of the resource image and then passes it as an argument to the drawImage() function together with an Offset instance configured to position the image in the top left-hand corner of the canvas area. Refresh the preview and confirm that the Canvas appears as follows:

Figure 38-17

The drawImage() function also allows color filters to be applied to the rendered image. This requires a ColorFilter instance which can be configured with tint, lighting, color matrix, and blend settings. A full explanation of color filtering is beyond the scope of this book, but more information can be found on the following web page:

https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/ColorFilter

For this example, add a tint color filter blending with a color matrix as follows:

.
.
drawImage(
    image = image,
    topLeft = Offset(x = 0f, y = 0f),
    colorFilter = ColorFilter.tint(
        color = Color(0xADFFAA2E),
        blendMode = BlendMode.ColorBurn
    )
)
.
.

When the canvas renders the image in the Preview panel, it will now do so with a yellowish hue.

Summary

The Compose Canvas component provides a surface on which to draw graphics. The Canvas DrawScope includes a set of functions that allow us to perform drawing operations within the canvas area, including the drawing of lines, shapes, gradients, images, and paths. In this chapter, we have explored some of the more common drawing features provided by Canvas and the DrawScope functions.

Jetpack Compose State-Driven Animation

The previous chapter focused on using animation when hiding and showing user interface components. In this chapter, we will turn our attention to state-driven animation. The features of the Compose Animation API allow a variety of animation effects to be performed based on the change of state from one value to another. This includes animations such as rotation, motion, and color changes to name just a few options. This chapter will explain the concepts of state-driven animation, introduce the animate as state functions, spring effects, and keyframes, and explore the use of transitions to combine multiple animations.

Understanding state-driven animation

We already know from previous chapters that working with state is a key element of Compose-based app development. Invariably, the way that an app appears, behaves, and responds to user input are all manifestations of changes to and of state occurring behind the scenes. Using the Compose Animation API, state changes can also be used as the basis for animation effects. If a state change transforms the appearance, position, orientation, or size of a component in a layout, there is a good chance that visual transformation can be animated using one or more of the animate as state functions.

Introducing animate as state functions

The animate as state functions are also referred to as the animate*AsState functions. The reason for this is that the functions all use the same naming convention whereby the ‘*’ wildcard is replaced by the type of the state value that is triggering the animation. For example, if you need to animate the background color change of a composable, you will need to use the animateColorAsState() function. At the time of writing, Compose provides state animation functions for Bounds, Color, Dp, Float, Int, IntOffset, IntSize, Offset, Rect, and Size data types which cover most animation requirements.

These functions animate the results of changes to a single state value. In basic terms, the function is given a target state value and then animates the change from the current value to the target value. The functions return special state values that can be used as properties for composables. Consider the following code fragment:

var temperature by remember { mutableStateOf(80) }
 
val animatedColor: Color by animateColorAsState(
    targetValue = if (temperature > 92) {
        Color.Red
    } else {
        Color.Green
    },
    animationSpec = tween(4500)
)

The above code declares a state variable named temperature initialized with a value of 80. Next, a call is made to animateColorAsState which uses the current temperature setting to decide whether the color should be red or green. Note that the animate as state functions also accept an animation spec, in this case, a duration of 4500 milliseconds. The animatedColor state can now be assigned as a color property for any composable in the layout.

In the following code example it is used to control the background color of a Box composable:

Box(
    Modifier.size(width = 20.dp, height = 200.dp)
        .background(animatedColor)
)

If the temperature state value exceeds 92 at any point during execution, the background color of the Box will transition from green to red using the declared animation.

In the remainder of this chapter, we will create some more state-driven animation examples. Finally, we will close out the chapter by demonstrating the use of the updateTransition() function to combine multiple animations.

Creating the AnimateState project

Launch Android Studio and create a new Empty Compose Activity project named AnimateState, specifying com.example.animatestate 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 RotationDemo:

@Composable
fun RotationDemo() {
    
}

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

Animating rotation with animateFloatAsState

In this first example, we will animate the rotation of an Image component. Since rotation angle in Compose is declared as a Float value, the animation will be created using the animateFloatAsState() function. Before writing code, a vector image needs to be added to the project. The image file is named propeller.svg and can be located in the images folder of the sample code download available from the following URL:

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

Within Android Studio, display the Resource Manager tool window (View -> Tool Windows -> Resource Manager). Locate the propeller.svg image in the file system navigator for your operating system and drag and drop it onto the Resource Manager tool window. In the resulting dialog, click Next followed by the Import button to add the image to the project. The image should now appear in the Resource Manager as shown in Figure 37-1 below:

Figure 37-1

The image will also appear in the res -> drawables section of the Project tool window:

Figure 37-2

Edit the MainActivity.kt file and modify the RotationDemo function to design the user interface layout:

.
.
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
.
.
@Composable
fun RotationDemo() {
 
    var rotated by remember { mutableStateOf(false) }
 
    Column(horizontalAlignment = Alignment.CenterHorizontally, 
                    modifier = Modifier.fillMaxWidth()) {
        Image(
            painter = painterResource(R.drawable.propeller),
            contentDescription = "fan",
            modifier = Modifier
                .padding(10.dp)
                .size(300.dp)
        )
 
        Button(
            onClick = { rotated = !rotated },
            modifier = Modifier.padding(10.dp)
        ) {
            Text(text = "Rotate Propeller")
        }
    }
}

The layout consists of a Column containing an Image configured to display the propeller drawing and a Button. The code includes a Boolean state variable named rotated, the value of which is toggled via the Button’s onClick handler.

When previewed, the layout should resemble that illustrated in Figure 37-3 below:

Figure 37-3

Although the button changes the rotation state value, that state has not yet been connected with an animation. We now need to make use of the animateFloatAsState() function by adding the following code:

.
.
import androidx.compose.animation.core.*
.
.
@Composable
fun RotationDemo() {
 
    var rotated by remember { mutableStateOf(false) }
 
    val angle by animateFloatAsState(
        targetValue = if (rotated) 360f else 0f,
        animationSpec = tween(durationMillis = 2500)
    )
.
.

Next, edit the Image declaration and pass the angle state through to the rotate() modifier as follows:

Image(
    painter = painterResource(R.drawable.propeller),
    contentDescription = "fan",
    modifier = Modifier
        .rotate(angle)
        .padding(10.dp)
        .size(300.dp)
)

This code calls animateFloatAsState() and assigns the resulting state value to a variable named angle. If the rotated value is currently set to true, then the target value for the animation is set to 360 degrees, otherwise, it is set to 0. All that remains now is to test the activity. Using either the Preview panel in interactive mode or an emulator or physical device for testing, click on the button. The propeller should rotate 360 degrees in the clockwise direction. A second click will rotate the propeller back to 0 degrees.

The rotation animation is currently using the default FastOutSlowInEasing easing setting where the animation rate slows as the propeller nears the end of the rotation. To see the other easing options outlined in the previous chapter in action, simply add them to the tween() call. The following change, for example, animates the rotation at a constant speed:

animationSpec = tween(durationMillis = 2500, easing = LinearEasing)

Animating color changes with animateColorAsState

In this example, we will look at animating color changes using the animateColorAsState() function. In this case, the layout will consist of a Box and Button pair. When the Button is clicked the Box will transition from one color to another using an animation. In preparation for this example, we will need to add an enumeration to the MainActivity.kt file to provide the two background color options. Edit the file and place the enum declaration after the MainActivity class:

.
.
enum class BoxColor {
    Red, Magenta
}
 
@Composable
fun RotationDemo() {
.
.

Add a new composable function to the MainActivity.kt file named ColorChangeDemo together with an @Preview function so that it will appear in the Preview panel:

.
.
import androidx.compose.foundation.background
import androidx.compose.ui.graphics.Color
.
.
@Composable
fun ColorChangeDemo() {
 
    var colorState by remember { mutableStateOf(BoxColor.Red) }
 
    Column(horizontalAlignment = Alignment.CenterHorizontally, 
              modifier = Modifier.fillMaxWidth()) {
        Box(
            modifier = Modifier
                .padding(20.dp)
                .size(200.dp)
                .background(Color.Red)
        )
 
        Button(
            onClick = {
                colorState = when (colorState) {
                    BoxColor.Red -> BoxColor.Magenta
                    BoxColor.Magenta -> BoxColor.Red
                }
            },
            modifier = Modifier.padding(10.dp)
        ) {
            Text(text = "Change Color")
        }
    }
}
 
@Preview(showBackground = true)
@Composable
fun ColorChangePreview() {
    AnimateStateTheme {
        ColorChangeDemo()
    }
}
.
.

Exit interactive mode, preview the layout, and confirm that it resembles that shown in Figure 37-4:

Figure 37-4

The BoxColor enumeration contains two possible color selections, Red and Magenta. A state variable named colorState is declared and initialized to BoxColor.Red. Next, the Button onClick handler uses a when statement to toggle the colorState value between the Red and Magenta BoxColor enumeration values.

The ColorChangeDemo function now needs to use the animateColorAsState() function to implement and animate the Box background color change. The Box also needs to be modified to use the animatedColor state as the background color value:

.
.
import androidx.compose.animation.animateColorAsState
.
.
@Composable
fun ColorChangeDemo() {
 
    var colorState by remember { mutableStateOf(BoxColor.Red) }
 
    val animatedColor: Color by animateColorAsState(
        targetValue = when (colorState) {
                BoxColor.Red -> Color.Magenta
                BoxColor.Magenta -> Color.Red
        },
        animationSpec = tween(4500)
    )
 
   Column(horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxWidth()) {
        Box(
            modifier = Modifier
                .padding(20.dp)
                .size(200.dp)
                .background(animatedColor)
        )
.
.

The code uses the current colorState color value to set the animation target value to the other color. This triggers the animated color change which is performed over a 4500-millisecond duration. Stop the current interactive session in the Preview panel if it is still running (only one preview can be in interactive mode at a time), locate the new composable preview, and run it in interactive mode. Once the preview is running, use the button to try out the color change animation.

Animating motion with animateDpAsState

In this, final example before looking at the updateTransition() function, we will use the animateDpAsState() function to animate the change in position of a composable. This will involve changing the x position offset of a component and animating the change as it moves to the new location on the screen. Using the same steps as before, add another composable function, this time named MotionDemo, together with a matching preview composable. As with the color change example, we also need an enumeration to contain the position options:

.
.
enum class BoxPosition {
    Start, End
}
 
@Composable
fun MotionDemo() {
 
    var boxState by remember { mutableStateOf(BoxPosition.Start)}
    val boxSideLength = 70.dp
 
    Column(modifier = Modifier.fillMaxWidth()) {
        Box(
            modifier = Modifier
                .offset(x = 0.dp, y = 20.dp)
                .size(boxSideLength)
                .background(Color.Red)
        )
 
        Spacer(modifier = Modifier.height(50.dp))
 
        Button(
            onClick = {
                boxState = when (boxState) {
                    BoxPosition.Start -> BoxPosition.End
                    BoxPosition.End -> BoxPosition.Start
                }
            },
            modifier = Modifier.padding(20.dp)
               .align(Alignment.CenterHorizontally)
        ) {
            Text(text = "Move Box")
        }
    }
}
 
@Preview(showBackground = true)
@Composable
fun MotionDemoPreview() {
    AnimateStateTheme {
        MotionDemo()
    }
}

This example is structured in much the same way as the color change animation except that this time we are working with density-independent pixel values instead of colors. The end goal is to animate the movement of the Box from the start of the screen to the end. Assuming that the code will potentially run on a variety of devices and screen sizes, we need to know the width of the screen to be able to find the end position. This information can be found by accessing properties of the LocalConfiguration instance. This is an object that is local to each Compose-based app and provides access to properties such as screen width, height and density, font scale information, and whether or not night mode is currently activated on the device. For this example we only need to know the width of the screen which can be obtained as follows:

.
.
import androidx.compose.ui.platform.LocalConfiguration
.
.
@Composable
fun MotionDemo() {
 
    val screenWidth = (LocalConfiguration.current.screenWidthDp.dp)
.
.

Next, we need to add the animation using the animateDpAsState() function:

.
.
import androidx.compose.ui.unit.Dp
.
.
@Composable
fun MotionDemo() {
 
    val screenWidth = (LocalConfiguration.current.screenWidthDp.dp)
    var boxState by remember { mutableStateOf(BoxPosition.Start)}
    val boxSideLength = 70.dp
 
    val animatedOffset: Dp by animateDpAsState(
        targetValue = when (boxState) {
            BoxPosition.Start -> 0.dp
            BoxPosition.End -> screenWidth - boxSideLength
        },
        animationSpec = tween(500)
    )
.
.

In the above code, the target state is set to either the start or end of the screen width, depending on the current boxState setting. In the case of the end position, the width of the Box is subtracted from the screen width so that the motion does not move beyond the edge of the screen.

Now that we have the animatedOffset state declared, we can pass it through as the x parameter to the Box offset() modifier call:

Box(
    modifier = Modifier
        .offset(x = animatedOffset, y = 20.dp)
        .size(boxSides)
        .background(Color.Red)
)

When the code is previewed in interactive mode, clicking the button should now cause the box to be animated as it moves back and forth across the screen.

Adding spring effects

The above example provides an ideal opportunity to introduce the spring animation effect. Spring behavior adds a bounce effect to animations and is applied using the spring() function via the animationSpec parameter. To understand the spring effect it helps to imagine one end of a spring attached to the animation start point (for example the left side of the screen or parent) and the other end attached to the corresponding side of the box. As the box moves, the spring stretches until the endpoint is reached, at which point the box bounces a few times on the string before finally resting at the endpoint.

The two key parameters to the spring() function are damping ratio and stiffness. The damping ratio defines the speed at which the bouncing effect decays and is declared as a Float value where 1.0 has no bounce and 0.1 is the highest bounce. Instead of using Float values, the following predefined constants are also available when configuring the damping ratio:

  • DampingRatioHighBouncy
  • DampingRatioLowBouncy
  • DampingRatioMediumBouncy
  • DampingRatioNoBouncy

To add a spring effect to the motion animation, add a spring() function call to the animation as follows:

.
.
import androidx.compose.animation.core.Spring.DampingRatioHighBouncy
.
.
val animatedOffset: Dp by animateDpAsState(
    targetValue = when (boxState) {
        BoxPosition.Start -> 0.dp
        BoxPosition.End -> screenWidth - boxSideLength
    },
    animationSpec = spring(dampingRatio = DampingRatioHighBouncy)
)

When tested, the box will now bounce when it reaches the target destination.

The stiffness parameter defines the strength of the spring. When using a lower stiffness, the range of motion of the bouncing effect will be greater. The following, for example, combines a high bounce damping ratio with very low stiffness. The result is an animation that is so bouncy that the box bounces beyond the edge of the screen a few times before finally coming to rest at the endpoint:

.
.
import androidx.compose.animation.core.Spring.StiffnessVeryLow
.
.
val animatedOffset: Dp by animateDpAsState(
    targetValue = when (boxState) {
        BoxPosition.Start -> 0.dp
        BoxPosition.End -> screenWidth - boxSides
    },
    spring(dampingRatio = DampingRatioHighBouncy, stiffness = StiffnessVeryLow)
)

The stiffness of the spring effect can be adjusted using the following constants:

  • StiffnessHigh
  • StiffnessLow
  • StiffnessMedium
  • StiffnessMediumLow
  • StiffnessVeryLow

Take some time to experiment with the different damping and stiffness settings to learn more about the effects they produce.

Working with keyframes

Keyframes allow different duration and easing values to be applied at specific points in an animation timeline. Keyframes are applied to animation via the animationSpec parameter and defined using the keyframes() function which accepts a lambda containing the keyframe data and returns a KeyframesSpec instance.

A keyframe specification begins by declaring the total required duration for the entire animation to complete. That duration is then marked by timestamps declaring how much of the total animation should be completed at that point based on the state unit type (for example Float, Dp, Int, etc.). These timestamps are created via calls to the at() function.

As an example, edit the animateDpAsState() function call to add a keyframe specification to the animation as follows:

val animatedOffset: Dp by animateDpAsState(
    targetValue = when (boxState) {
        BoxPosition.Start -> 0.dp
        BoxPosition.End -> screenWidth - boxSides
    },
    animationSpec = keyframes {
        durationMillis = 1000
        100.dp.at(10) 
        110.dp.at(500) 
        200.dp.at(700)
    }
)

This keyframe declares a 1000 millisecond duration for the entire animation. This duration is then divided by three timestamps. The first timestamp occurs 10 milliseconds into the animation, at which point the offset value must have reached 100dp. At 500 milliseconds the offset must be 110dp and, finally, 200dp by the time 700 milliseconds have elapsed. This leaves 300 milliseconds to complete the remainder animation.

Try out the animation and observe the changes in the speed of the animation as each timestamp is reached.

The animation behavior can be further configured using the with() function to add easing settings to the timestamps, for example:

animationSpec = keyframes {
    durationMillis = 1000
    100.dp.at(10).with(LinearEasing)
    110.dp.at(500).with(FastOutSlowInEasing)
    200.dp.at(700).with(LinearOutSlowInEasing)
}

Combining multiple animations

Multiple animations can be run in parallel based on a single target state using the updateTransition() function. This function is passed the target state and returns a Transition instance to which multiple child animations may be added. When the target state changes, the transition will run all of the child animations concurrently. The updateTransition() call may also be passed an optional label parameter which can be used to identify the transition within the Animation Inspector (a topic that will be covered in the next section).

A Transition object configured to trigger its child animations in response to changes to a state variable named myState would typically be declared as follows:

val transition = updateTransition(targetState = myState, 
                                  label = "My Transition")

The Transition class includes a collection of functions that are used to add animation to children. These functions use the naming convention of animate<Type>() depending on the unit type used for the animation such as animateFloat(), animateDp() and animationColor(). The syntax for these functions is as follows:

val myAnimation: <Type> by transition.animate<Type>(
 
    transitionSpec = {
        // anination spec (tween, spring etc)
    }
 
) { state ->
    // Code to identify new target state based on current state    
}

To demonstrate updateTransitions in action, we will modify the example to perform both the color change and motion animations based on changes to the boxState value. Begin by adding a new function named TransitionDemo together with a corresponding preview composable (undefined symbol errors will be corrected in the next steps):

@Composable
fun TransitionDemo() {
    var boxState by remember { mutableStateOf(BoxPosition.Start)}
    var screenWidth = LocalConfiguration.current.screenWidthDp.dp
 
    Column(modifier = Modifier.fillMaxWidth()) {
        Box(
            modifier = Modifier
                .offset(x = animatedOffset, y = 20.dp)
                .size(70.dp)
                .background(animatedColor)
        )
        Spacer(modifier = Modifier.height(50.dp))
 
        Button(
            onClick = {
                boxState = when (boxState) {
                    BoxPosition.Start -> BoxPosition.End
                    BoxPosition.End -> BoxPosition.Start
                }
            },
            modifier = Modifier.padding(20.dp)
               .align(Alignment.CenterHorizontally)
        ) {
            Text(text = "Start Animation")
        }
    }
}
 
@Preview(showBackground = true)
@Composable
fun TransitionDemoPreview() {
    AnimateStateTheme {
        TransitionDemo()
    }
}

Next, edit the new function to obtain a Transition instance configured to react to changes to boxState:

Composable
fun TransitionDemo() {
    var boxState by remember { mutableStateOf(BoxPosition.Start)}
    var screenWidth = LocalConfiguration.current.screenWidthDp.dp
    val transition = updateTransition(targetState = boxState, 
                 label = "Color and Motion")
.
.

Finally, add the color and motion animations to the transition:

.
.
import androidx.compose.animation.animateColor
.
.
@Composable
fun TransitionDemo() {
.
.
    val transition = updateTransition(targetState = boxState, 
                 label = "Color and Motion")
 
    val animatedColor: Color by transition.animateColor(
 
        transitionSpec = {
            tween(4000)
        }
 
    ) { state ->
        when (state) {
            BoxPosition.Start -> Color.Red
            BoxPosition.End -> Color.Magenta
        }
    }
 
    val animatedOffset: Dp by transition.animateDp(
 
        transitionSpec = {
            tween(4000)
        }
    ) { state ->
        when (state) {
            BoxPosition.Start -> 0.dp
            BoxPosition.End -> screenWidth - 70.dp
        }
    }
.
.

When previewed, the box should change color as it moves across the screen.

Using the Animation Inspector

The Animation Inspector is a tool built into Android Studio that allows you to interact directly with the animation timeline and manually scroll back and forth through the animation sequences. The inspector is only available when a Transition-based animation is present and is accessed using the button highlighted in Figure 37-5 below:

Figure 37-5

If this button is not visible, select the File -> Settings… menu option (Android Studio -> Preferences… on macOS), click on the Experimental option and switch on the Enable animation preview option

Once enabled, the inspector panel will appear beneath the preview panel as illustrated in Figure 37-6:

Figure 37-6

The area marked A contains tabs for each transition in the current source file. Since our example only contains a single transition, there is only one tab in the above image. Since a label was passed to the updateTransition() function call, this is displayed as the tab title.

The toolbar (B) provides options to play the animation, jump to the start or end of the timeline, loop repeatedly through the animation, and change the animation playback speed.

The transition’s animation children are listed in the timeline panel (C). The blue vertical line (D) indicates the current position in the timeline which can be dragged to manually move through the animation. Finally, the drop-down menus (E) can be used to change the direction of the animation. Note that the options listed in these menus are taken from the BoxPosition enumeration. As an alternative to manually changing these menu settings, click on the button marked F as shown below to swap the direction settings:

Figure 37-7

Summary

The Compose Animation API provides several options for performing animation based on state changes. A set of animate as state functions are used to animate the results of changes to state values. These functions are passed a target state value and animate the change from the current value to the target value. Animations can be configured in terms of timeline linearity, duration, and spring effects. Individual animations are combined into a single Transition instance using the updateTransition() function. Android Studio includes the Animation Inspector for testing and manually scrolling through animation sequences.

Jetpack Compose Visibility Animation Tutorial

For adding animation effects to user interfaces, Jetpack Compose includes the Animation API. The Animation API consists of classes and functions that provide a wide range of animation options that can be added to your apps with relative ease. In this chapter, we will explore the use of animation when hiding and showing user interface components including the use of cross fading when replacing one component with another. The next chapter, entitled Jetpack Compose State-Driven Animation, will cover topics such as animating motion, rotation, and color changes, in addition to combining multiple animations into a single transition. Throughout this chapter, each animation technique will be demonstrated within an example project.

Creating the AnimateVisibility project

Launch Android Studio and create a new Empty Compose Activity project named AnimateVisibility, specifying com.example.animatevisibility 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. Also, enable the system UI option on the preview composable:

@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DefaultPreview() {
    AnimateVisibilityTheme {
        MainScreen()
    }
}

Animating visibility

Perhaps the simplest form of animation involves animating the appearance and disappearance of a composable. Instead of a component instantly appearing and disappearing, a variety of animated effects can be applied using the AnimatedVisibility composable. For example, user interface elements can be made to gradually fade in and out of view, slide into and out of position horizontally or vertically, or show and hide by expanding and shrinking.

The minimum requirement for calling AnimatedVisibility is a Boolean state variable parameter to control whether or not its child composables are to be visible. Before exploring the capabilities of AnimatedVisibility, it first helps to experience the hiding and showing of a composable without animation.

When the following layout design is complete, two buttons will be used to show and hide content using animation. Before designing the screen layout, add a new composable named CustomButton to the MainActivity.kt file as follows:

.
.
import androidx.compose.material.*
import androidx.compose.ui.graphics.Color
.
.
@Composable
fun CustomButton(text: String, targetState: Boolean, 
       onClick: (Boolean) -> Unit, bgColor: Color = Color.Blue) {
 
    Button(
        onClick = { onClick(targetState) }, 
        colors= ButtonDefaults.buttonColors(backgroundColor = bgColor, 
                         contentColor = Color.White)) {
        Text(text)
    }
}

The composable is passed the text to be displayed on the button and both an onClick handler and the new state value to be passed to the handler when the button is clicked. The button also accepts an optional background color which defaults to blue.

Next, locate the MainScreen function and modify it as follows:

.
.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Alignment
import androidx.compose.runtime.*
.
.
@Composable
fun MainScreen() {
 
    var boxVisible by remember { mutableStateOf(true) }
 
    val onClick = { newState : Boolean ->
        boxVisible = newState
    }
 
    Column(
        Modifier.padding(20.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Row(
            Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            CustomButton(text = "Show", targetState = true, onClick = onClick)
            CustomButton(text = "Hide", targetState = false, onClick = onClick)
        }
 
        Spacer(modifier = Modifier.height(20.dp))
       
        if (boxVisible) {
            Box(modifier = Modifier
                .size(height = 200.dp, width = 200.dp)
                .background(Color.Blue))
        }
    }
}

In summary, this code begins by declaring a Boolean state variable named boxVisible with an initial true value and an onClick event handler to be passed to instances of the CustomButton composable. The purpose of the handler is to change the boxVisible state based on button selection.

Column and Row composables are then used to display two CustomButton composables and a blue Box. The buttons are passed the text to be displayed, the new setting for the boxVisible state, and a reference to the onClick handler. When a button is clicked, it calls the handler and passes it the new state value. Finally, an if statement is used to control whether the Box composable is included as a child of the Column based on the value of boxVisible.

When previewed in interactive mode, or tested on a device or emulator, the layout will appear as illustrated in Figure 36-1:

Figure 36-1

Clicking on the Show and Hide buttons will cause the Box to instantly appear and disappear without any animation effects. Default visibility animation effects can be added simply by replacing the if statement with a call to AnimatedVisibility as follows:

.
.
import androidx.compose.animation.*
.
.
    AnimatedVisibility(visible = boxVisible) {
        Box(modifier = Modifier
            .size(height = 200.dp, width = 200.dp)
            .background(Color.Blue))
    }
.
.

If the code editor reports that AnimatedVisibility is an experimental feature, add the following annotation to the MainScreen composable:

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun MainScreen() {
.
.

When the app is now tested, the hiding and showing of the box will be subtly animated. The default behavior of AnimatedVisibility is so subtle it can be difficult to notice any difference. Fortunately, the Compose Animation API provides a range of customization options. The first option allows different animation effects to be defined when the child composables appear and disappear (referred to as the enter and exit animations).

Defining enter and exit animations

The animations to be used when children of an AnimatedVisibility composable appear and disappear are declared using the enter and exit parameters. The following changes, for example, configure the animations to fade the box into view and slide it vertically out of view:

AnimatedVisibility(visible = boxVisible,
    enter = fadeIn(),
    exit = slideOutVertically()
) {
 
    Box(modifier = Modifier
        .size(height = 200.dp, width = 200.dp)
        .background(Color.Blue))
    }
}

The full set of animation effects is as follows:

  • expandHorizontally() – Content is revealed using a horizontal clipping technique. Options are available to control how much of the content is initially revealed before the animation begins.
  • expandVertically() – Content is revealed using a vertical clipping technique. Options are available to control how much of the content is initially revealed before the animation begins.
  • expandIn() – Content is revealed using both horizontal and vertical clipping techniques. Options are available to control how much of the content is initially revealed before the animation begins.
  • fadeIn() – Fades the content into view from transparent to opaque. The initial transparency (alpha) may be declared using a floating-point value between 0 and 1.0. The default is 0.
  • fadeOut() – Fades the content out of view from opaque to invisible. The target transparency before the content disappears may be declared using a floating-point value between 0 and 1.0. The default is 0.
  • scaleIn() – The content expands into view as though a “zoom in” has been performed. By default, the content starts at zero size and expands to full size though this default can be changed by specifying the initial scale value as a float value between 0 and 1.0.
  • scaleOut() – Shrinks the content from full size to a specified target scale before it disappears. The target scale is 0 by default but may be configured using a float value between 0 and 1.0.
  • shrinkHorizontally() – Content slides from view behind a shrinking vertical clip bounds line. The target width and direction may be configured.
  • shrinkVertically() – Content slides from view behind a shrinking horizontal clip bounds line. The target width and direction may be configured.
  • shrinkOut() – Content slides from view behind shrinking horizontal and vertical clip bounds lines.
  • slideInHorizontally() – Content slides into view along the horizontal axis. The sliding direction and offset within the content where sliding begins are both customizable.
  • slideInVertically() – Content slides into view along the vertical axis. The sliding direction and offset within the content where sliding begins are both customizable.
  • slideIn() – Slides the content into view at a customizable angle defined using an initial offset value.
  • slideOut() – Slides the content out of view at a customizable angle defined using a target offset value.
  • slideOutHorizontally() – Content slides out of view along the horizontal axis. The sliding direction and offset within the content where sliding ends are both customizable.
  • slideOutVertically() – Content slides out of view along the vertical axis. The sliding direction and offset within the content where sliding ends are both customizable.

It is also possible to combine animation effects. The following, for example, combines the expandHorizontally and fadeIn effects:

AnimatedVisibility(visible = boxVisible,
    enter = fadeIn() + expandHorizontally(),
    exit = slideOutVertically()
) {
.
.

All of the above animations may be further customized by making use of animation specs.

Animation specs and animation easing

Animation specs are represented by instances of AnimationSpec, (or, more specifically, subclasses of AnimationSpec) and are used to configure aspects of animation behavior including the animation duration, start delay, spring, and bounce effects, repetition, and animation easing.

As with Rows, Columns, and other container composables, AnimatedVisibility has its own scope (named AnimatedVisibilityScope). Within this scope, we have access to additional functions specific to animation. For example, to control the duration of an animation, we need to generate a DurationBasedAnimationSpec instance (a subclass of AnimationSpec) by calling the tween() function and passing it as a parameter to the animation effect function call. For example, modify our example fadeIn() call to pass through a duration specification:

.
.
import androidx.compose.animation.core.*
.
.
AnimatedVisibility(visible = boxVisible,
    enter = fadeIn(animationSpec = tween(durationMillis = 5000)),
    exit = slideOutVertically()
) {
.
.

Update the preview and hide and show the box, noting that the fade-in animation is now slow enough that we can see it.

The tween() function also allows us to specify animation easing. Animation easing allows the animation to speed up and slow down and can be defined either using custom keyframe positions for speed changes (a topic which will be covered in Jetpack Compose State-Driven Animation) or using one of the following predefined values: • FastOutSlowInEasing

  • LinearOutSlowInEasing
  • FastOutLinearEasing
  • LinearEasing
  • CubicBezierEasing

The following change uses LinearOutSlowInEasing easing for a slideInHorizontally effect:

AnimatedVisibility(visible = boxVisible,
    enter = slideInHorizontally(animationSpec = 
                tween(durationMillis = 5000, easing = LinearOutSlowInEasing)),
    exit = slideOutVertically()
) {

When the box is shown, the animation gradually slows as it reaches the target position. Similarly, the following change bases the animation speed changes on four points within a Bezier curve:

AnimatedVisibility(visible = boxVisible,
    enter = slideInHorizontally(animationSpec = tween(durationMillis = 5000, 
                       easing = CubicBezierEasing(0f, 1f, 0.5f,1f))),
    exit = slideOutVertically(),
) {

Repeating an animation

To make an animation repeat, we also need to use an animation spec, though in this case the RepeatableSpec subclass will be used, an instance of which can be obtained using the repeatable() function. In addition to the animation to be repeated, the function also accepts a RepeatMode parameter specifying whether the repetition should be performed from beginning to end (RepeatMode.Restart) or reversed from end to beginning (RepeatMode.Reverse) of the animation sequence. For example, modify the AnimatedVisibility call to repeat a fade-in enter animation 10 times using the reverse repeat mode:

AnimatedVisibility(visible = boxVisible,
    enter = fadeIn(
        animationSpec = repeatable(10, animation = tween(durationMillis = 2000), 
                                        repeatMode = RepeatMode.Reverse)
    ),
    exit = slideOutVertically(),
.
.

Different animations for different children

When enter and exit animations are applied to an AnimatedVisibility call, those settings apply to all direct and indirect children. Specific animations may be added to individual children by applying the animateEnterExit() modifier to them. As is the case with AnimatedVisibility, this modifier allows both enter and exit animations to be declared. The following changes add vertical sliding animations on both entry and exit to the red Box call:

AnimatedVisibility(
    visible = boxVisible,
    enter = fadeIn(animationSpec = tween(durationMillis = 5500)),
    exit = fadeOut(animationSpec = tween(durationMillis = 5500))
) {
    Row {
        Box(Modifier.size(width = 150.dp, height = 150.dp)
                             .background(Color.Blue)
        )
        Spacer(modifier = Modifier.width(20.dp))
        Box(
            Modifier
                .animateEnterExit(
                    enter = slideInVertically(
                           animationSpec = tween(durationMillis = 5500)),
                    exit = slideOutVertically(
                           animationSpec = tween(durationMillis = 5500))
                )
                .size(width = 150.dp, height = 150.dp)
                .background(Color.Red)
        )
    }
}

When the above code runs, you will notice that the red box uses both fade and sliding animations. This is because the animateEnterExit() modifier animations are combined with those passed to the parent AnimatedVisibility instance. For example, the enter animation in the above example is equivalent to fadeIn(…) + slideInVertically(…). If you only want the modifier animations to be used, the enter and exit settings for the parent AnimatedVisibility instance must be set to EnterTransition.None and ExitTransition.None respectively. In the following code, animation (including the default animation) is disabled on the parent so that only those specified by a call to the animateEnterExit() modifier are performed:

AnimatedVisibility(
    visible = boxVisible,
    enter = EnterTransition.None,
    exit = ExitTransition.None
) {
    Row {
        Box(
            Modifier
                .animateEnterExit(
                    enter = fadeIn(animationSpec = tween(durationMillis = 5500)),
                    exit = fadeOut(animationSpec = tween(durationMillis = 5500)) 
                )
                .size(width = 150.dp, height = 150.dp)
                .background(Color.Blue))
        Spacer(modifier = Modifier.width(20.dp))
        Box(
            Modifier
                .animateEnterExit(
                    enter = slideInVertically(
                           animationSpec = tween(durationMillis = 5500)),
                    exit = slideOutVertically(
                           animationSpec = tween(durationMillis = 5500))
                )
                .size(width = 150.dp, height = 150.dp)
                .background(Color.Red)
        )
    }
}

Auto-starting an animation

So far in this chapter, animations have been initiated in response to button click events. It is not unusual, however, to need an animation to begin as soon as the call to AnimatedVisibility is made. To trigger this, AnimatedVisibility can be passed a MutableTransitionState instance when it is called.

MutableTransitionState is a special purpose state which includes two properties named currentState and targetState. By default, both the current and target states are set to the same value which, in turn, is defined by passing through an initial state when the MutableTransitionState instance is created. The following, for example, creates a transition state initialized to false and passes it through to the AnimatedVisibility call via the visibleState parameter:

.
.
    val state = remember {  MutableTransitionState(false)  }
.
.
        AnimatedVisibility(visibleState = state,
            enter = fadeIn(
                animationSpec = tween(5000)
            ),
            exit = slideOutVertically(),
 
        ) {

When tested, the Box composable will not appear until the show button is clicked because the initial state is set to false. To initiate the “enter” fade-in animation, we need to set the targetState property of the transition state instance to true when it is created. We do this by calling apply() on the state instance and setting the property in the trailing lambda as follows:

val state = remember { MutableTransitionState(true) }
 
state.apply { targetState = true } 

Now when the app is run the fade-in animation starts automatically without user interaction.

Implementing crossfading

Crossfading animates the replacement of one composable with another and is performed using the Crossfade function. This function is passed a target state value that is used to decide which composable is to replace the currently visible component. A fading animation effect is then used to perform the replacement.

In our example app, we currently display both the show and hide buttons. In practice, only one of these buttons needs to be visible at any one time depending on the current visibility state of the Box component. It is not necessary, for example, to display the show button when the content is already visible. This is an ideal candidate for using cross fading to transition from one button to the other. To do this, we need to enclose the two CustomButton composables within a Crossfade call, passing through the boxVisible state value as the target state. We can then add some logic within the Crossfade lambda to decide which button is to be visible. To implement this behavior, modify the MainScreen function so that it reads as follows:

fun MainScreen() {
 
    var boxVisible by remember { mutableStateOf(true) }
 
    val onClick = { newState : Boolean ->
        boxVisible = newState
    }
 
    Column(
        Modifier.padding(20.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Row(
            Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
 
            Crossfade(
                targetState = boxVisible,
                animationSpec = tween(5000)
            ) { visible ->
                when (visible) {
                    true -> CustomButton(text = "Hide", targetState = false, 
                          onClick = onClick, bgColor = Color.Red)
                    false -> CustomButton(text = "Show", targetState = true, 
                          onClick = onClick, bgColor = Color.Magenta)
                }
            }
        }
.
.

To enhance the effect of the crossfade, the above code also changes the background colors of the two buttons. We also use a when statement to decide which button to display based on the current boxVisible value. Test the layout and check that clicking on the Show button initiates a crossfade to the Hide button and vice versa.

Summary

This chapter has explored the use of the Compose Animation API to animate the appearance and disappearance of components within a user interface layout. This requires the use of the animatedVisibility() function which may be configured to use different animation effects and durations, both for the appearance and disappearance of the target composable. The Animation API also includes crossfade support which allows the replacement of one component with another to be animated.

Jetpack Compose Sticky Headers and Scroll Detection

In the previous chapter, we created a project that makes use of the LazyColumn layout to display a list of Card components containing images and text. The project also implemented clickable list items which display a message when tapped.

This chapter will extend the project both to include sticky header support and to use scroll detection to display a “go to top” button when the user has scrolled a specific distance through the list, both of which were introduced in the chapter entitled Jetpack Compose Lists and Grids.

Grouping the list item data

As currently implemented, the LazyColumn list is populated directly from an array of string values. The goal is now to group those items by manufacturer, with each group preceded in the list by a sticky header displaying the manufacturer’s name.

The first step in adding sticky header support is to call the groupBy() method on the itemList array, passing through the first word of each item string (i.e. the manufacturer name) as the group selector value. Edit the MainActivity.kt file, locate the MainScreen function and modify it as follows to group the items into a mapped list:

@Composable
fun MainScreen(itemArray: Array<out String>) {
 
    val context = LocalContext.current
    val groupedItems = itemArray.groupBy { it.substringBefore(' ') }
.
.

Displaying the headers and items

Now that the list items have been grouped, the body of the LazyColumn needs to be modified. In terms of logic, this will require an outer loop that iterates through each of the manufacturer names, displaying the corresponding sticky header. The inner loop will display the items for each manufacturer. Within the MainScreen function, start by embedding the existing items() loop within a forEach loop on the groupedItems object:

@Composable
fun MainScreen(itemArray: Array<out String>) {
.
.
    LazyColumn {
        groupedItems.forEach { (manufacturer, models) ->
            items(itemArray) { model ->
                MyListItem(item = model, onItemClick = onListItemClick)
            }
        }
    }
.
.

On each loop iteration, the forEach statement will call the trailing lambda, passing through the current selector value (manufacturer) and the items (models). Instead of displaying items from the ungrouped itemArray, the items() call now needs to be passed the models parameter:

items(models) { model ->
    MyListItem(item = model, onItemClick = onListItemClick)
}

Before we add sticky headers, compile and run the app to confirm that all the items still appear in the list as before.

Adding sticky headers

For each manufacturer group, we now need to display the header. This involves a call to the LazyListScope stickyHeader function. Although the content of the header can be any combination of composables, an appropriately configured Text component is usually more than adequate for most requirements:

.
.
import androidx.compose.ui.graphics.Color
.
.
LazyColumn() {
 
    groupedItems.forEach { (manufacturer, models) ->
 
        stickyHeader {
            Text(
                text = manufacturer,
                color = Color.White,
                modifier = Modifier
                    .background(Color.Gray)
                    .padding(5.dp)
                    .fillMaxWidth()
            )
        }
        
        items(models) { model ->
            MyListItem(item = model, onItemClick = onListItemClick)
        }
    }
}

If the code editor reports that stickyHeader is an experimental feature, mark the MainScreen function using the ExperimentalFoundationApi annotation as follows:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MainScreen(itemArray: Array<out String>) {
.
.

After building and running the app, it should now appear as shown in Figure 35-1 with the manufacturer name appearing in the headers above each group:

Figure 35-1

Reacting to scroll position

In this, the final step of the LazyListDemo tutorial, the project will be modified to make use of scroll position detection. Once these changes have been made, scrolling beyond the item in list position 4 will display a button that, when clicked, returns the user to the top of the list.

The button will appear at the bottom of the screen and needs to be placed outside of the LazyColumn so that it does not scroll out of view. To achieve this, we first need to place the LazyColumn declaration within a Box component. Within MainActivity.kt, edit the MainScreen function so that it reads as follows:

@Composable
fun MainScreen(itemArray: Array<out String>) {
 
    val context = LocalContext.current
    val groupedItems = itemArray.groupBy { it.substringBefore(' ') }
.
.
    Box {
        LazyColumn() {
    
            groupedItems.forEach { (manufacturer, models) ->
.
.
    }
.
.
}

Next, we need to request a LazyListState instance and pass it to the LazyColumn. Now is also a good opportunity to obtain the coroutine scope which will be needed to perform the scroll when the button is clicked.

.
.
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.rememberCoroutineScope
.
.
@Composable
fun MainScreen(itemArray: Array<out String>) {
 
    val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()
.
.
    Box {
        LazyColumn(
            state = listState,
            contentPadding = PaddingValues(bottom = 40.dp)
        ) {
 
            groupedItems.forEach { (manufacturer, models) ->
.
.

In addition to applying the list state to the LazyColumn, the above changes also add padding to the bottom of the list. This will ensure that when the bottom of the list is reached there will be enough space for the button.

The visibility of the button will be controlled by a Boolean variable which we will name displayButton. The value of this variable will be derived using the firstVisibleItemIndex property of the list state:

@Composable
fun MainScreen(itemArray: Array<out String>) {
 
    val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()
    val displayButton = listState.firstVisibleItemIndex > 5
.
.

In the above declaration, the displayButton variable will be false unless the index of the first visible list item is greater than 5.

Adding the scroll button

Now that code has been added to detect the list scroll position, the button needs to be added. This will be called within the Box component and will be represented by the OutlinedButton composable. The OutlinedButton is one of the Material Design components and allows buttons to be drawn with an outline border with other effects such as border stroke patterns and rounded corners.

Add an OutlinedButton inside the Box declaration and immediately after the LazyColumn:

.
.
import androidx.compose.material.*
 
import kotlinx.coroutines.launch
.
.
    Box {
        LazyColumn(
            state = listState
        ) {
.
.
                items(models) { model ->
                    MyListItem(item = model, onItemClick = onListItemClick)
                }
            }
        }
 
        OutlinedButton(
            onClick = {
                coroutineScope.launch {
                    listState.scrollToItem(0)
                }
            },
            border = BorderStroke(1.dp, Color.Gray),
            shape = RoundedCornerShape(50),
            colors = ButtonDefaults.outlinedButtonColors(
                               contentColor = Color.DarkGray),
            modifier = Modifier.padding(5.dp)
        ){
            Text( text = "Top" )
        }
    }
.
.

Next, we need to control the position and visibility of the button so that it appears at the bottom center of the screen and is only visible when displayButton is true. This can be achieved by calling the OutlinedButton function from within an AnimatedVisibility composable, the purpose of which is to animate the hiding and showing of its child components (a topic covered in the chapter entitled Jetpack Compose Visibility Animation Tutorial). Make the following change to base the visibility of the OutlinedButton on the displayButton variable and to position it using CenterBottom alignment:

.
.
import androidx.compose.animation.AnimatedVisibility
.
.
       AnimatedVisibility(visible = displayButton, 
                     Modifier.align(Alignment.BottomCenter)) {
            OutlinedButton(
                onClick = {
                    coroutineScope.launch {
                        listState.scrollToItem(0)
                    }
                },
                border = BorderStroke(1.dp, Color.Gray),
                shape = RoundedCornerShape(40),
                colors = ButtonDefaults.outlinedButtonColors(
                                      contentColor = Color.DarkGray),
                modifier = Modifier.padding(5.dp)
            ) {
                Text(text = "Top")
            }
        }
.
.

If the editor reports that the AnimatedVisibility composable is experimental, add the ExperimentalAnimationApi annotation to the Mainscreen function before proceeding:

@OptIn(ExperimentalFoundationApi::class, androidx.compose.animation.ExperimentalAnimationApi::class)
@Composable
fun MainScreen(itemArray: Array<out String>) {
.
.

Testing the finished app

Compile and run the app one last time and, once running, scroll down the list until the button appears. Continue scrolling until the bottom of the list to check that enough bottom padding was added to the LazyColumn so that there is no overlap with the button as shown in Figure 35-2 below:

Figure 35-2

Click on the Top button to return to the top of the list.

Summary

This chapter completed the LazyListDemo project by adding support for sticky headers and scroll position detection. The tutorial also introduced the Material Theme OutlinedButton and the use of lazy list content padding.