Migrating from Material Design 2 to Material Design 3

This chapter will demonstrate how to migrate an Android Studio project from Material Design 2 to Material Design 3 and integrate a custom theme generated using the Material Theme Builder tool.

Creating the ThemeMigration Project

Select the New Project option from the welcome screen and, within the resulting new project dialog, choose the Empty Activity template before clicking on the Next button.

Enter ThemeMigration into the Name field and specify com.ebookfrenzy.thememigration as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Kotlin.

Designing the User Interface

The main activity will consist of a simple layout containing some of the user interface components that will enable us to see the effect of both switching to Material Design 3, and the theming work performed later in the chapter. For information on MD3 components, refer to the following web page:

https://material.io/blog/migrating-material-3

The layout will be designed within the activity_main.xml file which currently contains a single Text view. Open this file in the layout editor, delete the Text view, disable Autoconnect mode (marked A in Figure 94-1), and click on the button to clear all constraints from the layout (B).

Figure 94-1

From the Buttons section of the Palette, drag and drop Chip, CheckBox, Switch, and Button views onto the layout canvas. Next, drag a FloatingActionButton onto the layout canvas so that it is positioned beneath the Button component. When prompted to choose an icon to appear on the FloatingActionButton, select the ic_ lock_power_off icon from within the resource tool window as illustrated in Figure 94-2:

Figure 94-2

Change the text attribute for the Chip widget to “This is my chip” and set the chipIcon attribute to @ android:drawable/ic_btn_speak_now so that the layout resembles that shown to the left in Figure 94-3:

Figure 94-3

Migrating from Material Design 2 to Material Design 3 To set up the constraints shown in the blueprint view in the figure above, select all of the components, right-click on the Chip view, and select Chains -> Create Vertical Chain from the resulting menu. Repeat this step, this time selecting the Center -> Horizontally in Parent menu option.

Compile and run the app on a device or emulator and verify that the user interface matches that shown in Figure 94-3 above. The layout is presented using the Material Design 2 components. The next step is to migrate the theme to Material Design 3.

Migrating to Material Design 3

To switch to Material Design 3 components, all we need to do is edit the two themes.xml files located under app – > res -> values -> themes. The name property of the style element in the first themes.xml needs to be changed as follows:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.ThemeMigration" parent="Theme.Material3.Light">

Similarly, make the following change to the themes.xml (night) file to migrate to MD3 for dark mode:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.ThemeMigration" parent="Theme.Material3.Dark">

After making the changes, run the app once again and note the differences in appearance between MD2 and MD3 as shown in Figure 94-4 below:

Figure 94-4

Now that we have migrated the project to MD3, the next step is to create a custom theme and apply it to the project.

Building a New Theme

If you completed the previous chapter (“A Material Design 3 Theming and Dynamic Color Tutorial”) you can use the custom theme created in that chapter. If you do not yet have a custom theme, 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 circle in the Core Colors section to display the color selector. From the color selector, choose any color as the basis for your theme:

Figure 94-5

Review the color scheme in the Your Theme panel and make any necessary color adjustments using the Core Color settings 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 Android Views (XML) 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 folders and files in a folder named material-theme:

  • values/colors.xml – The color definitions.
  • values/themes.xml – The theme for the light mode.
  • values-night/themes.xml – The theme for dark mode.

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. This is easier to do if the Project tool window is in Project Files mode. To switch mode, use the menu at the top of the tool Project tool window as shown below and select the Project Files option:

Figure 94-6

With Project Files mode selected, navigate to the app -> src -> main -> res -> values folder and select and delete the colors.xml and themes.xml files. Also, delete the themes.xml file located in the values-night folder.

Open the filesystem navigation tool for your operating system, locate the colors.xml and themes.xml files in the values folder of the new material theme and copy and paste them into the values folder within the Project tool window. Repeat this step to copy the themes.xml file located in the values-night folder, this time pasting into the values-night folder.

Switch the Project tool window back to Android mode, at which point the value resource files section should match Figure 94-7:

Figure 94-7

Review the two themes.xml files and if the colorInversePrimary and colorShadow items are displayed in red indicating they are unresolved, delete these lines from the file before continuing. Next, modify the light themes.xml file to match the current project as follows:

<resources>
    <style name="Base.Theme.ThemeMigration" parent="Theme.Material3.Light.NoActionBar">
        <item name="colorPrimary">@color/md_theme_light_primary</item>
        <item name="colorOnPrimary">@color/md_theme_light_onPrimary</item>
.
.
    </style>

    <style name="Theme.ThemeMigration" parent="Base.Theme.ThemeMigration" />

</resources>

Repeat these steps to make the same modifications to the themes.xml (night) file.

Return to the activity_main.xml file or run the app once again to confirm that the user interface is now rendered using the custom theme colors.

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 custom theme and explained the steps to integrate the generated theme files into the legacy project.

An Android Material Design 3 Theming and Dynamic Color Tutorial

This chapter will show you how to create a new Material Design 3 theme using the Material Theme Builder tool and integrate it into an Android Studio project. The tutorial will also demonstrate how to add support for and test dynamic theme colors to an app.

Creating the ThemeDemo Project

Select the New Project option from the welcome screen and, within the resulting new project dialog, choose the Basic Activity (Material3) template before clicking on the Next button.

Enter ThemeDemo into the Name field and specify com.ebookfrenzy.themedemo as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Kotlin.

Preparing the Project

For this example, we will not be using most of the structure provided by the Basic Activity template. Since this is only a sample app, we can quickly modify it for our needs as follows:

  1. Open the res -> layout -> content_main.xml file, then select and delete the nav_host_fragment_content_main child of the ConstraintLayout entry in the Component Tree panel as shown in Figure 93-1:

Figure 93-1

  1. Edit the MainActivity.kt file and delete the following lines from the onCreate() method:
  val navController = findNavController(R.id.nav_host_fragment_content_main)
  appBarConfiguration = AppBarConfiguration(navController.graph)
  setupActionBarWithNavController(navController, appBarConfiguration)
  1. Remaining in the MainActivity.kt file, delete the onSupportNavigateUp() method.

Designing the User Interface

The main activity will consist of a simple layout containing a Button and the existing Floating Action Button. The layout will be designed within the content_main.xml file which currently contains a ConstraintLayout parent. Open this file in the layout editor and drag and drop a Button view so that it is positioned in the center of the layout canvas so that the layout resembles that shown to the left in Figure 93-2:

Figure 93-2

Compile and run the app on a device or emulator and note the default colors used on the Button and Floating Action Button. The next step is to build a new theme for our project.

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 circle in the Core 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 93-3

Review the color scheme in the Your Theme panel and make any necessary color adjustments using the Core Color options 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 Android Views (XML) 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 folders and files in a folder named material-theme:

  • values/colors.xml – The color definitions.
  • values/themes.xml – The theme for the light mode.
  • values-night/themes.xml – The theme for dark mode. Now that the theme files have been generated, they need to be integrated into the Android Studio project.

Adding the Custom Colors to the Project

The first step is to replace the current colors.xml file with the new one generated by the Material Theme Builder. Within the Project tool window, select the res -> values -> colors.xml file, tap the keyboard delete key and click on the OK button in the confirmation dialog to remove it from the project.

Open the filesystem navigation tool for your operating system, locate the colors.xml file in the values folder of the new material theme and copy and paste it into the values folder within the Project tool window.

Merging the Custom Themes

Now that we have added our custom theme colors to the project, the next step is modify the themes.xml files to reflect the new theme. The values -> themes -> themes.xml currently reads as follows:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Base.Theme.ThemeDemo" parent="Theme.Material3.DayNight.NoActionBar">
        <!-- Customize your light theme here. -->
        <!-- <item name="colorPrimary">@color/my_light_primary</item> -->
    </style>

    <style name="Theme.ThemeDemo" parent="Base.Theme.ThemeDemo" />
</resources>

We now need to edit this file and place our new theme item declarations in the area marked <!– Customize your light theme here. –>. Using the Android Studio File -> Open… menu option and open the themes.xml file located in the values folder of your custom theme folder. Once loaded, highlight and copy all of the content between the following elements:

<resources>
    <style name="AppTheme" parent="Theme.Material3.Light.NoActionBar">

          <!-- Copy all elements listed here -->

  </style>
</resources>

Return to the res -> values -> themes -> themes.xml file and paste the content in place of the <!– Customize your light theme here. –> comment lines as follows:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Base.Theme.ThemeDemo" parent="Theme.Material3.DayNight.NoActionBar">
        <item name="colorPrimary">@color/md_theme_light_primary</item>
        <item name="colorOnPrimary">@color/md_theme_light_onPrimary</item>
        <item name="colorPrimaryContainer">@color/md_theme_light_primaryContainer</item>
.
.
    </style>

    <style name="Theme.ThemeDemo" parent="Base.Theme.ThemeDemo" />
</resources>

If the colorInversePrimary and colorShadow items are displayed in red indicating they are unresolved, delete these lines from the file before continuing. This is caused by a mismatch between the Android implementation of MD3 and the resources generated by the Material Theme Builder

Repeat the above steps to merge the items from the themes.xml file located in the values-night custom theme folder into the themes.xml (night) file.

Run the app once again to confirm that the Button and Floating Action Button are rendered using the new theme colors.

Enabling Dynamic Color Support

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 93-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 93-4

A Material Design 3 Theming and Dynamic Color Tutorial To enable dynamic colors, we need to make a call to the applyToActivitiesIfAvailable() method of the DynamicColors class. To enable dynamic color support for the entire app, this needs to be called from within the onCreate() method of a custom Application instance. Begin by adding a new Kotlin class file to the project under app -> java -> com -> ebookfrenzy -> themedemo named ThemeDemoApplication.kt and modifying it so that it reads as follows:

package com.ebookfrenzy.themedemo
 
import android.app.Application
import com.google.android.material.color.DynamicColors
 
class ThemeDemoApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        DynamicColors.applyToActivitiesIfAvailable(this)
    }
}

With the custom Application class created, we now need to configure the project to use this class instead of the default Application instance. To do this, edit the AndroidManifest.xml file and add an android:name element referencing the new class:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.ebookfrenzy.themedemo">
 
    <application
        android:name=".ThemeDemoApplication"
        android:allowBackup="true"
.
.

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 made use of the Material Theme Builder to design a new theme and explained the steps to integrate the generated theme files into an Android Studio project. Finally, the chapter demonstrated how to implement and use the Material You dynamic colors feature of Android 12.

Working with Material Design 3 Theming in Android

The appearance of an Android app is intended to conform to 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 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 projects created with Android Studio Chipmunk default to Material Design 2 unless you select the Basic Activity (Material3) template when creating a new project. 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 complement the user’s wallpaper selection.

Understanding Material Design Theming

We know, of course, that Android app user interfaces are created by assembling components such as layouts, text fields, and buttons. All of these components appear using default colors unless we specifically override a color attribute either in the XML layout resource file or by writing code. These default colors are defined by the project’s theme. The theme consists of a set of color slots (declared in themes.xml files) to which are assigned color values (declared in the colors.xml file). Each UI component is programmed internally to use theme color slots as the default color for specific attributes (such as the foreground and background colors of the Text widget). It follows, therefore, that we can change the application-wide theme of an app simply by changing the colors assigned to specific theme slots. When the app runs, the new default colors will be used as the defaults for all of the widgets when the user interface is rendered.

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. The theme used by an application project is declared as a property of the application element within the AndroidManifest.xml file, for example:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.ebookfrenzy.themedemo">
 
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyDemoApp">
        <activity
.
.

As previously discussed, all of the files associated with the project theme are contained within the colors.xml and themes.xml files located in the res -> values folder as shown in Figure 92-1:

Figure 92-1

The theme itself is declared in the two themes.xml files located in the themes folder. These resource files declare different color palettes containing Material Theme color slots for use when the device is in light or dark mode. Note that the style name property in each file must match that referenced in the AndroidManifest.xml file:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.MyDemoApp" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryVariant">@color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
.
.

These color slots (also referred to as color attributes) are used by the Material components to set colors when they are rendered on the screen. For example, the colorPrimary color slot is used as the background color for 758

Working with Material Design 3 Theming the Material Button component. The actual colors assigned to the slots are declared in the colors.xml resource file as follows:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="purple_200">#FFBB86FC</color>
    <color name="purple_500">#FF6200EE</color>
    <color name="purple_700">#FF3700B3</color>
    <color name="teal_200">#FF03DAC5</color>
    <color name="teal_700">#FF018786</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>
</resources>

The colorPrimary slot, for example, is assigned the color purple_500 which, in turn, is assigned an RGB color value of FF6200EE.

Creating a custom theme simply involves editing these files to use different color settings. These changes will then be used by the Material components as the default colors.

Material Design 3 Theming

Material Design 3 (MD3) still uses colors.xml and themes.xml files but provides a broader range of color slots to be used by the MD3 components. In fact, MD3 provides over 25 color slots for use when customizing an app theme. The color names are also different in that they describe the purpose of the color rather than the color itself. In Material Design 2, for example, we had a color named purple_500:

<color name="purple_500">#FF6200EE</color>

This, in turn, was used for the colorPrimary theme slot in the light mode themes.xml file:

<item name="colorPrimary">@color/purple_500</item>

Suppose that we need to change colorPrimary from purple to red. We could, of course, just assign the RGB value for a shade of red to the purple_500 color declaration. Unfortunately, we now have a red color declaration with a name that suggests it is purple. The only option is to add a new entry to the colors.xml file for the red color and then assign it to the colorPrimary slot in the themes.xml file. This works, but to achieve this we had to edit both the color and theme files. In MD3, color names do not reference the actual color they represent. The colorPrimary slot in an MD3 theme file is instead declared as follows:

<item name="colorPrimary">@color/md_theme_light_primary</item>

This color is then declared in the colors.xml file:

<color name="md_theme_light_primary">#B82000</color>

We can now change the RGB color value for this color while leaving the themes.xml files unchanged and without having to create a new color element in the colors.xml file.

In addition, elements within MD3 themes.xml files are based on Base.Theme.Material3.* instead of Theme. MaterialComponents.*. The following, for example, is an excerpt from an MD3 themes.xml file:

<resources>
    <style name="Theme.MyAppDemo" parent="Base.Theme.Material3.Dark.NoActionBar">
        <item name="colorPrimary">@color/md_theme_dark_primary</item>
        <item name="colorOnPrimary">@color/md_theme_dark_onPrimary</item>
        <item name="colorPrimaryContainer">@color/md_theme_dark_primaryContainer</item>
        <item name="colorOnPrimaryContainer">@color/md_theme_dark_onPrimaryContainer</item>
        <item name="colorSecondary">@color/md_theme_dark_secondary</item>
.
.
   </style>
   <style name="Theme.ThemeDemo" parent="Base.Theme.ThemeDemo" />
</resources>

Color slots in MD3 are grouped as Primary, Secondary, Tertiary, Error, Background, and Surface. These slots are further divided into pairs consisting of a base color and an “on” base color. This generally translates to the background and foreground colors of a Material component.

The particular group used for coloring will differ between widgets. A Material Button widget, for example, will use the colorPrimary base color for the background color and colorOnPrimary for its content (i.e. the text or icon it displays). The FloatingActionButton component, on the other hand, uses colorPrimaryContainer as the background color and colorOnPrimaryContainer for the foreground. The correct group for a specific widget type can usually be identified quickly by changing color settings in the theme files and reviewing the rendering in the layout editor.

The biggest difference between MD2 and MD3 is support for dynamic colors and Material You, both of which will be covered in the next chapter entitled “A Material Design 3 Theming and Dynamic Color Tutorial”. 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 92-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 circles (D) and selecting different colors from the color selection panel:

Figure 92-2

To incorporate the theme into your design, click on the Export button (E) and select the Android View (XML) option. Once downloaded, the colors.xml and themes.xml files can be used to replace the existing files in your project. Note that the theme name in the two exported themes.xml files 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 You 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.

An Android 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 within a ViewModel. 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 RecyclerView located in the user interface layout of the main fragment. 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. Code within the main fragment will collect the values from the flow and list them in the RecyclerView. The project will then be modified to suspend the collection process while the app is placed in the background.

Creating the SharedFlowDemo Project

Begin by launching Android Studio, selecting the New Project option from the welcome screen and, within the resulting new project dialog, choosing the Fragment + ViewModel template before clicking on the Next button.

Enter SharedFlowDemo into the Name field and specify com.ebookfrenzy.sharedflowdemo as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Kotlin. Edit the build.gradle (Module: SharedFlowDemo.app) file to enable view binding:

android {
 
    buildFeatures {
        viewBinding true
    }
.
.

Remaining in the build.gradle file, add the Kotlin lifecycle library to the dependencies section as follows before clicking on the Sync Now link at the top of the editor panel:

dependencies {
.
.
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"
.
.
}

Designing the User Interface Layout

Locate the res -> layout -> fragment_main.xml file, load it into the layout editor, and delete the default TextView component. From the Containers section of the widget palette drag and drop a RecyclerView onto the center of the layout canvas. Add constraints so that the view fills the entire canvas and each side is attached to the corresponding side of the parent container. With the RecyclerView selected, refer to the Attributes tool window and change the id to recyclerView if it does not already have this id.

Adding the List Row Layout

We now need to add a layout resource file containing a TextView to be used for each row in the list. Add this file now by right-clicking on the app -> res -> layout entry in the Project tool window and selecting the New -> Layout resource file menu option. Name the file list_row and change the root element to LinearLayout before clicking on OK to create the file and load it into the layout editor. With the layout editor in Design mode, drag a TextView object from the palette onto the layout where it will appear by default at the top of the layout:

Figure 70-1

With the TextView selected in the layout, use the Attributes tool window to set the id of the view to itemText, the layout_height to 50dp, and the textSize attribute to 20sp. With the text view still selected, unfold the gravity settings and set center to true and all other values to false:

Figure 70-2

Select the LinearLayout entry in the Component Tree window and set the layout_height attribute to wrap_ content.

Adding the RecyclerView Adapter

Add the RecyclerView adapter class to the project by right-clicking on the app -> java -> com -> ebookfrenzy -> sharedflowdemo -> ui.main entry in the Project tool window and selecting the New -> Kotlin File/Class… menu. In the dialog, name the class ListAdapter and choose Class from the list before pressing the keyboard Return key. With the resulting ListAdapter.kt class file loaded into the editor, implement the class as follows:

package com.ebookfrenzy.sharedflowdemo.ui.main
 
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.NonNull
import androidx.recyclerview.widget.RecyclerView
import com.ebookfrenzy.sharedflowdemo.R
 
class ListAdapter(private var itemsList: List<String>) :
    RecyclerView.Adapter<ListAdapter.MyViewHolder>() {
    
    class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        var itemText: TextView = view.findViewById(R.id.itemText)
    }
    
    @NonNull
    override fun onCreateViewHolder(parent: ViewGroup, 
                                  viewType: Int): MyViewHolder {
        val itemView = LayoutInflater.from(parent.context)
            .inflate(R.layout.list_row, parent, false)
        return MyViewHolder(itemView)
    }
  
    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        val item = itemsList[position]
        holder.itemText.text = item
    }
  
    override fun getItemCount(): Int {
        return itemsList.size
    }
}

Completing the ViewModel

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

package com.ebookfrenzy.sharedflowdemo.ui.main
 
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 MainViewModel : ViewModel() {
 
    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. Before adding this code, we first need to declare the flow as follows:

.
.
class MainViewModel : ViewModel() {
 
    private val _sharedFlow = MutableSharedFlow<Int>()
    val sharedFlow = _sharedFlow.asSharedFlow()
.
.

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

Modifying the Main Fragment for View Binding

Before we start to work on collecting values from the flow, we first need to make some changes to the MainFragment.kt file to add support for view binding. Open the file and modify it so that it reads as follows:

.
.
import com.ebookfrenzy.sharedflowdemo.databinding.FragmentMainBinding
 
class MainFragment : Fragment() {
 
    companion object {
        fun newInstance() = MainFragment()
    }
 
    private lateinit var viewModel: MainViewModel
    private var _binding: FragmentMainBinding? = null
    private val binding get() = _binding!!
 
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return inflater.inflate(R.layout.fragment_main, container, false)
        _binding = FragmentMainBinding.inflate(inflater, container, false)
        return binding.root
    }
 
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
.
.

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 the RecyclerView list. The intention is for collection to start automatically when the app launches so this code will be placed in the onActvityCreated() method of the MainFragment.kt file.

Start by adding some variables to store a reference to our list adapter and the array of items to be displayed in the RecyclerView. Now is also a good time to add the imports we will need to complete the app:

.
.
import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
.
.
class MainFragment : Fragment() {
 
    companion object {
        fun newInstance() = MainFragment()
    }
 
    private lateinit var viewModel: MainViewModel
    private var _binding: FragmentMainBinding? = null
    private val binding get() = _binding!!
    private val itemList = ArrayList<String>()
    private lateinit var listAdapter: ListAdapter
.
.

Within the onActvityCreated() method, add some code to create a list adapter instance and assign it to the RecyclerView. We also need to configure the RecyclerView to use a LinearLayout manager:

override fun onActivityCreated(savedInstanceState: Bundle?) {
    super.onActivityCreated(savedInstanceState)
    viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
 
    listAdapter = ListAdapter(itemList)
    val layoutManager = LinearLayoutManager(context)
    binding.recyclerView.layoutManager = layoutManager
    binding.recyclerView.adapter = listAdapter
}

With these changes made we are ready to collect the values emitted by the shared flow and add them to the RecyclerView. Add code to the onActivityCreated() method so that it now reads as follows:

override fun onActivityCreated(savedInstanceState: Bundle?) {
.
.
    lifecycleScope.launch {
        viewModel.sharedFlow.collect() { value ->
            itemList.add(value.toString())
            listAdapter.notifyItemInserted(itemList.lastIndex)
            binding.recyclerView.smoothScrollToPosition(listAdapter.itemCount)
         }
    }
}

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 itemList array that was previously used when the ListAdapter was initialized. We then notify the adapter that a new item has been added to the end of the list. This will cause the RecyclerView to update so that the new value appears in the list. We have also added code to instruct the RecyclerView to scroll smoothly to the last position in the list so that the most recent values are automatically visible.

Testing the SharedFlowDemo App

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

Figure 70-3

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 RecyclerView. 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(1100)
            println("Emitting $i")
            _sharedFlow.emit(i)
        }

    }
}

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

.
.
    lifecycleScope.launch {
        viewModel.sharedFlow.collect() { value ->
            println("Collecting $value")
            itemList.add(value.toString())
            listAdapter.notifyDataSetChanged()
            binding.recyclerView.smoothScrollToPosition(listAdapter.itemCount)
         }
    }
.
.

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 Logcat panel:

Emitting1
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 (a topic covered previously in the “Working with Android Lifecycle-Aware Components” chapter):

  • 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.START is reached and to stop when the lifecycle is suspended. To implement this, modify the collection code as follows:

lifecycleScope.launch {
   repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.sharedFlow.collect() { value ->
            println("Collecting $value")
            itemList.add(value.toString())
            listAdapter.notifyDataSetChanged()
            binding.recyclerView.smoothScrollToPosition(listAdapter.itemCount)
        }
   }
}

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 fragment 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 fragment 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 Code Examples

The earlier chapter titled “An Introduction to Kotlin Coroutines” taught us about Kotlin Coroutines and explained how they can be used to perform multiple tasks concurrently without blocking the main thread. As we have seen, coroutine suspend functions are ideal for performing tasks that return a single result 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 (An Android SharedFlow Tutorial), we will look more closely at using SharedFlow within the context of Android app development

Understanding Kotlin 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 69-1 illustrates the typical structure of a Kotlin flow:

Figure 69-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.

Creating the Sample Kotlin Flow Project

Select the New Project option from the Android Studio welcome screen and, within the resulting new project dialog, choose the Empty Activity template before clicking on the Next button.

Enter FlowDemo into the Name field and specify com.ebookfrenzy.flowdemo as the package name. Before clicking on the Finish button, change the Minimum API level setting to API 26: Android 8.0 (Oreo) and the Language menu to Kotlin.

Once the new project has been created, locate and load the activity_main.xml layout file located in the Project tool window under app -> res -> layout and, with the Layout Editor tool in Design mode, replace the TextView object with a Button view and set the text property so that it reads “Start”. Once the text value has been set, follow the usual steps to extract the string to a resource.

With the button still selected in the layout, locate the onClick property in the Attributes panel and configure it to call a method named handleFlow.

Adding the Kotlin Lifecycle Library

Kotlin flow requires that the Kotlin extensions lifecycle library is included as a dependency, so edit the build.gradle (Module: FlowDemo.app) file and add the library to the dependencies section as follows:

dependencies {
.
.
    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.

Declaring a 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 MainActivity.kt file to declare a flow named myFlow designed to emit a stream of integer values:

.
.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
.
.
fun 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 three-second delay is performed on each loop iteration:

fun myFlow(): Flow<Int> = flow {
    var counter = 1
    
    while (counter < 6) {
        emit(counter)
        counter++
        delay(2000)
    }
}

Collecting Flow Data

The streaming data within a flow can be collected by a consumer by calling the collect() method on the flow instance. This will continue to collect data from the stream either until the stream ends or the lifecycle scope in which the collection is being performed is destroyed. For example, we can collect the data from the myFlow stream and output each value by adding the handleFlow() onClick function:

.
.
import android.view.View
.
.
fun handleFlow(view: View) {
    lifecycleScope.launch {
        myFlow().collect() { value ->
            println("Collected value = $value")
        }
    }
}

Note that collect() is a suspend function so must be called from within a coroutine scope.

Compile and run the app on a device or emulator, display the Logcat tool window and enter System.out into the search bar as shown in Figure 69-2. This will filter the log output to display only that generated by the println() statement:

Figure 69-2

When the Start button is clicked in the running app, the following output should appear with a two-second delay between each output:

Collected value = 1
Collected value = 2
Collected value = 3
Collected value = 4
Collected value = 5

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

fun handleFlow(view: View) {
    lifecycleScope.launch {
        try {
            myFlow().collect() { value ->
                println("Collected value = $value")
            }
        } finally {
            println("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 as follows:

fun handleFlow(view: View) {
    lifecycleScope.launch {
        myFlow().collect() { value ->
            println("Collected value = $value")
            delay(2500)
        }
    }
}

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 generally only 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 myFlow producer, the collection process in our consumer takes an additional second to complete. We can simulate this behavior as follows:

.
.
import kotlin.system.measureTimeMillis
.
.
fun handleFlow(view: View) {
    lifecycleScope.launch {
        val elapsedTime = measureTimeMillis {
            myFlow()
                .collect() { value ->
                    println("Collected value = $value")
                    delay(1000)
                }
        }
        println("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. After execution completes, a duration similar to the following will be reported:

Duration = 15024

This accounts for approximately ten seconds to process the five values within myFlow and an additional five 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:

val elapsedTime = measureTimeMillis {
    myFlow()
        .buffer()
        .collect() { value ->
        println("Collected value = $value")
        delay(1000)
    }
}
println("Duration = $elapsedTime")

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

Duration = 10323

Transforming Data with Intermediaries

All of the examples we have looked at so far in this chapter have 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:

fun handleFlow(view: View) {
    lifecycleScope.launch {
        myFlow()
            .map {
                "Collected value = $it"
            }
            .collect() {
                println(it)
            }
        }
    }
}

When executed, this will give us the following output:

Collected value = 1
Collected value = 2
Collected value = 3
Collected value = 4
Collected value = 5

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):

fun handleFlow(view: View) {
    lifecycleScope.launch {
        myFlow()
            .filter {
                it % 2 == 0
            }
            .map {
                "Collected value $it"
            }
            .collect() {
                println(it)
            }
        }
    }
}

The above changes will generate the following output:

Collected value = 2
Collected value = 4

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 as demonstrated below:

.
.
myFlow()
    .transform {
        emit("Value = $it")
        var doubled = it * 2
        emit("Value doubled = $doubled")
    }
    .collect {
        println(it)
    }
}
.
.
// Output
Value = 1
Value doubled = 2
Value = 2
Value doubled = 4
Value = 3
Value doubled = 6
Value = 4
Value doubled = 8
Value = 5
Value doubled = 10

Terminal Flow Operators

All of the collection operators covered previously are referred to as 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):

.
.
myFlow()
    .reduce { accumulator, value ->
        println("accumulator = $accumulator, value = $value")
        accumulator + value
    }
}
.
.
// Output
accumulator = 1, value = 2
accumulator = 3, value = 3
accumulator = 6, value = 4
accumulator = 10, value = 5

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

.
.
myFlow()
    .fold(10) { accumulator, value ->
        println("accumulator = $accumulator, value = $value")
        accumulator * value
    }
}
.
.
// Output
accumulator = 10, value = 1
accumulator = 10, value = 2
accumulator = 20, value = 3
accumulator = 60, value = 4
accumulator = 240, value = 5

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 such situations, these streams can be flattened into a single stream. Consider the following example code which declares two flows:

fun myFlow(): Flow<Int> = flow {
    for (i in 1..5) {
        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:

.
.
myFlow()
    .flatMapConcat { doubleIt(it) }
    .collect { println(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:

myFlow()
    .flatMapMerge { doubleIt(it) }
    .collect { println(it) }
}

When executed, the following output will appear:

1
2
3
4
5
2
4
6
8
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:

fun handleFlow(view: View) {
    lifecycleScope.launch {
        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 { println(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 { println(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 beginning with the chapter titled “Modern Android App Architecture with Jetpack”).

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 in the usual ways, though it is generally recommended to collect from StateFlow using the collectLatest() operator, for example:

stateFlow.collectLatest { println("Counter = $it") }

To try out this example, make the following modifications to the MainActivity.kt file:

.
.
class MainActivity : AppCompatActivity() {
 
    private val _stateFlow = MutableStateFlow(0)
    val stateFlow = _stateFlow.asStateFlow()
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
 
        lifecycleScope.launch {
            stateFlow.collectLatest {
                println("Counter = $it")
            }
        }
    }
 
    fun handleFlow(view: View) {
        _stateFlow.value += 1
    }
}

Run the app and verify that the Start button outputs the incremented counter 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() to obtain a SharedFlow reference:

.
.
import kotlinx.coroutines.channels.BufferOverflow
.
.
class MainActivity : AppCompatActivity() {
 
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:

fun handleFlow(view: View) {
 
    var counter = 1
 
    lifecycleScope.launch {
        while (counter < 6) {
            _sharedFlow.emit(counter)
            counter++
            delay(2000)
        }
    }
}

Once the flow is active, subscribers can collect values using the usual techniques on the SharedFlow instance. For example, we can add the following collection code to the onCreate() method of our example project to output the flow values:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
 
    lifecycleScope.launch {
        sharedFlow.collect {
            println("$it")
        }
    }
}

Also, the current number of subscribers to a SharedFlow stream can be obtained via the subscriptionCount property of the mutable instance:

val subCount = _sharedFlow.subscriptionCount

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 sequential 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.