A Jetpack Compose In-App Purchasing Tutorial

In the previous chapter, we explored how to integrate in-app purchasing into an Android project and also looked at some code samples that can be used when working on your own projects. This chapter will put this theory into practice by creating an example project that demonstrates how to add a consumable in-app product to an Android app using Jetpack Compose. The tutorial will also show how in-app products are added and managed within the Google Play Console and explain how to enable test payments so that purchases can be made during testing without having to spend real money.

About the In-App Purchasing Example Project

The simple concept behind this project is an app in which an in-app product must be purchased before a button can be clicked. This in-app product is consumed each time the button is clicked, requiring the user to re-purchase the product each time they want to be able to click the button. On initialization, the app will connect to the app store, obtain details of the product, and display the product name. Once the app has established that the product is available, a purchase button will be enabled which, when clicked, will step through the purchase process. On completion of the purchase, a second button will be enabled so that the user can click on it and consume the purchase.

Creating the InAppPurchase Project

The first step in this exercise is to create a new project. Begin by launching Android Studio and selecting the New Project option from the welcome screen. In the new project dialog, choose the Empty Compose Activity template before clicking on the Next button.

Enter InAppPurchase into the Name field and specify a package name that will uniquely identify your app within the Google Play ecosystem (for example com.<your company>.InAppPurchase). Before clicking on the Finish button, change the Minimum API level setting to 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 function to call MainScreen 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.

Adding Libraries to the Project

Before we start writing code, some libraries need to be added to the project build configuration, including the standard Android billing client libraries. Later in the project, we will also need to use the ImmutableList class which is part of Google’s Guava Core Java libraries. Add these libraries now by modifying the Gradle Scripts -> build.gradle (Module: InAppPurchase.app) file with the following changes:

.
.
dependencies {
.
.
    implementation 'com.android.billingclient:billing:5.0.0'
    implementation 'com.android.billingclient:billing-ktx:5.0.0'
    implementation 'com.google.guava:guava:24.1-jre'
    implementation 'com.google.guava:guava:27.0.1-android'
.
.

Click on the Sync Now link at the top of the editor panel to commit these changes.

Adding the App to the Google Play Store

Using the steps outlined in the chapter entitled Creating, Testing, and Uploading an Android App Bundle, sign into the Play Console, create a new app, and set up a new internal testing track including the email addresses of designated testers. Return to Android Studio and generate a signed release app bundle for the project. Once the bundle file has been generated, upload it to the internal testing track and roll it out for testing.

Now that the app has a presence in the Google Play Store, we are ready to create an in-app product for the project.

Creating an In-App Product

With the app selected in the Play Console, scroll down the list of options in the left-hand panel until the Monetize section comes into view. Within this section, select the In-app products option listed under Products as shown in Figure 1-1:

Figure 1-1

On the In-app products page, click on the Create product button:

Figure 1-2

On the new product screen, enter the following information before saving the new product:

  • Product ID: one_button_click
  • Name: A Button Click
  • Description: This is a test in-app product that allows a button to be clicked once.
  • Default price: Set to the lowest possible price in your preferred currency.

Enabling License Testers

When testing in-app billing it is useful to be able to make test purchases without spending any money. This can be achieved by enabling license testing for the internal track testers. License testers can use a test payment card when making purchases so that they are not charged.

Within the Play Console, return to the main home screen and select the Setup -> License testing option:

Figure 1-3

Within the license testing screen, add the testers that were added for the internal testing track, change the License response setting to RESPOND_NORMALLY, and save the changes:

Figure 1-4

Now that both the app and the in-app product have been set up in the Play Console, we can start adding code to the project.

Creating a Purchase Helper Class

To establish a clean separation between the user interface and billing code, we will create a new helper class that will handle all of the purchasing tasks and use StateFlow instances to update the user interface with status changes. While it may be tempting to create this helper class as a view model, doing so will result in unstable code. The problem is that the billing client will need a reference to the main activity to process purchase transactions. This means that we will need to pass this reference to our helper class when an instance is created. As we know from previous chapters, activities are subject to being destroyed and recreated during the lifecycle of an app. Since view models are, by definition, designed to survive the destruction and recreation of activities we run the risk within our billing code of relying on a reference to an activity that no longer exists. To avoid this problem we will declare our purchase helper as a standard Kotlin data class that will be destroyed and recreated along with the activity.

Within the Project tool window, right-click on the com.<your company>.InAppPurchase entry, select the New -> Kotlin Class/File menu option and create a new class named PurchaseHelper. With the new class file created, edit it so that it reads as follows:

.
.
import android.app.Activity
import android.util.Log
import com.android.billingclient.api.*
import com.google.common.collect.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
 
data class PurchaseHelper(val activity: Activity) {

}

These changes import a set of libraries that will be needed later in the chapter and configure the class to expect an Activity initialization parameter. Next, add variable declarations to store values related to the billing process together with the id of the product created in the Google Play Console:

.
.
data class PurchaseHelper(val activity: Activity)  {

    private val coroutineScope = CoroutineScope(Dispatchers.IO)

    private lateinit var billingClient: BillingClient
    private lateinit var productDetails: ProductDetails
    private lateinit var purchase: Purchase

    private val demoProductId = "one_button_click"
.
.

Adding the StateFlow Streams

Communication between the purchase process and the user interface will be performed using StateFlow streams. Specifically, the user interface will use these to display status information on Text components and to ensure that Buttons are appropriately enabled and disabled. Using the techniques outlined in the chapter titled Kotlin Flow with Jetpack Compose, add the following StateFlow declarations to the PurchaseHelper class:

data class PurchaseHelper(val activity: Activity)  {
.
.
    private val _productName = MutableStateFlow("Searching...")
    val productName = _productName.asStateFlow()

    private val _buyEnabled = MutableStateFlow(false)
    val buyEnabled = _buyEnabled.asStateFlow()

    private val _consumeEnabled = MutableStateFlow(false)
    val consumeEnabled = _consumeEnabled.asStateFlow()

    private val _statusText = MutableStateFlow("Initializing...")
    val statusText = _statusText.asStateFlow()
}

Initializing the Billing Client

Next, the PurchaseHelper class needs a method that can be called from the MainActivity to initialize the billing client. Remaining within the PurchaseHelper.kt file, add this new method as follows:

fun billingSetup() {
    billingClient = BillingClient.newBuilder(activity)
        .setListener(purchasesUpdatedListener)
        .enablePendingPurchases()
        .build()

    billingClient.startConnection(object : BillingClientStateListener {
        override fun onBillingSetupFinished(
            billingResult: BillingResult
        ) {
            if (billingResult.responseCode ==
                BillingClient.BillingResponseCode.OK
            ) {
                _statusText.value = "Billing Client Connected"
                queryProduct(demoProductId)
            } else {
                _statusText.value = "Billing Client Connection Failure"
            }
        }

        override fun onBillingServiceDisconnected() {
            _statusText.value = "Billing Client Connection Lost"
        }
    })
}

When this method is called, it will create a new billing client instance and attempt to connect to the Google Play Billing Library. The onBillingSetupFinished() listener will be called when the connection attempt completes and update the statusText state flow indicating the success or otherwise of the connection attempt. Finally, we have also implemented the onBillingServiceDisconnected() callback which will be called if the Google Play Billing Library connection is lost.

If the connection is successful a method named queryProduct() is called. Both this method and the purchasesUpdatedListener assigned to the billing client now need to be added.

Querying the Product

To make sure the product is available for purchase, we need to create a QueryProductDetailsParams instance configured with the product ID that was specified in the Play Console, and pass it to the queryProductDetailsAsync() method of the billing client. This will require that we also add the onProductDetailsResponse() callback method where we will check that the product exists, extract the product name, and assign it to the statusText state. Now that we have obtained the product details, we can also safely enable the purchase button via the buyEnabled flow. Within the PurchaseHelper.kt file, add the queryProduct() method so that it reads as follows:

fun queryProduct(productId: String) {
    val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
        .setProductList(
            ImmutableList.of(
                QueryProductDetailsParams.Product.newBuilder()
                    .setProductId(productId)
                    .setProductType(
                        BillingClient.ProductType.INAPP
                    )
                    .build()
            )
        )
        .build()

    billingClient.queryProductDetailsAsync(
        queryProductDetailsParams
    ) { billingResult, productDetailsList ->
        if (productDetailsList.isNotEmpty()) {
            productDetails = productDetailsList[0]
            _productName.value = "Product: " + productDetails.name
        } else {
            _statusText.value = "No Matching Products Found"
            _buyEnabled.value = false
        }
    }
}

Much of the code used here should be familiar from the previous chapter. The listener code checks that at least one product was found that matches the query criteria. The ProductDetails object is then extracted from the first matching product, stored in the productDetails variable, and the product name property assigned to the productName state flow.

Handling Purchase Updates

The results of the purchase process will be reported to the app via the PurchaseUpdatedListener that was assigned to the billing client during the initialization phase. Add this handler now as follows:

private val purchasesUpdatedListener =
    PurchasesUpdatedListener { billingResult, purchases ->
        if (billingResult.responseCode ==
            BillingClient.BillingResponseCode.OK
            && purchases != null
        ) {
            for (purchase in purchases) {
                completePurchase(purchase)
            }
        } else if (billingResult.responseCode ==
            BillingClient.BillingResponseCode.USER_CANCELED
        ) {
            _statusText.value = "Purchase Canceled"
        } else {
            _statusText.value = "Purchase Error"
        }
    }

The handler will update the status text if the user cancels the purchase or another error occurs. A successful purchase, however, results in a call to a method named completePurchase() which is passed the current Purchase object. Add this method as outlined below:

private fun completePurchase(item: Purchase) {
    purchase = item
    if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
        _buyEnabled.value = false
        _consumeEnabled.value = true
        _statusText.value = "Purchase Completed"
    }
}

This method stores the purchase before verifying that the product has indeed been purchased and that payment is not still pending. The consume button is enabled, the purchase button disabled, and the user is notified that the purchase was successful.

Launching the Purchase Flow

We now need to add the following method which will be called from the purchase button in the user interface to start the purchase process:

fun makePurchase() {
    val billingFlowParams = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(
            ImmutableList.of(
                BillingFlowParams.ProductDetailsParams.newBuilder()
                    .setProductDetails(productDetails)
                    .build()
            )
        )
        .build()

    billingClient.launchBillingFlow(activity, billingFlowParams)
}

Consuming the Product

With the user now able to click on the “consume” button, the next step is to make sure the product is consumed so that only one click can be performed before another button click is purchased. This requires that we now write the consumePurchase() method:

fun consumePurchase() {
    val consumeParams = ConsumeParams.newBuilder()
        .setPurchaseToken(purchase.purchaseToken)
        .build()

    coroutineScope.launch {
        val result = billingClient.consumePurchase(consumeParams)

        if (result.billingResult.responseCode ==
            BillingClient.BillingResponseCode.OK) {
            _statusText.value = "Purchase Consumed"
            _buyEnabled.value = true
            _consumeEnabled.value = false
        }
    }
}

This method creates a ConsumeParams instance and configures it with the purchase token for the current purchase (obtained from the Purchase object previously saved in the completePurchase() method). This is passed to the consumePurchase() method which is launched within a coroutine using the IO dispatcher. If the product is successfully consumed, the consume button is disabled and the status text updated.

Restoring a Previous Purchase

With the code added so far, we can purchase a product and consume it within a single session. If we were to make a purchase and then exit the app before consuming it the purchase would currently be lost when the app restarts. We can solve this problem by configuring a QueryPurchasesParams instance to search for the unconsumed In-App product and passing it to the queryPurchasesAsync() method of the billing client together with a reference to a listener that will be called with the results. Add a new method and the listener to the MainActivity.kt file as follows:

private fun reloadPurchase() {
    val queryPurchasesParams = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.INAPP)
        .build()

    billingClient.queryPurchasesAsync(
        queryPurchasesParams,
        purchasesListener
    )
}
 
private val purchasesListener =
    PurchasesResponseListener { billingResult, purchases ->
        if (purchases.isNotEmpty()) {
            purchase = purchases.first()
            _buyEnabled.value = false
            _consumeEnabled.value = true
            _statusText.value = "Previous Purchase Found"
        } else {
            _buyEnabled.value = true
            _consumeEnabled.value = false
        }
    }

If the list of purchases passed to the listener is not empty, the first purchase in the list is assigned to the purchase variable, and the consume button enabled (in a more complete implementation code should be added to check this is the correct product by comparing the product id and to handle the return of multiple purchases). If no purchases are found, the consume button is disabled until another purchase is made. All that remains is to call our new reloadPurchase() method during the billing setup process as follows:

fun billingSetup() {
.
.
            if (billingResult.responseCode ==
                BillingClient.BillingResponseCode.OK
            ) {
                _statusText.value = "Billing Client Connected"
                queryProduct(demoProductId)
                reloadPurchase()
            } else {
                _statusText.value = "Billing Client Connection Failure"
            }
        }
.
.
}

Completing the MainActivity

Now that the helper class is completed, changes need to be made to the MainActivity.kt file. The first step is to modify the onCreate() function to create an instance of our PurchaseHelper class and pass it to the MainScreen composable:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        InAppPurchaseTheme {
            // A surface container using the 'background' color from the theme
            Surface(
                modifier = Modifier.fillMaxSize(),
                color = MaterialTheme.colorScheme.background
            ) {
                val purchaseHelper = PurchaseHelper(this)
                purchaseHelper.billingSetup()
                MainScreen(purchaseHelper)
            }
        }
    }
}

Remaining in the MainActivity.kt file, modify the MainScreen function as follows to accept the purchase handler instance and to collect from the state flow instances:

.
.
import androidx.compose.runtime.*
.
.
@Composable
fun MainScreen(purchaseHelper: PurchaseHelper) {
 
    val buyEnabled by purchaseHelper.buyEnabled.collectAsState(false)
    val consumeEnabled by purchaseHelper.consumeEnabled.collectAsState(false)
    val productName by purchaseHelper.productName.collectAsState("")
    val statusText by purchaseHelper.statusText.collectAsState("")
}

The final task before testing the app is to call the composables that make up the user interface. This will consist of a Column containing two Text components and an embedded Row containing two Buttons configured to call the makePurchase() and consumePurchase() methods of the purchase handler. The content displayed by the Text composables and the status of the buttons will be controlled by the state flow values. Make the following changes to complete the MainScreen composable:

.
.
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Alignment
import androidx.compose.material.Button
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
.
.
@Composable
fun MainScreen(purchaseHelper: PurchaseHelper) {
.
.
    Column(
        Modifier.padding(20.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {

        Text(
            productName,
            Modifier.padding(20.dp),
            fontSize = 30.sp)

        Text(statusText)

        Row(Modifier.padding(20.dp)) {

           Button(
               onClick = { purchaseHelper.makePurchase() },
               Modifier.padding(20.dp),
               enabled = buyEnabled
           ) {
               Text("Purchase")
           }

           Button(
               onClick = { purchaseHelper.consumePurchase() },
               Modifier.padding(20.dp),
               enabled = consumeEnabled
           ) {
               Text("Consume")
           }
       }
    }
}

Testing the App

Before we can test the app we need to upload this latest version to the Play Console. As we already have version 1 uploaded, we first need to increase the version number in the build.gradle (Module: InAppPurchase.app) file:

.
.
defaultConfig {
    applicationId "com.ebookfrenzy.inapppurchase"
    minSdk 26
    targetSdk 32
    versionCode 2
    versionName "2.0"
.
.

Sync the build configuration, then follow the steps in the Creating, Testing, and Uploading an Android App Bundle chapter to generate a new app bundle, upload it to the internal test track and roll it out to the testers. Using the testing track link, install the app on a device or emulator on which one of the test accounts is signed in.

After the app starts the user interface should appear as shown in Figure 1-5 below with the billing client connected, the product name displayed, and the Purchase button enabled:

Figure 1-5

Clicking the Purchase button will begin the purchase flow as shown in Figure 1-6:

Figure 1-6

Tap the buy button to complete the purchase using the test card and wait for the Consume button to be enabled.

Tap the Consume button and wait for the “Purchase Consumed” status message to appear. With the product consumed, it should now be possible to purchase it again. Make another purchase, then terminate and restart the app. The app should locate the previous unconsumed purchase and enable the consume button.

Troubleshooting

For additional information about failures, a useful trick is to access the debug message from BillingResult instances, for example:

.
.
} else if (billingResult.responseCode ==
    BillingClient.BillingResponseCode.USER_CANCELED
) {
    _statusText.value = "Purchase Canceled"
} else {
    _statusText.value = "Purchase Error"
    Log.i("InAppPurchase", billingResult.getDebugMessage())
}

After adding the debug code, make sure the device is attached to Android Studio, either via a USB cable or WiFi, and select it from within the Logcat panel. Enter InAppPurchaseTag into the Logcat search bar and check the diagnostic output, adding additional Log calls in the code if necessary.

Note that as long as you leave the app version number unchanged in the module-level build.gradle file, you should now be able to run modified versions of the app directly on the device or emulator without having to re-bundle and upload it to the console.

If the test payment card is not listed, make sure the user account on the device has been added to the license testers list. If the app is running on a physical device, try running it on an emulator. If all else fails, you can enter a valid payment method to make test purchases, and then refund yourself using the Order management screen accessible from the Play Console home page.

Summary

In this chapter, we created a project that demonstrated how to add an in-app product to an Android app. This included the creation of the product within the Google Play Console and the writing of code to initialize and connect to the billing client, querying of available products, and, finally, the purchase and consumption of the product. We also explained how to add license testers using the Play Console so that purchases can be made during testing without spending money.

Coroutines and LaunchedEffects in Jetpack Compose

When an Android application is first started, the runtime system creates a single thread in which all application components will run by default. This thread is generally referred to as the main thread. The primary role of the main thread is to handle the user interface in terms of event handling and interaction with views in the user interface. Any additional components that are started within the application will, by default, also run on the main thread.

Any code within an application that performs a time-consuming task using the main thread will cause the entire application to appear to lock up until the task is completed. This will typically result in the operating system displaying an “Application is not responding” warning to the user. This is far from the desired behavior for any application. Fortunately, Kotlin provides a lightweight alternative in the form of Coroutines. In this chapter, we will introduce Coroutines, including terminology such as dispatchers, coroutine scope, suspend functions, coroutine builders, and structured concurrency. The chapter will also explore channel-based communication between coroutines and explain how to safely launch coroutines from within composable functions.

What are coroutines?

Coroutines are blocks of code that execute asynchronously without blocking the thread from which they are launched. Coroutines can be implemented without having to worry about building complex multi-tasking implementations or directly managing multiple threads. Because of the way they are implemented, coroutines are much more efficient and less resource-intensive than using traditional multi-threading options. Coroutines also make for code that is much easier to write, understand and maintain since it allows code to be written sequentially without having to write callbacks to handle thread-related events and results.

Although a relatively recent addition to Kotlin, there is nothing new or innovative about coroutines. Coroutines in one form or another have existed in programming languages since the 1960s and are based on a model known as Communicating Sequential Processes (CSP). In fact, Kotlin still uses multi-threading behind the scenes, though it does so highly efficiently.

Threads vs. coroutines

A problem with threads is that they are a finite resource and expensive in terms of CPU capabilities and system overhead. In the background, a lot of work is involved in creating, scheduling, and destroying a thread. Although modern CPUs can run large numbers of threads, the actual number of threads that can be run in parallel at any one time is limited by the number of CPU cores (though newer CPUs have 8 or more cores, most Android devices contain CPUs with 4 cores). When more threads are required than there are CPU cores, the system has to perform thread scheduling to decide how the execution of these threads is to be shared between the available cores.

To avoid these overheads, instead of starting a new thread for each coroutine and then destroying it when the coroutine exits, Kotlin maintains a pool of active threads and manages how coroutines are assigned to those threads. When an active coroutine is suspended it is saved by the Kotlin runtime and another coroutine resumed to take its place. When the coroutine is resumed, it is simply restored to an existing unoccupied thread within the pool to continue executing until it either completes or is suspended. Using this approach, a limited number of threads are used efficiently to execute asynchronous tasks with the potential to perform large numbers of concurrent tasks without the inherent performance degeneration that would occur using standard multithreading.

Coroutine Scope

All coroutines must run within a specific scope which allows them to be managed as groups instead of as individual coroutines. This is particularly important when canceling and cleaning up coroutines and ensuring that coroutines do not “leak” (in other words continue running in the background when they are no longer needed by the app). By assigning coroutines to a scope they can, for example, all be canceled in bulk when they are no longer needed.

Kotlin and Android provide some built-in scopes as well as the option to create custom scopes using the CoroutineScope class. The built-in scopes can be summarized as follows:

  • GlobalScope – GlobalScope is used to launch top-level coroutines which are tied to the entire lifecycle of the application. Since this has the potential for coroutines in this scope to continue running when not needed (for example when an Activity exits) use of this scope is not recommended for use in Android applications. Coroutines running in GlobalScope are considered to be using unstructured concurrency.
  • ViewModelScope – Provided specifically for use in ViewModel instances when using the Jetpack architecture ViewModel component. Coroutines launched in this scope from within a ViewModel instance are automatically canceled by the Kotlin runtime system when the corresponding ViewModel instance is destroyed.
  • LifecycleScope – Every lifecycle owner has associated with it a LifecycleScope. This scope is canceled when the corresponding lifecycle owner is destroyed making it particularly useful for launching coroutines from within composables and activities.

For most requirements, the best way to access a coroutine scope from within a composable is to make a call to the rememberCoroutineScope() function as follows:

val coroutineScope = rememberCoroutineScope()

The coroutineScope declares the dispatcher that will be used to run coroutines (though this can be overridden) and must be referenced each time a coroutine is started if it is to be included within the scope. All of the running coroutines in a scope can be canceled via a call to the cancel() method of the scope instance:

coroutineScope.cancel()

Suspend functions

A suspend function is a special type of Kotlin function that contains the code of a coroutine. It is declared using the Kotlin suspend keyword which indicates to Kotlin that the function can be paused and resumed later, allowing long-running computations to execute without blocking the main thread. The following is an example suspend function:

suspend fun mySlowTask() {
    // Perform long-running task here    
}

Coroutine dispatchers

Kotlin maintains threads for different types of asynchronous activity and, when launching a coroutine, you have the option to specify a specific dispatcher from the following options:

  • Dispatchers.Main – Runs the coroutine on the main thread and is suitable for coroutines that need to make changes to the UI and as a general-purpose option for performing lightweight tasks.
  • Dispatchers.IO – Recommended for coroutines that perform network, disk, or database operations.
  • Dispatchers.Default – Intended for CPU-intensive tasks such as sorting data or performing complex calculations.

The dispatcher is responsible for assigning coroutines to appropriate threads and suspending and resuming the coroutine during its lifecycle. The following code, for example, launches a coroutine using the IO dispatcher:

.
.
coroutineScope.launch(Dispatchers.IO) {
    performSlowTask()
}
.
.

In addition to the predefined dispatchers, it is also possible to create dispatchers for your own custom thread pools.

Coroutine builders

The coroutine builders bring together all of the components covered so far and launch the coroutines so that they start executing. For this purpose, Kotlin provides the following six builders:

  • launch – Starts a coroutine without blocking the current thread and does not return a result to the caller. Use this builder when calling a suspend function from within a traditional function, and when the results of the coroutine do not need to be handled (sometimes referred to as “fire and forget” coroutines).
  • async – Starts a coroutine and allows the caller to wait for a result using the await() function without blocking the current thread. Use async when you have multiple coroutines that need to run in parallel. The async builder can only be used from within another suspend function.
  • withContext – This allows a coroutine to be launched in a different context from that used by the parent coroutine. A coroutine running using the Main context could, for example, launch a child coroutine in the Default context using this builder. The withContext builder also provides a useful alternative to async when returning results from a coroutine.
  • coroutineScope – The coroutineScope builder is ideal for situations where a suspend function launches multiple coroutines that will run in parallel and where some action needs to take place only when all the coroutines reach completion. If those coroutines are launched using the coroutineScope builder, the calling function will not return until all child coroutines have completed. When using coroutineScope, a failure in any of the coroutines will result in the cancellation of all other coroutines.
  • supervisorScope – Similar to the coroutineScope outlined above, with the exception that a failure in one child does not result in cancellation of the other coroutines.
  • runBlocking – Starts a coroutine and blocks the current thread until the coroutine reaches completion. This is typically the opposite of what is wanted from coroutines but is useful for testing code and integrating legacy code and libraries. Otherwise to be avoided.

Jobs

Each call to a coroutine builder such as launch or async returns a Job instance which can, in turn, be used to track and manage the lifecycle of the corresponding coroutine. Subsequent builder calls from within the coroutine create new Job instances which will become children of the immediate parent Job forming a parentchild relationship tree where canceling a parent Job will recursively cancel all its children. Canceling a child does not, however, cancel the parent, though an uncaught exception within a child created using the launch builder may result in the cancellation of the parent (this is not the case for children created using the async builder which encapsulates the exception in the result returned to the parent).

The status of a coroutine can be identified by accessing the isActive, isCompleted, and isCancelled properties of the associated Job object. In addition to these properties, several methods are also available on a Job instance. A Job and all of its children may, for example, be canceled by calling the cancel() method of the Job object, while a call to the cancelChildren() method will cancel all child coroutines.

The join() method can be called to suspend the coroutine associated with the job until all of its child jobs have completed. To perform this task and cancel the Job once all child jobs have completed, simply call the cancelAndJoin() method.

This hierarchical Job structure together with coroutine scopes form the foundation of structured concurrency, the goal of which is to ensure that coroutines do not run for longer than they are required without the need to manually keep references to each coroutine.

Coroutines – suspending and resuming

To gain a better understanding of coroutine suspension, it helps to see some examples of coroutines in action. To start with, let’s assume a simple Android app containing a button that, when clicked, calls a suspend function named performSlowTask(). The code for this might read as follows:

val coroutineScope = rememberCoroutineScope()

Button(onClick = {
    coroutineScope.launch {
        performSlowTask()
    }
}) {
    Text(text = "Click Me")
}

In the above code, a coroutine scope is obtained and referenced in the call to the launch builder which, in turn, calls the performSlowTask() suspend function. Next, we can declare the performSlowTask() suspend function as follows:

suspend fun performSlowTask() {
    println("performSlowTask before")
    delay(5000) // simulates long-running task
    println("performSlowTask after")
}

As implemented, all the function does is output diagnostic messages before and after performing a 5-second delay, simulating a long-running task. While the 5-second delay is in effect, the user interface will continue to be responsive because the main thread is not being blocked. To understand why it helps to explore what is happening behind the scenes.

A click on the button launches the performSlowTask() suspend function as a coroutine. This function then calls the Kotlin delay() function passing through a time value. In fact, the built-in Kotlin delay() function is itself implemented as a suspend function so is also launched as a coroutine by the Kotlin runtime environment. The code execution has now reached what is referred to as a suspend point which will cause the performSlowTask() coroutine to be suspended while the delay coroutine is running. This frees up the thread on which performSlowTask() was running and returns control to the main thread so that the UI is unaffected.

Once the delay() function reaches completion, the suspended coroutine will be resumed and restored to a thread from the pool where it can display the log message and return.

When working with coroutines in Android Studio suspend points within the code editor are marked as shown in the figure below:

Figure 1-1

We will explore some coroutine examples when we start to look at List composables, starting with the chapter titled Jetpack Compose Lists and Grids.

Coroutine channel communication

Channels provide a simple way to implement communication between coroutines including streams of data. In the simplest form this involves the creation of a Channel instance and calling the send() method to send the data. Once sent, transmitted data can be received in another coroutine via a call to the receive() method of the same Channel instance.

The following code, for example, passes six integers from one coroutine to another:

.
.
import kotlinx.coroutines.channels.*
.
.
val channel = Channel<Int>()
 
coroutineScope.launch() {
    coroutineScope.launch(Dispatchers.Main) { performTask1() }
    coroutineScope.launch(Dispatchers.Main) { performTask2() }
}
 
suspend fun performTask1() {
    (1..6).forEach {
        channel.send(it)
    }
}

suspend fun performTask2() {
    repeat(6) {
        println("Received: ${channel.receive()}")
    }
}

When executed, the following logcat output will be generated:

Received: 1
Received: 2
Received: 3
Received: 4
Received: 5
Received: 6

Understanding side effects

So far in this chapter, we have looked at coroutines and explained how to use a coroutine scope to execute code asynchronously. In each case, the coroutine was launched from within the onClick event handler of a Button composable. The reason for this is that while it is possible to launch a coroutine in this way from within the scope of an event handler, it is not safe to do so from within the scope of the parent composable. Consider, for example, the following code:

@Composable
fun Greeting(name: String) {

    val coroutineScope = rememberCoroutineScope()

    coroutineScope.launch() {
        performSlowTask()
    }
}

An attempt to compile the above code will result in an error that reads as follows:

Calls to launch should happen inside a LaunchedEffect and not composition

It is not possible to launch coroutines in this way when working within a composable because it can cause adverse side effects. In the context of Jetpack Compose, a side effect occurs when asynchronous code makes changes to the state of a composable from a different scope without taking into consideration the lifecycle of that composable. The risk here is the potential for a coroutine to continue running after the composable exits, a particular problem if the coroutine is still executing and making state changes the next time the composable runs.

To avoid this problem, we need to launch our coroutines from within the body of either a LaunchedEffect or SideEffect composable. Unlike the above attempt to directly launch a coroutine from within the scope of a composable, these two composables are considered safe to launch coroutines because they are aware of the lifecycle of the parent composable.

When a LaunchedEffect composable containing coroutine launch code is called, the coroutine will immediately launch and begin executing the asynchronous code. As soon as the parent composable completes, the LaunchedEffect instance and coroutine are destroyed.

The syntax for declaring a LaunchedEffect containing a coroutine is as follows:

LaunchedEffect(key1, key2, ...) {
    coroutineScope.launch() {
        // async code here
    }
}

The key parameter values (of which there must be at least one) control the behavior of the coroutine through recompositions. As long as the values of any of the key parameters remain unchanged, LaunchedEffect will keep the same coroutine running through multiple recompositions of the parent composable. If a key value changes, however, LaunchedEffect will cancel the current coroutine and launch a new one.

To call our suspend function from within our composable, we would need to change the code to read as follows:

@Composable
fun Greeting(name: String) {

    val coroutineScope = rememberCoroutineScope()

    LaunchedEffect(key1 = Unit) {
        coroutineScope.launch() {
            performSlowTask()
        }
    }
}

Note that we have passed a Unit instance (the equivalent of a void value) as the key in the above example to indicate that the coroutine does not need to be recreated through recompositions.

In addition to LaunchedEffect, Jetpack Compose also includes the SideEffect composable. Unlike LaunchedEffect, a SideEffect coroutine is executed after composition of the parent completes. SideEffect also does not accept key parameters and relaunches on every recomposition of the parent composable. We will be making use of LaunchedEffect in the chapter entitled “A Jetpack Compose SharedFlow Tutorial”.

Summary

Kotlin coroutines provide a simpler and more efficient approach to performing asynchronous tasks than that offered by traditional multi-threading. Coroutines allow asynchronous tasks to be implemented in a structured way without the need to implement the callbacks associated with typical thread-based tasks. This chapter has introduced the basic concepts of coroutines including jobs, scope, builders, suspend functions, structured concurrency, and channel-based communication.

While it is possible to directly start coroutines from within an event handler such as the onClick handler of a Button, doing so within the main body of a Composable is considered unsafe and results in a syntax error. In this situation, coroutines must be launched using either the LaunchedEffect or SideEffect composable functions.

A Jetpack Compose SharedFlow Tutorial

The previous chapter introduced Kotlin flows and explored how these can be used to return multiple sequential values from within coroutine-based asynchronous code. In this tutorial, we will look at a more detailed flow implementation, this time using SharedFlow. The tutorial will also demonstrate how to ensure that flow collection responds correctly to an app switching between background and foreground modes.

About the project

The app created in this chapter will consist of a user interface containing a List composable. A shared flow located within a ViewModel will be activated as soon as the view model is created and will emit an integer value every two seconds. The Main Activity will collect the values from the flow and display them within the List. The project will then be modified to suspend the collection process while the app is placed in the background.

Creating the SharedFlowDemo project

Launch Android Studio and create a new Empty Compose Activity project named SharedFlowDemo, specifying com.example.sharedflowdemo 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 and remove the Greeting call from DefaultPreview.

Next, modify the build.gradle (Module: FlowDemo.app) file to add the Compose view model and Kotlin runtime extensions libraries to the dependencies section:

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

When prompted, click on the Sync Now button at the top of the editor panel to commit to the change.

Adding a view model to the project

For this project, the flow will once again reside in a view model class. Add this model to the project by locating and right-clicking on the app -> java -> com.example.sharedflowdemo entry in the Project tool window and selecting the New -> Kotlin Class/File menu option. In the resulting dialog, name the class DemoViewModel before tapping the keyboard Enter key. Once created, modify the file so that it reads as follows:

package com.example.flowdemo

import androidx.lifecycle.ViewModel

class DemoViewModel : ViewModel() {
}

Return to the MainActivity.kt file and make changes to access an instance of the view model:

.
.
import androidx.lifecycle.viewmodel.compose.viewModel
.
.
@Composable
fun ScreenSetup(viewModel: DemoViewModel = viewModel()) {
    MainScreen()
}

Declaring the SharedFlow

The next step is to add some code to the view model to create and start the SharedFlow instance. Begin by editing the DemoViewModel.kt file so that it reads as follows:

package com.example.sharedflowdemo
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
 
class DemoViewModel  : ViewModel() {
 
    private val _sharedFlow = MutableSharedFlow<Int>()
    val sharedFlow = _sharedFlow.asSharedFlow()
 
    init {
        sharedFlowInit()
    }
 
    fun sharedFlowInit() {
    }
}

When the ViewModel instance is created, the initializer will call the sharedFlowInit() function. The purpose of this function is to launch a new coroutine containing a loop in which new values are emitted using a shared flow.

With the flow declared, code can now be added to the sharedFlowInit() function to launch the flow using the view model’s own scope. This will ensure that the flow ends when the view model is destroyed:

fun sharedFlowInit() {
    viewModelScope.launch {
        for (i in 1..1000) {
            delay(2000)
            _sharedFlow.emit(i)
        }
    }
}

Collecting the flow values

Before testing the app for the first time we need to add some code to perform the flow collection and display those values in a LazyColumn composable. As the values are collected from the flow, they will be added to a mutable list state instance which, in turn, will serve as the data source for the LazyColumn content. We also need to pass a reference to the shared flow down to the MainScreen composable. Edit the MainActivity.kt file and make the following changes:

.
.
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.*
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.SharedFlow
import androidx.compose.ui.platform.LocalLifecycleOwner
.
.
@Composable
fun ScreenSetup(viewModel: DemoViewModel = viewModel()) {
    MainScreen(viewModel.sharedFlow)
}
 
@Composable
fun MainScreen(sharedFlow: SharedFlow<Int>) {

    val messages = remember { mutableStateListOf<Int>()}

    LazyColumn {
        items(messages) {
            Text(
                "Collected Value = $it",
                style = MaterialTheme.typography.h3,
                modifier = Modifier.padding(5.dp)
            )
        }
    }
}
 
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    SharedFlowDemoTheme {
        val viewModel: DemoViewModel = viewModel()
        MainScreen(viewModel.sharedFlow)
    }
}

With these changes made we are ready to collect the values emitted by the shared flow and display them. Since the flow collection will be taking place in a coroutine and outside the scope of the MainScreen composable, the launch code needs to be placed within a LaunchedEffect call (a topic covered in the chapter titled “Coroutines and Side Effects in Jetpack Compose”. Add a LaunchedEffect call to the MainScreen composable as follows to collect from the flow:

.
.
import kotlinx.coroutines.flow.collect
.
.
@Composable
fun MainScreen(sharedFlow: SharedFlow<Int>) {
 
    val messages = remember { mutableStateListOf<Int>()}
    val lifecycleOwner = LocalLifecycleOwner.current
 
    LaunchedEffect(key1 = Unit) {
        sharedFlow.collect {
             messages.add(it)
        }
    }
.
.

This code accesses the shared flow instance within the view model and begins collecting values from the stream. Each collected value is added to the messages mutable list. This will cause a recomposition and the new value will appear at the end of the LazyColumn list.

Testing the SharedFlowDemo app

Compile and run the app on a device or emulator and verify that values appear within the LazyColumn as they are emitted by the shared flow. Rotate the device into landscape orientation to trigger a configuration change and confirm that the count continues without restarting from zero:

Figure 1-1

With the app now working, it is time to look at what happens when it is placed in the background.

Handling flows in the background

In our app, we have a shared flow that feeds values to the user interface in the form of a LazyColumn. By performing the collection in a coroutine scope, the user interface remains responsive while the flow is being collected (you can verify this by scrolling up and down within the list of values while the list is updating). This raises the question of what happens when the app is placed in the background. To find out, we can add some diagnostic output to both the emitter and collector code. First, edit the MainViewModel.kt file and add a println() call within the body of the emission for loop:

fun sharedFlowInit() {
    viewModelScope.launch {
        for (i in 1..1000) {
            delay(2000)
            println("Emitting $i")
            _sharedFlow.emit(i)
        }
    }
}

Make a similar change to the collection code block in the MainFragment.kt file as follows:

.
.
LaunchedEffect(key1 = Unit) {
        sharedFlow.collect {
        println("Collecting $it")
        messages.add(it)
    }
}
.
.

Once these changes have been made, display the Logcat tool window, enter System.out into the search bar, and run the app. As the list of values updates, output similar to the following should appear in the Run tool window:

Emitting 1
Collecting 1
Emitting 2
Collecting 2
Emitting 3
Collecting 3
.
.

Now place the app in the background and note that both the emission and collection operations continue to run, even though the app is no longer visible to the user. The continued emission is to be expected and is the correct behavior for a shared flow residing within a view model. It is wasteful of resources, however, to be collecting data and updating a user interface that is not currently visible to the user. We can resolve this problem by executing the collection using the repeatOnLifecycle function.

The repeatOnLifecycle function is a suspend function that runs a specified block of code each time the current lifecycle reaches or exceeds one of the following states:

  • Lifecycle.State.INITIALIZED
  • Lifecycle.State.CREATED
  • Lifecycle.State.STARTED
  • Lifecycle.State.RESUMED
  • Lifecycle.State.DESTROYED

Conversely, when the lifecycle drops below the target state, the coroutine is canceled.

In this case, we want the collection to start each time Lifecycle.State.STARTED is reached and to stop when the lifecycle is suspended. To implement this, modify the collection code as follows:

.
.
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
.
.
LaunchedEffect(key1 = Unit) {
    lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        sharedFlow.collect {
            println("Collecting $it")
            messages.add(it)
        }
    }
}

Run the app once again, place it in the background and note that only the emission diagnostic messages appear in the Logcat output confirming that the main activity is no longer collecting values and adding them to the

RecyclerView list. When the app is brought to the foreground, the collection will resume at the latest emitted value since replay was not configured on the shared flow.

Summary

In this chapter, we created a SharedFlow instance within a view model. We then collected the streamed values within the main activity and used that data to update the user interface. We also outlined the importance of avoiding unnecessary flow-driven user interface updates when an app is placed in the background, a problem that can easily be resolved using the repeatOnLifecycle function. This function can be used to cancel and restart asynchronous tasks such as flow collection when a target lifecycle state is reached by the containing lifecycle.

Kotlin Flow with Jetpack Compose

The earlier chapter titled “Coroutines and Side Effects in Jetpack Compose” taught us about Kotlin Coroutines and explained how they can be used to perform multiple tasks concurrently without blocking the main thread. A shortcoming of suspend functions is that they are typically only useful for performing tasks that either do not return a result or only return a single value. In this chapter, we will introduce Kotlin Flows and explore how these can be used to return sequential streams of results from coroutine-based tasks.

By the end of the chapter, you should have a good understanding of the Flow, StateFlow, and SharedFlow Kotlin types, and appreciate the difference between hot and cold flow streams. In the next chapter (“A Jetpack Compose SharedFlow Tutorial”), we will look more closely at using SharedFlow within the context of an example Android app project.

Understanding Flows

Flows are a part of the Kotlin programming language and are designed to allow multiple values to be returned sequentially from coroutine-based asynchronous tasks. A stream of data arriving over time via a network connection would, for example, be an ideal situation for using a Kotlin flow.

Flows are comprised of producers, intermediaries, and consumers. Producers are responsible for providing the data that makes up the flow. The code that retrieves the stream of data from our hypothetical network connection, for example, would be considered a producer. As each data value becomes available, the producer emits that value to the flow. The consumer sits at the opposite end of the flow stream and collects the values as they are emitted by the producer.

Intermediaries may be placed between the producer and consumer to perform additional operations on the data such as filtering the stream, performing additional processing, or transforming the data in other ways before it reaches the consumer. Figure 1-1 illustrates the typical structure of a Kotlin flow:

Figure 1-1

The flow shown in the above diagram consists of a single producer and consumer. In practice, it is possible both for multiple consumers to collect emissions from a single producer, and for a single consumer to collect data from multiple producers.

The remainder of this chapter will demonstrate many of the key features of Kotlin flows within the context of Jetpack Compose-based development.

Creating the Sample Project

Launch Android Studio and create a new Empty Compose Activity project named FlowDemo, specifying com. example.flowdemo 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 onCreate() method function to call ScreenSetup instead of Greeting (we will modify the DefaultPreview composable later).

Next, modify the build.gradle (Module: FlowDemo.app) file to add the Compose view model and Kotlin runtime extensions libraries to the dependencies section:

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

When prompted, click on the Sync Now button at the top of the editor panel to commit the change.

Adding a view model to the project

For this project, the flow will reside in a view model class. Add this model to the project by locating and rightclicking on the app -> java -> com.example.flowdemo entry in the project tool window and selecting the New -> Kotlin Class/File menu option. In the resulting dialog, name the class DemoViewModel before tapping the keyboard Enter key. Once created, modify the file so that it reads as follows:

package com.example.flowdemo
 
import androidx.lifecycle.ViewModel
 
class DemoViewModel : ViewModel() {
}

Return to the MainActivity.kt file and make changes to access an instance of the view model:

.
.
import androidx.lifecycle.viewmodel.compose.viewModel
.
.
@Composable
fun ScreenSetup(viewModel: DemoViewModel = viewModel()) {
    MainScreen()
}

Declaring the flow

The most basic form of flow is represented by the Kotlin Flow type. Each flow is only able to emit data of a single type which must be specified when the flow is declared. The following declaration, for example, declares a Flow instance designed to stream String-based data:

Flow<String>

When declaring a flow, we need to assign to it the code that will generate the data stream. This code is referred to as the producer block. This can be achieved using the flow() builder which takes as a parameter a coroutine suspend block containing the producer block code. Add the following code to the DemoViewModel.kt file to declare a flow named myFlow designed to emit a stream of integer values:

package com.example.flowdemo
 
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
 
class DemoViewModel : ViewModel() {
 
    val myFlow: Flow<Int> = flow {
        // Producer block
    }
}

As an alternative to the flow builder, the flowOf() builder can be used to convert a fixed set of values into a flow:

val myFlow2 = flowOf(2, 4, 6, 8)

Also, many Kotlin collection types now include an asFlow() extension function that can be called to convert the contained data to a flow. The following code, for example, converts an array of string values to a flow:

val myArrayFlow = arrayOf<String>("Red", "Green", "Blue").asFlow()

Emitting flow data

Once a flow has been built, the next step is to make sure the data is emitted so that it reaches any consumers that are observing the flow. Of the three flow builders we looked at in the previous section, only the flowOf() and asFlow() builders create flows that automatically emit the data as soon as a consumer starts collecting. In the case of the flow builder, however, we need to write code to manually emit each value as it becomes available. We achieve this by making calls to the emit() function and passing through as an argument the current value to be streamed. The following changes to our myFlow declaration implement a loop that emits the value of an incrementing counter. To demonstrate the asynchronous nature of flow streams, a 2-second delay is performed on each loop iteration:

val myFlow: Flow<Int> = flow {
    for (i in 0..9) {
        emit(i)
        delay(2000)
    }
}

Collecting flow data as state

As we will see later in the chapter, one way to collect data from a flow within a consumer is to call the collect() method on the flow instance. When working with Compose, however, a less flexible, but more convenient option is to convert the flow to state by calling the collectAsState() function on the flow instance. This allows us to treat the data just as we would any other state within our code. To see this in action, edit the MainActivity.kt file and make the following changes:

.
.
import androidx.compose.runtime.*
import kotlinx.coroutines.flow.*
.
.
@Composable
fun ScreenSetup(viewModel: DemoViewModel = viewModel()) {
    MainScreen(viewModel.myFlow)
}
 
@Composable
fun MainScreen(flow: Flow<Int>) {
   val count by flow.collectAsState(initial = 0)
}
.
.
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    FlowDemoTheme {
        ScreenSetup(viewModel())
    }
}

The changes pass a myFlow reference to the MainScreen composable where it is converted to a State with an initial value of 0. Next, we need to design a simple user interface to display the count values as they are emitted to the flow:

.
.
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
.
.
@Composable
fun MainScreen(myFlow: Flow<Int>) {
    val count by myFlow.collectAsState(initial = 0)

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "$count", style = TextStyle(fontSize = 40.sp))
    }
}

Try out the app either using the preview panel in interactive mode or by running it on a device or emulator. Once the app starts, the count value displayed on the Text component should increment as each new value is emitted by the flow.

Transforming data with intermediaries

In the previous example, we passed the data values to the consumer without any modifications. Changes to the data can be made between the producer and consumer by applying one or more intermediate flow operators. In this section, we will look at some of these operators.

The map() operator can be used to convert the value to some other value. We can use map(), for example, to convert our integer value to a string and add some additional text. Edit the DemoViewModel.kt file and create a modified version of our flow as follows:

.
.
class DemoViewModel : ViewModel() {
    
    val myFlow: Flow<Int> = flow {
        for (i in 0..9) {
            emit(i)
            delay(2000)
        }
    }
 
    val newFlow = myFlow.map {
        "Current value = $it"
    }
}

Before we can test this operator, some changes are needed within the MainActivity.kt file to use this new flow:

Composable
fun ScreenSetup(viewModel: DemoViewModel = viewModel()) {
    MainScreen(viewModel.newFlow)
}
 
@Composable
fun MainScreen(flow: Flow<String>) {
    val count by flow.collectAsState(initial = "Current value =")
.
.

When the code is executed, the text will display the text string updated with the count:

Current value = 1
Current value = 2
.
.

The map() operator will perform the conversion on every collected value. The filter() operator can be used to control which values get collected. The filter code block needs to contain an expression that returns a Boolean value. Only if the expression evaluates to true does the value pass through to the collection. The following code filters odd numbers out of the data flow (note that we’ve left the map() operator in place to demonstrate the chaining of operators):

val newFlow = myFlow
    .filter {
        it % 2 == 0
    }
    .map {
        "Current value = $it"
    }

The above changes will display count updates only for even numbers.

The transform() operator serves a similar purpose to map() but provides more flexibility. The transform() operator also needs to manually emit the modified result. A particular advantage of transform() is that it can emit multiple values, for example:

val newFlow = myFlow
    .transform {
        emit("Value = $it")
        delay(1000)
        val doubled = it * 2
        emit("Value doubled = $doubled")
    }
 
// Output
Value = 0
Value doubled = 0
Value = 1
Value doubled = 2
Value = 2
Value doubled = 4
Value = 3
.
.

Before moving to the next step, revert the newFlow declaration to its original form:

val newFlow = myFlow.map {
    "Current value = $it"
}

Collecting flow data

So far in this chapter we have used the collectAsState() function to convert a flow to a State instance. Behind the scenes, this method is using the collect() function to initiate the collection of data. Although collectAsState() works well most of the time, there will be situations where you may need to call collect(). In fact, collect() is just one of several so-called terminal flow operators that can be called directly to achieve results that aren’t possible using collectAsState().

These operators are suspend functions so can only be called from within a coroutine scope. In the chapter entitled “Coroutines and Side Effects in Jetpack Compose”, we looked at coroutines and explained how to use LaunchedEffect to execute asynchronous code safely from within a composable function. Once we have implemented the LaunchedEffect call, we still need the streamed values to be stored as state, so we also need a mutable state into which to store the latest value. Bringing these requirements together, modify the MainScreen function so that it reads as follows:

@Composable
fun MainScreen(flow: Flow<String>) {
    
    var count by remember { mutableStateOf<String>("Current value =")}
 
    LaunchedEffect(Unit) {
        flow.collect {
            count = it
        }
    }
 
    Column(
        modifier = Modifier.fillMaxSize(),
.
.

Test the app and verify that the text component updates as expected. Now that we are using the collect() function we can begin to explore some options that were not available to us when we were using collectAsState().

For example, to add code to be executed when the stream ends, the collection can be performed in a try/finally construct, for example:

LaunchedEffect(Unit) {
    try {
        flow.collect {
            count = it
        }
    } finally {
        count = "Flow stream ended."
    }
}

The collect() operator will collect every value emitted by the producer, even if new values are emitted while the last value is still being processed in the consumer. For example, our producer is configured to emit a new value every two seconds. Suppose, however, that we simulate our consumer taking 2.5 seconds to process each collected value. When executed, we will still see all of the values listed in the output because collect() does not discard any uncollected values regardless of whether more recent values have been emitted since the last collection. This type of behavior is essential to avoid data loss within the flow. In some situations, however, the consumer may be uninterested in any intermediate values emitted between the most recently processed value and the latest emitted value. In this case, the collectLatest() operator can be called on the flow instance. This operator works by canceling the current collection if a new value arrives before processing completes on the previous value and restarts the process on the latest value.

The conflate() operator is similar to the collectLatest() operator except that instead of canceling the current collection operation when a new value arrives, conflate() allows the current operation to complete, but discards intermediate values that arrive during this process. When the current operation completes, the most recent value is then collected.

Another collection operator is the single() operator. This operator collects a single value from the flow and throws an exception if it finds another value in the stream. This operator is useful where the appearance of a second stream value indicates that something else has gone wrong somewhere in the app or data source.

Adding a flow buffer

When a consumer takes time to process the values emitted by a producer, there is the potential for execution time inefficiencies to occur. Suppose, for example, that in addition to the two-second delay between each emission from our newFlow producer, the collection process in our consumer takes an additional second to complete. We can simulate this behavior as follows:

.
.
import kotlin.system.measureTimeMillis
import kotlinx.coroutines.delay
.
.
LaunchedEffect(Unit) {
 
    val elapsedTime = measureTimeMillis {
        flow.collect {
                count = it
                delay(1000)
        }
    }
    count = "Duration = $elapsedTime"
}

To allow us to measure the total time to fully process the flow, the consumer code has been placed in the closure of a call to the Kotlin measureTimeMillis() function. Next we need to make the following changes to the newFlow declaration in the DemoViewController class:

val newFlow = myFlow
    .map {
        "Current value = $it"
    }

After execution completes, a duration similar to the following will be reported:

Duration = 30044

This accounts for approximately 20 seconds to process the 10 values within newFlow and an additional 10 seconds for those values to be collected. There is an inefficiency here because the producer is waiting for the consumer to process each value before starting on the next value. This would be much more efficient if the producer did not have to wait for the consumer. We could, of course, use the collectLatest() or conflate() operators, but only if the loss of intermediate values is not a concern. To speed up the processing while also collecting every emitted value we can make use of the buffer() operator. This operator buffers values as they are emitted and passes them to the consumer when it is ready to receive them. This allows the producer to continue emitting values while the consumer is processing preceding values while also ensuring that every emitted value is collected. The buffer() operator may be applied to a flow as follows:

LaunchedEffect("Unit") {

    val elapsedTime = measureTimeMillis {
        flow
            .buffer()
            .collect {
                count = it
                delay(1000)
        }
    }
    count = "Duration = $elapsedTime"
}

Execution of the above code indicates that we have now reclaimed the 10 seconds previously lost in the collection code:

Duration = 20052

More terminal flow operators

The reduce() operator is one of several other terminal flow operators that can be used in place of a collection operator to make changes to the flow data. The reduce() operator takes two parameters in the form of an accumulator and a value. The first flow value is placed in the accumulator and a specified operation is performed between the accumulator and the current value (with the result stored in the accumulator). To try this out we need to revert to using myFlow instead of newFlow in addition to adding the reduce() operator call:

@Composable
fun ScreenSetup(viewModel: DemoViewModel = viewModel()) {
    MainScreen(viewModel.myFlow)
}

@Composable
fun MainScreen(flow: Flow<Int>) {
    
    var count by remember { mutableStateOf<Int>(0)}


    LaunchedEffect(Unit) {
    
        flow
            .reduce { accumulator, value ->
                count = accumulator
                accumulator + value
            }
    }
.
.

The fold() operator works similarly to the reduce() operator, with the exception that it is passed an initial accumulator value:

.
.
LaunchedEffect(Unit) {

    flow
        .fold(10) { accumulator, value ->
            count = accumulator
            accumulator + value
        }

}
.
.

Flow flattening

As we have seen in earlier examples, we can use operators to perform tasks on values collected from a flow. An interesting situation occurs, however, when that task itself creates one or more flows resulting in a “flow of flows”. In situations where this occurs, these streams can be flattened into a single stream. Consider the following example code which declares two flows:

val myFlow: Flow<Int> = flow {
    for (i in 1..5) {
        delay(1000)
        emit(i)
    }
}

fun doubleIt(value: Int) = flow {
    emit(value)
    delay(1000)
    emit(value + value)
}

If we were to call doubleIt() for each value in the myFlow stream we would end up with a separate flow for each value. This problem can be solved by concatenating the doubleIt() streams into a single flow using the flatMapConcat() operator as follows:

@Composable
fun ScreenSetup(viewModel: DemoViewModel = viewModel()) {
    MainScreen(viewModel)
}

@Composable
fun MainScreen(viewModel: DemoViewModel) {

    var count by remember { mutableStateOf<Int>(0)}

    LaunchedEffect(Unit) {

        viewModel.myFlow
            .flatMapConcat { viewModel.doubleIt(it) }
            .collect { 
              count = it 
              println("Count = $it")
            }
    }
.
.

When this modified code executes we will see the following output from the collect() operator:

1
2
2
4
3
6
4
8
5
10

As we can see from the output, the doubleIt() flow has emitted the value provided by myFlow followed by the doubled value. When using the flatMapConcat() operator, the doubleIt() calls are being performed synchronously, causing execution to wait until doubleIt() has emitted both values before processing the next flow value. The emitted values can instead be collected asynchronously using the flatMapMerge() operator as follows:

viewModel.myFlow
    .flatMapMerge { viewModel.doubleIt(it) }
    .collect { 
        count = it 
        println("Count = $it")
    }
}

Because the collection is being performed asynchronously the displayed value change too quickly to see all of the count values. Display the Run tool window to see the full list of collected values generated by the println() call:

I/System.out: Count = 1
I/System.out: Count = 2
I/System.out: Count = 2
I/System.out: Count = 4
I/System.out: Count = 3
I/System.out: Count = 6
I/System.out: Count = 4
I/System.out: Count = 8
I/System.out: Count = 5
I/System.out: Count = 10

Combining multiple flows

Multiple flows can be combined into a single flow using the zip() and combine() operators. The following code demonstrates the zip() operator being used to convert two flows into a single flow:

var count by remember { mutableStateOf<String>("")}

LaunchedEffect(Unit) {

    val flow1 = (1..5).asFlow()
        .onEach { delay(1000) }
    val flow2 = flowOf("one", "two", "three", "four")
        .onEach { delay(1500) }
    flow1.zip(flow2) { value, string -> "$value, $string" }
        .collect { count = it }
}
// Output
1, one
2, two
3, three
4, four

Note that we have applied the onEach() operator to both flows in the above code. This is a useful operator for performing a task on receipt of each stream value.

The zip() operator will wait until both flows have emitted a new value before performing the collection. The combine() operator works slightly differently in that it proceeds as soon as either flow emits a new value, using the last value emitted by the other flow in the absence of a new value:

.
.
    val flow1 = (1..5).asFlow()
        .onEach { delay(1000) }
    val flow2 = flowOf("one", "two", "three", "four")
        .onEach { delay(1500) }
    flow1.combine(flow2) { value, string -> "$value, $string" }
        .collect { count = it }
.
.
// Output
1, one
2, one
3, one
3, two
4, two
4, three
5, three
5, four

As we can see from the output, multiple instances have occurred where the last value has been reused on a flow because a new value was emitted on the other.

Hot and cold flows

So far in this chapter, we have looked exclusively at the Kotlin Flow type. Kotlin also provides additional types in the form of StateFlow and SharedFlow. Before exploring these, however, it is important to understand the concept of hot and cold flows.

A stream declared using the Flow type is referred to as a cold flow because the code within the producer does not begin executing until a consumer begins collecting values. StateFlow and SharedFlow, on the other hand, are referred to as hot flows because they begin emitting values immediately, regardless of whether any consumers are collecting the values.

Once a consumer begins collecting from a hot flow, it will receive the latest value emitted by the producer followed by any subsequent values. Unless steps are taken to implement caching, any previous values emitted before the collection starts will be lost.

Another important difference between Flow, StateFlow, and SharedFlow is that a Flow-based stream cannot have multiple collectors. Each Flow collector launches a new flow with its own independent data stream. With StateFlow and SharedFlow, on the other hand, multiple collectors share access to the same flow.

StateFlow

StateFlow, as the name suggests, is primarily used as a way to observe a change in state within an app such as the current setting of a counter, toggle button, or slider. Each StateFlow instance is used to store a single value that is likely to change over time and to notify all consumers when those changes occur. This enables you to write code that reacts to changes in state instead of code that has to continually check whether or not a state value has changed. StateFlow behaves the same way as LiveData with the exception that LiveData has lifecycle awareness and does not require an initial value (LiveData was covered previously in the chapter titled “Working with ViewModels in Compose”).

To create a StateFlow stream, begin by creating an instance of MutableStateFlow, passing through a mandatory initial value. This is the variable that will be used to change the current state value from within the app code:

private val _stateFlow = MutableStateFlow(0)

Next, call asStateFlow() on the MutableStateFlow instance to convert it into a StateFlow from which changes in state can be collected:

val stateFlow = _stateFlow.asStateFlow()

Once created, any changes to the state are made via the value property of the mutable state instance. The following code, for example, increments the state value:

_stateFlow.value += 1

Once the flow is active, the state can be consumed using collectAsState() or directly using a collection function, though it is generally recommended to collect from StateFlow using the collectLatest() operator. To try out an example, begin by making the following modifications to the DemoViewModel.kt file:

.
.
class DemoViewModel : ViewModel() {

    private val _stateFlow = MutableStateFlow(0)
    val stateFlow = _stateFlow.asStateFlow()

    fun increaseValue() {
        _stateFlow.value += 1
    }
.
.

Next, edit the MainActivity.kt file and change MainScreen so that it collects from the new state flow and add a button configured to call the view model increaseValue() function:

.
.
import androidx.compose.material.Button
.
.
@Composable
fun MainScreen(viewModel: DemoViewModel) {

    val count by viewModel.stateFlow.collectAsState()

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "$count", style = TextStyle(fontSize = 40.sp))
        Button(onClick = { viewModel.increaseValue() }) {
            Text("Click Me")
        }
    }
}

Run the app and verify that the button updates the count Text component with the incremented count value each time it is clicked.

SharedFlow

SharedFlow provides a more general-purpose streaming option than that offered by StateFlow. Some of the key differences between StateFlow and SharedFlow are as follows:

  • Consumers are generally referred to as subscribers.
  • An initial value is not provided when creating a SharedFlow instance.
  • SharedFlow allows values that were emitted prior to collection starting to be “replayed” to the collector.
  • SharedFlow emits values instead of using a value property.

SharedFlow instances are created using MutableSharedFlow as the backing property on which we call the asSharedFlow() function to obtain a SharedFlow reference. For example, make the following changes to the DemoViewModel class to declare a shared flow:

.
.
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.BufferOverflow
.
.
class DemoViewModel : ViewModel() {

    private val _sharedFlow = MutableSharedFlow<Int>(
        replay = 10,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )

    val sharedFlow = _sharedFlow.asSharedFlow()
.
.

As configured above, new flow subscribers will receive the last 10 values before receiving any new values. The above flow is also configured to discard the oldest value when more than 10 values are buffered. The full set of options for handling buffer overflows are as follows:

  • DROP_LATEST – The latest value is dropped when the buffer is full leaving the buffer unchanged as new values are processed.
  • DROP_OLDEST – Treats the buffer as a “last-in, first-out” stack where the oldest value is dropped to make room for a new value when the buffer is full.
  • SUSPEND – The flow is suspended when the buffer is full.

Values are emitted on a SharedFlow stream by calling the emit() method of the MutableSharedFlow instance from within a coroutine. Remaining in the DemoViewModel.kt file, add a new method that can be called from the main activity to start the shared flow:

fun startSharedFlow() {
    
    viewModelScope.launch {
        for (i in 1..5) {
            _sharedFlow.emit(i)
            delay(2000)
        }
    }
}

Finally, make the following changes to the MainScreen composable:

@Composable
fun MainScreen(viewModel: DemoViewModel) {

    val count by viewModel.sharedFlow.collectAsState(initial = 0)

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "$count", style = TextStyle(fontSize = 40.sp))
        Button(onClick = { viewModel.startSharedFlow() }) {
            Text("Click Me")
        }
    }
}

Run the app on a device or emulator (shared flow code does not always work in the interactive preview) and verify that clicking the button causes the count to begin updating. Note that since new values are being emitted from within a coroutine you can click on the button repeatedly and collect values from multiple flows.

One final point to note about shared flows is that the current number of subscribers to a SharedFlow stream can be obtained via the subscriptionCount property of the mutable instance:

val subCount = _sharedFlow.subscriptionCount

Converting a flow from cold to hot

A cold flow can be made hot by calling the shareIn() function on the flow. This call requires a coroutine scope in which to execute the flow, a replay value, and start policy setting indicating the conditions under which the flow is to start and stop. The available start policy options are as follows:

  • SharingStarted.WhileSubscribed() – The flow is kept alive as long as it has active subscribers.
  • SharingStarted.Eagerly() – The flow begins immediately and remains active even in the absence of active subscribers.
  • SharingStarted.Lazily() – The flow begins only after the first consumer subscribes and remains active even in the absence of active subscribers.

We could, for example, make one of our earlier cold flows hot using the following code:

val hotFlow = myFlow.shareIn(
    viewModelScope, 
    replay = 1, 
    started = SharingStarted.WhileSubscribed()
)

Summary

Kotlin flows allow sequential data or state changes to be returned over time from asynchronous tasks. A flow consists of a producer that emits a sequence of values and consumers that collect and process those values. The flow stream can be manipulated between the producer and consumer by applying one or more intermediary operators including transformations and filtering. Flows are created based on the Flow, StateFlow, and SharedFlow types. A Flow-based stream can only have a single collector while StateFlow and SharedFlow can have multiple collectors.

Flows are categorized as being hot or cold. A cold flow does not begin emitting values until a consumer begins collection. Hot flows, on the other hand, begin emitting values as soon as they are created, regardless of whether or not the values are being collected. In the case of SharedFlow, a predefined number of values may be buffered and subsequently replayed to new subscribers when they begin collecting values. A cold flow can be made hot via a call to the flow’s shareIn() function.

Creating, Testing, and Uploading an Android App Bundle

Once the development work on an Android application is complete and it has been tested on a wide range of Android devices, the next step is to prepare the application for submission to Google Play. Before submission can take place, however, the application must be packaged for release and signed with a private key. This chapter will explain how to obtain a private key, prepare the Android App Bundle for the project, and upload it to Google Play.

The release preparation process

Up until this point in the book, we have been building application projects in a mode suitable for testing and debugging. Building an application package for release to customers via Google Play, on the other hand, requires that some additional steps be taken. The first requirement is that the application is compiled in release mode instead of debug mode. Secondly, the application must be signed with a private key that uniquely identifies you as the application’s developer. Finally, the application must be packaged into an Android App Bundle.

While each of these tasks can be performed outside of the Android Studio environment, the procedures can more easily be performed using the Android Studio build mechanism as outlined in this chapter. First, however, it is important to understand a little more about Android App Bundles.

Android app bundles

When a user installs an app from Google Play, the app is downloaded in the form of an APK file. This file contains everything needed to install and run the app on the user’s device. Before the introduction of Android Studio 3.2, the developer would generate one or more APK files using Android Studio and upload them to Google Play. To support multiple device types, screen sizes, and locales this would require either the creation and upload of multiple APK files customized for each target device and locale or the generation of a large universal APK containing all of the different configuration resources and platform binaries within a single package.

Creating multiple APK files involved a significant amount of work that had to be repeated each time the app needed to be updated imposing a considerable time overhead on the app release process.

The universal APK option, while less of a burden to the developer, caused an entirely unexpected problem. By analyzing app installation metrics, Google discovered that the larger an installation APK file becomes (resulting in longer download times and increased storage use on the device), the fewer conversions the app receives. The conversion rate is calculated as a percentage of the users who completed the installation of an app after viewing that app on Google Play. Google estimates that the conversion rate for an app drops by 1% for each 6MB increase in APK file size.

Android App Bundles solve both of these problems by providing a way for the developer to create a single package from within Android Studio and have custom APK files automatically generated by Google Play for each individual supported configuration (a concept referred to as Dynamic Delivery).

An Android App Bundle is essentially a ZIP file containing all of the files necessary to build APK files for the devices and locales for which support has been provided within the app project. The project might, for example, include resources and images for different screen sizes. When a user installs the app, Google Play receives information about the user’s device including the display, processor architecture, and locale. Using this information, the appropriate pre-generated APK files are transferred onto the user’s device.

An additional benefit of Dynamic Delivery is the ability to split an app into multiple modules, referred to as dynamic feature modules, where each module contains the code and resources for a particular area of functionality within the app. Each dynamic feature module is contained within a separate APK file from the base module and is downloaded to the device only when that feature is required by the user. Dynamic Delivery and app bundles also allow for the creation of instant dynamic feature modules which can be run instantly on a device without the need to install an entire app.

Although it is still possible to generate APK files from Android Studio, app bundles are now the recommended way to upload apps to Google Play.

Register for a Google Play Developer Console account

The first step in the application submission process is to create a Google Play Developer Console account. To do so, navigate to https://play.google.com/apps/publish/signup/ and follow the instructions to complete the registration process. Note that there is a one-time $25 fee to register. Once an application goes on sale, Google will keep 30% of all revenues associated with the application.

Once the account has been created, the next step is to gather together information about the application. To bring your application to market, the following information will be required:

  • Title – The title of the application.
  • Short Description – Up to 80 words describing the application.
  • Full Description – Up to 4000 words describing the application.
  • Screenshots – Up to 8 screenshots of your application running (a minimum of two is required). Google recommends submitting screenshots of the application running on a 7” or 10” tablet.
  • Language – The language of the application (the default is US English).
  • Promotional Text – The text that will be used when your application appears in special promotional features within the Google Play environment.
  • Application Type – Whether your application is considered to be a game or an application.
  • Category – The category that best describes your application (for example finance, health and fitness, education, sports, etc.).
  • Locations – The geographical locations into which you wish your application to be made available for purchase.
  • Contact Details – Methods by which users may contact you for support relating to the application. Options include web, email, and phone.
  • Pricing & Distribution – Information about the price of the application and the geographical locations where it is to be marketed and sold.

Having collected the above information, click on the Create app button within the Google Play Console to begin the creation process.

Configuring the app in the console

When the Create app button is first clicked, the app details and declarations screen will appear as shown in Figure 51-1 below:

Figure 51-1

Once the app entry has been fully configured, click on the Create app button (highlighted in the above figure) to add the app and display the dashboard screen. Within the dashboard, locate the Initial setup section and unfold the list of steps to configure the app store listing:

Figure 51-2

Work through the list of links and provide the requested information for your app, making sure to save the changes at each step.

Enabling Google Play app signing

Up until recently, Google Play uploads were signed with a release app signing key from within Android Studio and then uploaded to the Google Play console. While this option is still available, the recommended way to upload files is to now use a process referred to as Google Play App Signing. For a newly created app, this involves opting into Google Play App Signing and then generating an upload key that is used to sign the app bundle file within Android Studio. When the app bundle file generated by Android Studio is uploaded, the Google Play console removes the upload key and then signs the file with an app signing key that is stored securely within the Google Play servers.

Within the Google Play console, select the newly added app entry from the All Apps screen (accessed via the option located at the top of the left-hand navigation panel), unfold the Setup section (Marked A in Figure 51-3), and select the App Signing option (B).

Figure 51-3

Opt in to Google Play app signing by clicking on the Create release button (C). The console is now ready to create the first release of your app for testing. Before doing so, however, the next step is to generate the upload key from within Android Studio. This is performed as part of the process of generating a signed app bundle. Leave the current Google Play Console screen loaded into the browser as we will be returning to this later in the chapter.

Creating a keystore file

To create a keystore file, select the Android Studio Build -> Generate Signed Bundle / APK… menu option to display the Generate Signed Bundle or APK Wizard dialog as shown in Figure 51-4:

Figure 51-4

Verify that the Android App Bundle option is selected before clicking on the Next button.

If you have an existing release keystore file, click on the Choose existing… button on the next screen and navigate to and select the file. If you have yet to create a keystore file, click on the Create new… button to display the

New Key Store dialog (Figure 51-5). Click on the button to the right of the Key store path field and navigate to a suitable location on your file system, enter a name for the keystore file (for example, release.keystore.jks) and click on the OK button.

The New Key Store dialog is divided into two sections. The top section relates to the keystore file. In this section, enter a strong password with which to protect the keystore file into both the Password and Confirm fields. The lower section of the dialog relates to the upload key that will be stored in the key store file.

Figure 51-5

Within the Certificate section of the New Key Store dialog, enter the following details:

  • An alias by which the key will be referenced. This can be any sequence of characters, though only the first 8 are used by the system.
  • A suitably strong password to protect the key.
  • The number of years for which the key is to be valid (Google recommends at least 25 years).

In addition, information must be provided for at least one of the remaining fields (for example, your first and last name, or organization name).

Figure 51-6

Once the information has been entered, click on the OK button to proceed with the bundle creation.

Creating the Android app bundle

The next step is to instruct Android Studio to build the application app bundle file in release mode and then sign it with the newly created private key. At this point the Generate Signed Bundle or APK dialog should still be displayed with the keystore path, passwords, and key alias fields populated with information:

Figure 51-7

Make sure that the Export Encrypted Key option is enabled and, assuming that the other settings are correct, click on the Next button to proceed to the app bundle generation screen (Figure 51-8). Within this screen, review the Destination Folder: setting to verify that the location into which the app bundle file will be generated is acceptable. If you would prefer to use another location, click on the button to the right of the text field and navigate to the desired file system location.

Figure 51-8

Click on the Finish button and wait for the Gradle system to build the app bundle. Once the build is complete, a dialog will appear providing the option to open the folder containing the app bundle file in an explorer window, or to load the file into the APK Analyzer:

Figure 51-9

At this point, the application is ready to be submitted to Google Play. Click on the locate link to open a filesystem browser window. The file should be named bundle.aab and be located in the app/release sub-directory of the project folder unless another location was specified.

The private key generated as part of this process should be used when signing and releasing future applications and, as such, should be kept in a safe place and securely backed up.

Generating test APK files

An optional step at this stage is to generate APK files from the app bundle and install and run them on devices or emulator sessions. Google provides a command-line tool called bundletool designed specifically for this purpose which can be downloaded from the following URL:

https://github.com/google/bundletool/releases

At the time of writing, bundletool is provided as a .jar file which can be executed from the command-line as follows (noting that the version number may have changed since this book was published):

java -jar bundletool-all-0.9.0.jar

Running the above command will list all of the options available within the tool. To generate the APK files from the app bundle, the build-apks option is used. To generate APK files that can be installed onto a device or emulator the files will also need to be signed. To achieve this include the –ks option specifying the path of the keystore file created earlier in the chapter, together with the –ks-key-alias option specifying the alias provided when the key was generated.

Finally, the –output flag must be used to specify the path of the file (referred to as the APK Set) into which the APK files will be generated. This file must not already exist and is required to have a .apks filename extension. Bringing these requirements together results in the following command-line (allowing for differences in your operating system path structure):

java -jar bundletool-all-0.9.0.jar build-apks --bundle=/tmp/MyApps/app/release/ bundle.aab --output=/tmp/MyApks.apks --ks=/MyKeys/release.keystore.jks --ks-keyalias=MyReleaseKey

When this command is executed, a prompt will appear requesting the keystore password before the APK files are generated into the specified APK Set file. The APK Set file is simply a ZIP file containing all of the APK files generated from the app bundle.

To install the appropriate APK files onto a connected device or emulator, use a command similar to the following:

java -jar bundletool-all-0.9.0.jar install-apks --apks=/tmp/MyApks.apks

This command will instruct the tool to identify the appropriate APK files for the connected device and install them so that the app can be launched and tested.

It is also possible to extract the APK files from the APK Set for the connected device without installing them. The first step in this process is to obtain the specification of the connected device as follows:

java -jar bundletool-all-0.9.0.jar get-device-spec --output=/tmp/device.json

The above command will generate a JSON file similar to the following:

Next, this specification file is used to extract the matching APK files from the APK Set:

java -jar bundletool-all-0.9.0.jar extract-apks --apks=/tmp/MyApks.apks --outputdir=/tmp/nexus5_apks --device-spec=/tmp/device.json

When executed, the directory specified via the –output-dir flag will contain correct APK files for the specified device configuration.

The next step in bringing an Android application to market involves submitting it to the Google Play Developer Console so that it can be made available for testing.

Uploading the app bundle to the Google Play Developer Console

Return to the Google Play Console and select the Internal testing option (marked A in Figure 51-10) located in the Testing section of the navigation panel before clicking on the Create new release button (B):

Figure 51-10

On the resulting screen, click on the Continue button (marked A below) to confirm the use of Google Play app signing, then drag and drop the bundle file generated by Android Studio onto the upload drop point (B):

Figure 51-11

When the upload is complete, scroll down the screen and enter the release name and optional release notes. The release name can be any information you need to help you recognize the release and it is not visible to users.

After the app bundle file has uploaded, Google Play will generate all of the necessary APK files ready for testing. Once the APK files have been generated, scroll down to the bottom of the screen and click on the Save button. Once the settings have been saved, click on the Review release button.

Exploring the app bundle

On the review screen, click on the arrow to the right of the uploaded bundle as indicated in Figure 51-12:

Figure 51-12

In the resulting panel, click on the Explore bundle link to load the app bundle explorer. This provides summary information relating to the API levels, screen layouts, and platforms supported by the app bundle:

Figure 51-13

Clicking on the Go to device catalog link will display the devices that are supported by the APK file:

Figure 51-14

At this point, the app is ready for testing but cannot be rolled out until some testers have been set up within the console.

Managing testers

If the app is still in the Internal, Alpha, or Beta testing phase, a list of authorized testers may be specified by selecting the app from within the Google Play console, clicking on Internal testing in the navigation panel, and selecting the Testers tab as shown in Figure 51-15:

Figure 51-15

To add testers, click on the Create email list button, name the list and specify the email addresses for the test users either manually or by uploading a CSV file.

The “Join on the web” URL may now be copied from the screen and provided to the test users so that they accept the testing invitation and download the app.

Rolling the app out for testing

Now that an internal release has been created and a list of testers added, the app is ready to be rolled out for testing. Remaining within the Internal testing screen, select the Releases tab before clicking on the Edit button for the recently created release:

Figure 51-16

On the review screen, scroll to the bottom and click on the Start rollout to Internal testing button. After a short delay, while the release is processed, the app will be ready to be downloaded and tested by the designated users.

Uploading new app bundle revisions

The first app bundle file uploaded for your application will invariably have a version code of 1. If an attempt is made to upload another bundle file with the same version code number, the console will reject the file with the following error:

You need to use a different version code for your APK because you already have one with version code 1.

To resolve this problem, the version code embedded into the bundle file needs to be increased. This is performed in the module level build.gradle file of the project, shown highlighted in Figure 51-17:

Figure 51-17

By default, this file will typically read as follows:

plugins {
    id 'com.android.application'
}'
 
android {
    compileSdkVersion 31
    
    defaultConfig {
        applicationId "com.example.myapplication"
        minSdkVersion 26
        targetSdkVersion 31
        versionCode 1
        versionName "1.0"
.
.
}

To change the version code, simply change the number declared next to versionCode. To also change the version number displayed to users of your application, change the versionName string. For example:

versionCode 2
versionName "2.0"

Having made these changes, rebuild the APK file and perform the upload again.

Analyzing the app bundle file

Android Studio provides the ability to analyze the content of an app bundle file. To analyze a bundle file, select the Android Studio Build -> Analyze APK… menu option and navigate to and choose the bundle file to be reviewed. Once loaded into the tool, information will be displayed about the raw and download size of the package together with a listing of the file structure of the package as illustrated in Figure 51-18:

Figure 51-18

Selecting the classes.dex file will display the class structure of the file in the lower panel. Within this panel, details of the individual classes may be explored down to the level of the methods within a class:

Figure 51-19

Similarly, selecting a resource or image file within the file list will display the file content within the lower panel. The size differences between two bundle files may be reviewed by clicking on the Compare with previous APK… button and selecting a second bundle file.

Summary

Once an app project is either complete or ready for user testing, it can be uploaded to the Google Play console and published for production, internal, alpha, or beta testing. Before the app can be uploaded, an app entry must be created within the console including information about the app together with screenshots to be used within the Play Store. A release Android App Bundle file is then generated and signed with an upload key from within Android Studio. After the bundle file has been uploaded, Google Play removes the upload key and replaces it with the securely stored app signing key and the app is ready to be published.

The content of a bundle file can be reviewed at any time by loading it into the Android Studio APK Analyzer tool.

A Material Design 3 Theming Tutorial

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

Creating the ThemeDemo project

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

@Composable
fun MainScreen() {
    
}

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

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

Adding the Material Design 3 library

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

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

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

Designing the user interface

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

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

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

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

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

Figure 50-1

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

Building a new theme

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

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

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

Figure 50-2

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

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

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

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

Adding the theme to the project

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

Figure 50-3

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

package com.example.themedemo.ui.theme 

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

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

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

Enabling dynamic colors

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

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

Figure 50-4

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

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

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

Summary

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

Custom Jetpack Compose Themes

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

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

Material Design 2 vs Material Design 3

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

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

Material Design 2 Theming

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

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

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

Figure 49-1

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Material Design 3 Theming

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

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

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

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

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

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

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

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

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

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

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

Building a Custom Theme

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

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

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

Figure 49-2

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

Summary

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

Detecting Swipe Gestures in Jetpack Compose

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

Swipe gestures and anchors

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

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

Detecting swipe gestures

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

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

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

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

Declaring the anchors map

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

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

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

Text(swipeableState.currentValue)

Declaring thresholds

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

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

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

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

Moving a component in response to a swipe

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

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

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

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

About the SwipeDemo project

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

Figure 48-1

Creating the SwipeDemo project

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

@Composable
fun MainScreen() {
    
}

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

Setting up the swipeable state and anchors

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

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

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

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

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

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

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

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

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

Designing the parent Box

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

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

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

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

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

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

Figure 48-2

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

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

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

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

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

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

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

Testing the project

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

Figure 48-3

Summary

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

Jetpack Compose Gesture Detection

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

Compose gesture detection

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

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

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

Creating the GestureDemo project

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

@Composable
fun MainScreen() {
    
}

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

Detecting click gestures

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

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

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

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

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

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

Detecting taps using PointerInputScope

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

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

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

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

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

Figure 47-1

Detecting drag gestures

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

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

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

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

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

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

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

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

Figure 47-2

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

Detecting drag gestures using PointerInputScope

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

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

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

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

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

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

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

Figure 47-3

Scrolling using the scrollable modifier

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

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

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

Scrolling using the scroll modifiers

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

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

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

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

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

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

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

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

Figure 47-4

Next, modify the MainActivity.kt file as follows:

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

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

Figure 47-5

Detecting pinch gestures

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

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

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

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

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

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

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

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

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

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

Detecting rotation gestures

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

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

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

Figure 47-6

Detecting translation gestures

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

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

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

Summary

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

A Jetpack Compose Bottom Navigation Bar Tutorial

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

Creating the BottomBarDemo project

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

@Composable
fun MainScreen() {
    
}

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

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

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

Declaring the navigation routes

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

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

Designing bar items

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

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

Creating the bar item list

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

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

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

Adding the destination screens

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

Figure 46-1

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

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

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

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

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

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

Creating the navigation controller and host

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

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

Designing the navigation bar

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

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

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

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

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

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

Working with the Scaffold component

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

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

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

Testing the project

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

Figure 46-2

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

Summary

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