Creating a Tabbed Interface using the TabLayout Component in Java

The previous chapter outlined the concept of material design in Android. It introduced two of the components provided by the design support library in the form of the floating action button and the Snackbar. This chapter will demonstrate how to use another of the design library components, the TabLayout, which can be combined with the ViewPager class to create a tab-based interface within an Android activity.

An Introduction to the ViewPager2

Although not part of the design support library, ViewPager2 is a useful companion class when used with the TabLayout component to implement a tabbed user interface. The primary role of ViewPager2 is to allow the user to flip through different pages of information where a layout fragment most typically represents each page. The fragments associated with ViewPager2 are managed by an instance of the FragmentStateAdapter class.

At a minimum, the pager adapter assigned to ViewPager2 must implement two methods. The first, named getItemCount(), must return the total number of page fragments to be displayed to the user. The second method, createFragment(), is passed a page number and must return the corresponding fragment object ready to be presented to the user.

An Overview of the TabLayout Component

As previously discussed, TabLayout is one of the components introduced in material design and is included in the design support library. The purpose of the TabLayout is to present the user with a row of tabs that can be selected to display different pages to the user. The tabs can be fixed or scrollable, whereby the user can swipe left or right to view more tabs than will currently fit on the display. The information displayed on a tab can be text-based, an image, or a combination of text and images. Figure 46-1, for example, shows the tab bar for an app consisting of four tabs displaying images:

Figure 46-1

Figure 46-2, on the other hand, shows a TabLayout configuration consisting of four tabs displaying text in a scrollable configuration:

Figure 46-2

The remainder of this chapter will work through creating an example project that demonstrates the TabLayout component together with a ViewPager2 and four fragments.

Creating the TabLayoutDemo Project

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

Enter TabLayoutDemo into the Name field and specify com.ebookfrenzy.tablayoutdemo 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 Java.

Once the project has been created, load the content_main.xml file into the Layout Editor tool, select the NavHostFragment object, and then delete it. Since we will not be using the navigation features of the Basic Views Activity template, edit the MainActivity.java file and modify the onCreate() method to remove the navigation code:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
 
    binding = ActivityMainBinding.inflate(getLayoutInflater());
    setContentView(binding.getRoot());
 
    setSupportActionBar(binding.toolbar);
 
    // NavController navController = 
    //    Navigation.findNavController(this, R.id.nav_host_fragment_content_main);
    // appBarConfiguration = 
    //    new AppBarConfiguration.Builder(navController.getGraph()).build();
    // NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration);
 
    binding.fab.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View view) {
            Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                    .setAction("Action", null).show();
        }
    });
}Code language: Java (java)

Finally, delete the onSupportNavigateUp() method.

Creating the First Fragment

Each tab on the TabLayout will display a different fragment when selected. Create the first of these fragments by right-clicking on the app -> java -> com.ebookfrenzy.tablayoutdemo entry in the Project tool window and selecting the New -> Fragment -> Fragment (Blank) option. In the resulting dialog, enter Tab1Fragment into the Fragment Name: field and fragment_tab1 into the Fragment Layout Name: field. Click on the Finish button to create the new fragment:

Figure 46-3

Edit the Tab1Fragment.java file, and if Android Studio has not added one automatically, add an OnFragmentInteractionListener interface declaration as follows:

.
.
import android.net.Uri;
.
.
    public interface OnFragmentInteractionListener {
        // TODO: Update argument type and name
        void onFragmentInteraction(Uri uri);
    }
.
.Code language: Java (java)

Load the newly created fragment_tab1.xml file (located under app -> res -> layout) into the Layout Editor tool, right-click on the FrameLayout entry in the Component Tree panel, and select the Convert FrameLayout to ConstraintLayout menu option. In the resulting dialog, verify that all conversion options are selected before clicking on OK. Change the ID of the layout to constraintLayout.

Once the layout has been converted to a ConstraintLayout, delete the TextView from the layout. From the Palette, locate the TextView widget and drag and drop it so it is positioned in the center of the layout. Edit the object’s text property to read “Tab 1 Fragment”, extract the string to a resource named tab_1_fragment, and click the Infer Constraints toolbar button. At this point, the layout should match that of Figure 46-4:

Figure 46-4

Duplicating the Fragments

So far, the project contains one of the four required fragments. It would be quicker to duplicate the first fragment instead of creating the remaining three fragments using the previous steps. Each fragment consists of a layout XML file and a Java class file, each needing to be duplicated.

Right-click on the fragment_tab1.xml file in the Project tool window and select the Copy option from the resulting menu. Right-click on the layout entry, this time selecting the Paste option. Name the new layout file fragment_tab2.xml in the resulting dialog before clicking the OK button. Edit the new fragment_tab2.xml file and change the text on the Text View to “Tab 2 Fragment”, following the usual steps to extract the string to a resource named tab_2_fragment.

To duplicate the Tab1Fragment class file, right-click on the class listed under app -> java -> com.ebookfrenzy. tablayoutdemo and select Copy. Right-click on the com.ebookfrenzy.tablayoutdemo entry and select Paste. In the Copy Class dialog, enter Tab2Fragment into the New name: field and click OK.

Edit the new Tab2Fragment.java file and modify the onCreateView() method to inflate the fragment_tab2 layout file:

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                         Bundle savedInstanceState) {
    // Inflate the layout for this fragment
    return inflater.inflate(R.layout.fragment_tab2, container, false);
}Code language: Java (java)

Perform the above duplication steps twice to create the fragment layout and class files for the remaining two fragments. On completion of these steps, the project structure should match that of Figure 46-5:

Figure 46-5

Adding the TabLayout and ViewPager2

With the fragment creation process now complete, the next step is to add the TabLayout and ViewPager2 to the main activity layout file. Edit the activity_main.xml file and add these elements as outlined in the following XML listing. Note that the TabLayout component is embedded into the AppBarLayout element while the ViewPager2 is placed after the AppBarLayout:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
 
    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/Theme.TabLayoutDemo.AppBarOverlay">
 
        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme="@style/Theme.TabLayoutDemo.PopupOverlay" />
 
        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tabLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:tabMode="fixed"
            app:tabGravity="fill"/>
        
    </com.google.android.material.appbar.AppBarLayout>
 
    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/view_pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />
    
    <include layout="@layout/content_main" />
 
    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_marginEnd="@dimen/fab_margin"
        android:layout_marginBottom="16dp"
        app:srcCompat="@android:drawable/ic_dialog_email" />
 
</androidx.coordinatorlayout.widget.CoordinatorLayout>Code language: HTML, XML (xml)

Creating the Pager Adapter

This example will use the ViewPager2 approach to handling the fragments assigned to the TabLayout tabs, with ViewPager2 added to the layout resource file, a new class which subclasses FragmentStateAdapter needs to be added to the project to manage the fragments that will be displayed when the user selects the tab items.

Add a new class to the project by right-clicking on the com.ebookfrenzy.tablayoutdemo entry in the Project tool window and selecting the New -> Java Class menu option. In the new class dialog, enter TabPagerAdapter into the Name: field, select the Class item in the list, and press the keyboard Return key. Edit the TabPagerAdapter.java file so that it reads as follows:

package com.ebookfrenzy.tablayoutdemo;
 
import androidx.annotation.NonNull;
import androidx.fragment.app.*;
import androidx.viewpager2.adapter.FragmentStateAdapter;
 
public class TabPagerAdapter extends FragmentStateAdapter {
 
    int tabCount;
 
    public TabPagerAdapter(@NonNull FragmentActivity fragmentActivity, int numberOfTabs) {
        super(fragmentActivity);
        this.tabCount = numberOfTabs;
    }
 
    @NonNull
    @Override
    public Fragment createFragment(int position) {
        switch (position) {
            case 0:
                return new Tab1Fragment();
            case 1:
                return new Tab2Fragment();
            case 2:
                return new Tab3Fragment();
            case 3:
                return new Tab4Fragment();
            default:
                return null;
        }
    }
 
    @Override
    public int getItemCount() {
        return tabCount;
    }
}Code language: Java (java)

The class is declared as extending the FragmentStateAdapter class, and a constructor is implemented, allowing the number of pages required to be passed to the class when an instance is created. The createFragment() method will be called when a specific page is required. A switch statement is used to identify the page number being requested and to return a corresponding fragment instance. Finally, the getItemCount() method returns the count value passed through when the object instance was created.

Performing the Initialization Tasks

The remaining tasks involve initializing the TabLayout, ViewPager2, and TabPagerAdapter instances and declaring the main activity class as implementing fragment interaction listeners for each of the four tab fragments. Edit the MainActivity.java file so that it reads as follows:

package com.ebookfrenzy.tablayoutdemo;
.
.
import android.net.Uri; 
import com.google.android.material.tabs.TabLayoutMediator;
.
.
public class MainActivity extends AppCompatActivity implements 
	 Tab1Fragment.OnFragmentInteractionListener, 
        Tab2Fragment.OnFragmentInteractionListener, 
        Tab3Fragment.OnFragmentInteractionListener, 
        Tab4Fragment.OnFragmentInteractionListener {
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
.
.
        configureTabLayout();
    } 
 
    protected void configureTabLayout() {
 
        for (int i = 0; i < 4; i++) {
            binding.tabLayout.addTab(binding.tabLayout.newTab());
        }
 
        final TabPagerAdapter adapter = new TabPagerAdapter
                (this, binding.tabLayout.getTabCount());
        binding.viewPager.setAdapter(adapter);
 
        new TabLayoutMediator(binding.tabLayout, binding.viewPager,
                (tab, position) -> tab.setText("Tab " + (position + 1) + 
                                " Item")).attach();
    }
 
   @Override
   public void onFragmentInteraction(Uri uri) {
   }
.
.
}Code language: Java (java)

The code begins by creating four tabs and adding them to the TabLayout instance as follows:

for (int i = 0; i < 4; i++) {
    binding.tabLayout.addTab(binding.tabLayout.newTab());
}Code language: Java (java)

Next, an instance of the TabPagerAdapter class is created. Note that the code to create the TabPagerAdapter instance passes through the number of tabs assigned to the TabLayout component. The TabPagerAdapter instance is then assigned as the adapter for the ViewPager2 instance:

final TabPagerAdapter adapter = new TabPagerAdapter
        (this, binding.tabLayout.getTabCount());
binding.viewPager.setAdapter(adapter);Code language: Java (java)

Finally, an instance of the TabLayoutMediator class is used to connect the TabLayout with the ViewPager2 object:

new TabLayoutMediator(binding.tabLayout, binding.viewPager,
        (tab, position) -> tab.setText("Tab " + (position + 1) + " Item")).attach();Code language: Java (java)

This class ensures that the TabLayout tabs remain synchronized with the currently selected fragment. This process involves ensuring that the correct text is displayed on each tab. In this case, the text is configured to read “Tab <n> Item” where <n> is replaced by the number of the currently selected tab.

Testing the Application

Compile and run the app on a device or emulator and make sure that selecting a tab causes the corresponding fragment to appear in the content area of the screen:

Figure 46-6

When building the project, you may encounter an error that reads in part:

Duplicate class kotlin.collections.jdk8.CollectionsJDK8Kt found in modules kotlin-stdlibCode language: plaintext (plaintext)

This error is caused by a bug in the Android Studio build toolchain and can be resolved by making the following changes to the build.gradle.kts (Module: app) file:

dependencies {
.
.
    implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0"))
.
.
}Code language: Gradle (gradle)

Customizing the TabLayout

The TabLayout in this example project is configured using fixed mode. This mode works well for a limited number of tabs with short titles. A greater number of tabs or longer titles can quickly become a problem when using fixed mode, as illustrated by Figure 46-7:

Figure 46-7

To fit the tabs into the available display width, the TabLayout has used multiple lines of text. Even so, the second line is truncated, making it impossible to see the full title. The best solution to this problem is to switch the TabLayout to scrollable mode. In this mode, the titles appear in full-length, single-line format allowing the user to swipe to scroll horizontally through the available items, as demonstrated in Figure 46-8:

Figure 46-8

To switch a TabLayout to scrollable mode, change the app:tabMode property in the activity_main.xml layout resource file from “fixed” to “scrollable”:

<android.support.design.widget.TabLayout
    android:id="@+id/tabLayout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabMode="scrollable"
    app:tabGravity="fill"/>
</android.support.design.widget.AppBarLayout>Code language: HTML, XML (xml)

When in fixed mode, the TabLayout may be configured to control how the tab items are displayed to take up the available space on the screen. This is controlled via the app:tabGravity property, the results of which are more noticeable on wider displays such as tablets in landscape orientation. When set to “fill”, for example, the items will be distributed evenly across the width of the TabLayout, as shown in Figure 46-9:

Figure 46-9

Changing the property value to “center” will cause the items to be positioned relative to the center of the tab bar:

Figure 46-10

Summary

TabLayout is one of the components introduced in the Android material design implementation. The purpose of the TabLayout component is to present a series of tab items that display different content to the user when selected. The tab items can display text, images, or a combination. When combined with the ViewPager2 class and fragments, tab layouts can be created relatively easily, with each tab item selection displaying a different fragment.

An Android Floating Action Button and Snackbar Java Tutorial

One of the objectives of this chapter is to provide an overview of the concepts of material design. Originally introduced as part of Android 5.0, material design is a set of design guidelines that dictate how the Android user interface, and that of the apps running on Android, appear and behave.

As part of implementing the material design concepts, Google also introduced the Android Design Support Library. This library contains several components that allow many of the key features of material design to be built into Android applications. Two of these components, the floating action button and the Snackbar, will also be covered in this chapter before introducing many of the other components in subsequent chapters.

The Material Design

The principles of material design define the overall appearance of the Android environment. Material design was created by the Android team at Google and dictates that the elements that make up the user interface of Android and the apps that run on it appear and behave in a certain way in terms of behavior, shadowing, animation, and style. One of the tenets of the material design is that the elements of a user interface appear to have physical depth and a sense that items are constructed in layers of physical material. A button, for example, appears to be raised above the surface of the layout where it resides through shadowing effects. Pressing the button causes the button to flex and lift as though made of a thin material that ripples when released.

Material design also dictates the layout and behavior of many standard user interface elements. A key example is how the app bar located at the top of the screen should appear and how it should behave in relation to scrolling activities taking place within the main content of the activity.

Material design covers a wide range of areas, from recommended color styles to how objects are animated. A full description of the material design concepts and guidelines can be found online at the following link and is recommended reading for all Android developers:

https://material.io/design/introduction

The Design Library

Many of the building blocks needed to implement Android applications that adopt material design principles are contained within the Android Design Support Library. This library contains a collection of user interface components that can be included in Android applications to implement much of the look, feel, and behavior of material design. Two of the components from this library, the floating action button and Snackbar, will be covered in this chapter, while others will be introduced in later chapters.

The Floating Action Button (FAB)

The floating action button appears to float above the surface of the user interface of an app. It generally promotes the most common action within a user interface screen. A floating action button could be placed on a screen to allow the user to add an entry to a list of contacts or to send an email from within the app. Figure 45-1, for example, highlights the floating action button that allows the user to add a new contact within the standard Android Contacts app:

Figure 45-1

Several rules should be followed when using floating action buttons to conform with the material design guidelines. Floating action buttons must be circular and can be either 56 x 56dp (Default) or 40 x 40dp (Mini) in size. The button should be positioned a minimum of 16dp from the edge of the screen on phones and 24dp on desktops and tablet devices. Regardless of the size, the button must contain an interior icon that is 24x24dp in size, and it is recommended that each user interface screen have only one floating action button.

Floating action buttons can be animated or designed to morph into other items when touched. For example, a floating action button could rotate when tapped or morph into another element, such as a toolbar or panel listing related actions.

The Snackbar

The Snackbar component provides a way to present the user with information as a panel at the bottom of the screen, as shown in Figure 45-2. Snackbar instances contain a brief text message and an optional action button that will perform a task when tapped by the user. Once displayed, a Snackbar will either timeout automatically or can be removed manually by the user via a swiping action. During the appearance of the Snackbar, the app will continue to function and respond to user interactions normally.

Figure 45-2

In the remainder of this chapter, an example application will be created that uses the basic features of the floating action button and Snackbar to add entries to a list of items.

Creating the Example Project

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

Enter FabExample into the Name field and specify com.ebookfrenzy.fabexample 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 Java.

Reviewing the Project

Since the Basic Views Activity template was selected, the activity contains four layout files. The activity_main. xml file consists of a CoordinatorLayout manager containing entries for an app bar, a Material toolbar, and a floating action button.

The content_main.xml file represents the layout of the content area of the activity and contains a NavHostFragment instance. This file is embedded into the activity_main.xml file via the following include directive:

<include layout="@layout/content_main" />Code language: HTML, XML (xml)

The floating action button element within the activity_main.xml file reads as follows:

<com.google.android.material.floatingactionbutton.FloatingActionButton
    android:id="@+id/fab"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom|end"
    android:layout_marginEnd="@dimen/fab_margin"
    android:layout_marginBottom="16dp"
    app:srcCompat="@android:drawable/ic_dialog_email" />Code language: HTML, XML (xml)

This declares that the button is to appear in the bottom right-hand corner of the screen with margins represented by the fab_margin identifier in the values/dimens.xml file (which, in this case, is set to 16dp). The XML further declares that the interior icon for the button is to take the form of the standard drawable built-in email icon.

The blank template has also configured the floating action button to display a Snackbar instance when tapped by the user. The code to implement this can be found in the onCreate() method of the MainActivity.java file and reads as follows:

binding.fab.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                .setAction("Action", null).show();
    }
});Code language: Java (java)

The code accesses the floating action button via the view binding and adds an onClickListener handler to be called when the button is tapped. This method displays a Snackbar instance configured with a message but no actions.

When the project is compiled and run, the floating action button will appear at the bottom of the screen, as shown in Figure 45-3:

Figure 45-3

Tapping the floating action button will trigger the onClickListener handler method causing the Snackbar to appear at the bottom of the screen:

Figure 45-4

Removing Navigation Features

As “A Guide to the Android Studio Layout Editor Tool” outlines, the Basic Views Activity template contains multiple fragments and buttons to navigate from one fragment to the other. These features are unnecessary for this tutorial and will cause problems later if not removed. Before moving ahead with the tutorial, modify the project as follows:

  1. Within the Project tool window, navigate to and double-click on the app -> res -> navigation -> nav_graph.xml file to load it into the navigation editor.
  1. Select the SecondFragment entry in the Component Tree panel within the editor and tap the keyboard delete key to remove it from the graph.
  2. Locate and delete the SecondFragment.java (app -> java -> <package name> -> SecondFragment) and fragment_second.xml (app -> res -> layout -> fragment_second.xml) files.
  3. Locate the FirstFragment.java file, double-click on it to load it into the editor, and remove the code from the onViewCreated() method so that it reads as follows:
binding.buttonFirst.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        NavHostFragment.findNavController(FirstFragment.this)
                .navigate(R.id.action_FirstFragment_to_SecondFragment);
    }
});Code language: Java (java)

Changing the Floating Action Button

Since the objective of this example is to configure the floating action button to add entries to a list, the email icon currently displayed on the button needs to be changed to something more indicative of the action being performed. The icon that will be used for the button is named ic_add_entry.png and can be found in the project_ icons folder of the sample code download available from the following URL:

https://www.ebookfrenzy.com/retail/giraffejava/index.php

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

Figure 45-5

Next, edit the activity_main.xml file and change the image source for the icon from @android:drawable/ic_ dialog_email to @drawable/ic_add_entry as follows:

<com.google.android.material.floatingactionbutton.FloatingActionButton
    android:id="@+id/fab"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom|end"
    android:layout_margin="@dimen/fab_margin"
    android:layout_marginBottom="16dp"
    app:srcCompat="@drawable/ic_add_entry" />Code language: HTML, XML (xml)

Within the layout preview, the interior icon for the button will have changed to a plus sign.

We can also make the floating action button do just about anything when clicked by adding code to the OnClickListener. The following changes to the MainActivity.java file, for example, calls a method named displayMessage() to display a toast message each time the button is clicked:

.
.
import android.widget.Toast;
.
.
binding.fab.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        displayMessage("Fab clicked");
        Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                .setAction("Action", null).show();
    }
});
.
.
public void displayMessage(String message) {
    Toast.makeText(this,message,Toast.LENGTH_SHORT).show();
}Code language: Java (java)

Adding an Action to the Snackbar

An action may also be added to the Snackbar, which performs a task when tapped by the user. Edit the MainActivity.java file and modify the Snackbar creation code to add an action titled “My Action” configured with an onClickListener named actionOnClickListener which, in turn, displays a toast message:

binding.fab.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                .setAction("My Action", actionOnClickListener).show();
    }
});Code language: Java (java)

Within the MainActivity.java file, add the listener handler:

View.OnClickListener actionOnClickListener = new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        displayMessage("Action clicked");
        Snackbar.make(view, "Action Complete", Snackbar.LENGTH_LONG)
                .setAction("Action", null).show();
    }
};Code language: Java (java)

Run the app and tap the floating action button, at which point both the toast message and Snackbar should appear. While the Snackbar is visible, tap the My Action button in the Snackbar and verify that the text on the Snackbar changes to “Action Complete”:

Figure 45-6

Summary

Before working through an example project that uses these features, this chapter has provided a general overview of material design, the floating action button, and the Snackbar.

The floating action button and the Snackbar are part of Android’s material design approach to user interface implementation. The floating action button provides a way to promote the most common action within a particular screen of an Android application. The Snackbar provides a way for an application to present information to the user and allow the user to act upon it.

An Android Java MotionLayout KeyCycle Tutorial

The previous chapters introduced and demonstrated the concepts of integrating animation into Android app user interfaces using the MotionLayout container combined with the features of the Android Studio MotionLayout editor. The chapter entitled Android Studio MotionLayout in Java briefly mentioned the cycle (KeyCycle) and time cycle (KeyTimeCycle) keyframes and explained how these can be used to implement animations involving large numbers of repetitive state changes.

This chapter will cover cycle keyframes in more detail before demonstrating how to make them in an example project using Android Studio and the Cycle Editor.

An Overview of Cycle Keyframes

Position keyframes can add intermediate state changes to the animation timeline. While this works well for small numbers of state changes, it would be cumbersome to implement in larger quantities. To make a button shake 50 times when tapped to indicate that an error occurred, for example, would involve manually creating 100 position keyframes to perform small clockwise and anti-clockwise rotations. Similarly, implementing a bouncing effect on a view as it moves across the screen would be equally time-consuming.

For situations where state changes need to be performed repetitively, MotionLayout includes the Cycle and Time Cycle keyframes. Both perform the same tasks, except that KeyCycle frames are based on frame positions within an animation path, while KeyTimeCycles are time-based in cycles per second (Hz).

Using these KeyCycle frames, the animation timeline is essentially divided into subsections (called cycles), each containing one or more waves that define how a property of a view is to be modified throughout the timeline. The following information is required when creating a KeyCycle cycle:

  • target view – The id of the view on which the changes will be made.
  • frame position – The position in the timeline at which the cycle is to start.
  • wave period – The number of waves to be included in the cycle.
  • attribute – The property of the view to be modified by the waves.
  • wave offset – Offsets the cycle by the specified amount from the keyframe baseline.
  • wave shape – The shape of the wave (sin, cos, sawtooth, square, triangle, bounce or reverse sawtooth)
  • Consider the following cycle keyframe set:
<KeyFrameSet>
   <KeyCycle
       motion:framePosition="0"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="1"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="50dp"/>
 
   <KeyCycle
       motion:framePosition="25"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="1"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="50dp"/>
 
   <KeyCycle
       motion:framePosition="50"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="1"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="50dp"/>
 
   <KeyCycle
       motion:framePosition="75"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="1"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="50dp"/>
 
   <KeyCycle
       motion:framePosition="100"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="1"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="50dp"/>
</KeyFrameSet>Code language: HTML, XML (xml)

The above keyframe set divides the timeline into four equal cycles. Each cycle is configured to contain a sin wave shape which adjusts the translationY property of a button 50dp. This animation will cause the button to oscillate vertically multiple times within the specified range when executed. This keyframe set can be visualized as shown in Figure 51-1, where the five dots represent the keyframe positions:

Figure 44-1

As currently implemented, each cycle contains a single wave. Suppose we need four waves within the last cycle instead of these evenly distributed waves. This can easily be achieved by increasing the wavePeriod property for the last KeyCycle element as follows:

.
.
    <KeyCycle
       motion:framePosition="75"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="4"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="50dp"/>
.
.Code language: HTML, XML (xml)

After making this change, the frame set can be rendered in wave form as follows:

Figure 44-2

So far, the examples in this chapter have been using sin waves. Several other wave shapes are available when working with cycle keyframes in MotionLayout. Figure 51-3, for example, illustrates the effect of changing the waveShape property for all the cycle keyframes to the sawtooth wave shape:

Figure 44-3

In addition to sin and sawtooth, MotionLayout also supports triangle, square, bounce, and reverseSawtooth wave shapes.

In the above examples, each cycle moves the button within the same range along the Y-axis. However, suppose we need the second cycle to move the button a greater distance along the positive Y-axis. This involves adjusting the waveOffset property of the second cycle as follows:

<KeyCycle 
        motion:framePosition="25"
        motion:target="@+id/button"
        motion:wavePeriod="1"
        motion:waveOffset="100dp"
        motion:waveShape="sin"
        android:translationY="50dp"/>Code language: HTML, XML (xml)

By making this change, we end up with a timeline that resembles Figure 51-4:

Figure 44-4

The movement of the button during the second cycle will now range between approximately 0 and 150dp on the Y-axis. If we still need the lower end of the range to match the other waves, we can, of course, add 100dp to the translationY value:

<KeyCycle 
        motion:framePosition="25"
        motion:target="@+id/button"
        motion:wavePeriod="1"
        motion:waveOffset="100dp"
        motion:waveShape="sin"
        android:translationY="150dp"/>Code language: HTML, XML (xml)

This change now gives us the following waveform:

Figure 44-5

Using the Cycle Editor

Although not particularly complicated, getting the exact cycle configuration you need can take some time by directly editing XML KeyCycle entries in the MotionScene file. In recognition, the Android engineers at Google have developed the Cycle Editor. This separate Java-based utility is not yet part of Android Studio. The Cycle Editor allows you to design and test cycle keyframe sets visually.

The Cycle Editor tool is a Java archive (jar) file requiring the Java runtime to be installed on your development system.

Once you have Java installed, the CycleEditor.jar file can be downloaded from the following URL:

https://github.com/googlesamples/android-ConstraintLayoutExamples/releases/download/1.0/CycleEditor.jar

Once downloaded, open a command prompt or terminal window, change directory to the location of the jar file, and run the following command: java -jar CycleEditor.jar

Once the tool has loaded, the screen shown in Figure 51-6 will appear:

Figure 44-6

The panel marked A in the above figure displays the XML for the keyframe set and can be edited directly or using the controls in panel B. Panel C displays the rendering of the cycles in wave form. Unfortunately, this is not redrawn in real time as changes are made. Instead, it must be refreshed by selecting the File -> parse xml menu option. The panel marked D will show a live rendering of the cycle animations when the play button in panel B is clicked. The Examples menu provides access to a collection of example keyframe sets that can be used both for learning purposes and as the basis for your own animations.

The remainder of this chapter will create a sample project that implements a KeyCycle-based animation effect to demonstrate the use of the Cycle Keyframe and the Cycle Editor.

Creating the KeyCycleDemo Project

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

Enter KeyCycleDemo into the Name field and specify com.ebookfrenzy.keycycledemo 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 Java.

With the layout editor in Design mode and the activity_main.xml file open, right-click on the ConstraintLayout entry and select the Convert to MotionLayout menu option:

Figure 44-7

After making the selection, click the Convert button in the confirmation dialog.

Configuring the Start and End Constraints

This tutorial aims to animate a button’s movement from one side of the device screen to the other, including KeyCycle effects that cause the view to also move up and down along the Y-axis. The first step is to configure the start and end constraints.

With the activity_main.xml file loaded into the MotionLayout editor, select and delete the default TextView widget. Make sure the Motion Layout box (marked E in Figure 51-9 below) is selected before dragging and dropping a Button view from the palette so that it is centered vertically and positioned along the left-hand edge of the layout canvas:

Figure 44-8

To configure the constraints for the start point, select the start constraint set entry in the editor window (marked A in Figure 51-9):

Figure 44-9

Next, select the button entry within the ConstraintSet list (B). With the button entry still selected, click the edit button (C) and select Create Constraint from the menu.

With the button still selected, use the Attributes tool window to set constraints on the top, left, and bottom sides of the view as follows:

Figure 44-10

Select the end constraint set entry (marked D in Figure 51-9 above) and repeat the steps to create a new constraint, this time with constraints on the top, bottom, and right-hand edges of the button:

Figure 44-11

Creating the Cycles

The next step is to use the Cycle Editor to generate the cycle keyframes for the animation. With the Cycle Editor running, refer to the control panel shown in Figure 51-12 below:

Figure 44-12

Using the menu marked A, change the property to be modified from rotation to translationY.

Next, use the KeyCycle control (B) to select cycle 0 so that changes made elsewhere in the panel will be applied to the first cycle. Move the Period slider to 1 and the translationY slider to 60 as shown in Figure 51-13 (refer to the XML panel to see the precise setting for the translationY value as you move the slider):

Figure 44-13

Select the File -> Parse XML menu option to see the changes in the graph. Using the values listed in Table 51-1, configure the settings for KeyFrames 1 through 4 (keeping in mind that you have already configured the settings in the KeyCycle 0 column):

KeyCycle 0

KeyCycle 1

KeyCycle 2

KeyCycle 3

KeyCycle 4

Position

0

25

50

75

100

Period

1

2

3

2

1

translationY

60

60

150

60

60

Table 44-1

On completion of these changes, the keyframe set XML should read as follows:

<KeyFrameSet>
   <KeyCycle
       motion:framePosition="0"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="1"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="60dp"/>
 
   <KeyCycle
       motion:framePosition="25"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="2"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="60dp"/>
 
   <KeyCycle
       motion:framePosition="50"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="3"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="150dp"/>
 
   <KeyCycle
       motion:framePosition="75"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="2"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="60dp"/>
 
   <KeyCycle
       motion:framePosition="100"
       motion:motionTarget="@+id/button"
       motion:wavePeriod="1"
       motion:waveOffset="0dp"
       motion:waveShape="sin"
       android:translationY="60dp"/>
</KeyFrameSet>Code language: HTML, XML (xml)

To view the graph with the new cycles, select the File -> Parse XML menu option to refresh the wave pattern, which should now appear as illustrated in Figure 51-14:

Figure 44-14

Previewing the Animation

The cycle-based animation may now be previewed from within the Cycle Editor tool. Start the animation by clicking the play button (marked A in Figure 51-15). To combine the cycles with horizontal movement, change the second menu (B) from Stationary to West to East. Also, take some time to experiment with the time and linearity settings (C and D).

Figure 44-15

Adding the KeyFrameSet to the MotionScene

Within the Cycle Editor, highlight and copy only the KeyCycle elements from the XML panel and paste them into the Transition section of the res -> xml -> activity_main_scene.xml file within Android Studio so that they are placed between the existing KeyFrameSet markers. Note also the increased duration setting and the addition of an OnClick handler to initiate the animation:

<Transition
    motion:constraintSetEnd="@+id/end"
    motion:constraintSetStart="@id/start"
    motion:duration="7000">
    <KeyFrameSet>
       <KeyCycle
           motion:framePosition="0"
           motion:motionTarget="@+id/button"
           motion:wavePeriod="1"
           motion:waveOffset="0dp"
           motion:waveShape="sin"
           android:translationY="60dp"/>
 
           <KeyCycle
               motion:framePosition="25"
.
.
   </KeyFrameSet>
 
   <OnClick motion:targetId="@id/button"
            motion:clickAction="toggle" />
.
. Code language: HTML, XML (xml)

Before proceeding, check that each target property is correctly declared. At the time of writing, the Cycle Editor was using the outdated motion:target tag. For example:

motion:target="@+id/button" Code language: HTML, XML (xml)

This will need to be changed for each of the five KeyCycle entries to read as follows:

motion:motionTarget="@+id/button"Code language: HTML, XML (xml)

Once these changes have been made, compile and run the app on a device or emulator and click the button to start and view the animation.

Note that the KeyCycle wave formation can also be viewed within the Android Studio MotionLayout editor, as shown in Figure 51-16 below:

Figure 44-16

KeyCycle frame sets are not limited to one per animation. For example, add the following KeyFrameSet to the Transition section of the activity_main_scene.xml file to add some rotation effects to the button as it moves:

<KeyFrameSet>
    <KeyCycle
        motion:framePosition="0"
        motion:motionTarget="@+id/button"
        motion:wavePeriod="1"
        motion:waveOffset="0dp"
        motion:waveShape="sin"
        android:translationY="60dp"
        android:rotation="45"/>
 
    <KeyCycle
        motion:framePosition="25"
        motion:motionTarget="@+id/button"
        motion:wavePeriod="2"
        motion:waveOffset="0dp"
        motion:waveShape="sin"
        android:translationY="60dp"
        android:rotation="80"/>
 
    <KeyCycle
        motion:framePosition="50"
        motion:motionTarget="@+id/button"
        motion:wavePeriod="3"
        motion:waveOffset="0dp"
        motion:waveShape="sin"
        android:translationY="150dp"
        android:rotation="45"/>
 
    <KeyCycle
        motion:framePosition="75"
        motion:motionTarget="@+id/button"
        motion:wavePeriod="2"
        motion:waveOffset="0dp"
        motion:waveShape="sin"
        android:translationY="60dp"
        android:rotation="80"/>
 
    <KeyCycle
        motion:framePosition="100"
        motion:motionTarget="@+id/button"
        motion:wavePeriod="1"
        motion:waveOffset="0dp"
        motion:waveShape="sin"
        android:translationY="60dp"
        android:rotation="45"/>
</KeyFrameSet>   Code language: HTML, XML (xml)

Summary

Cycle keyframes provide a useful way to build frame animations that involve potentially large numbers of state changes that match wave patterns. As this chapter outlines, generating these cycle keyframes can be eased significantly using the Cycle Editor application.

An Android Studio Java MotionLayout Editor Tutorial

Now that the basics of MotionLayout have been covered, this chapter will provide an opportunity to try out MotionLayout in an example project. In addition to continuing to explore the main features of MotionLayout, this chapter will also introduce the MotionLayout editor and explore how it can be used to construct and modify MotionLayout animations visually.

The project created in this chapter will use start and end ConstraintSets, gesture handlers, and Attribute and Position Keyframes.

Creating the MotionLayoutDemo Project

Click the New Project button in the welcome screen and choose the Empty Views Activity template within the resulting new project dialog before clicking the Next button.

Enter MotionLayoutDemo into the Name field and specify com.ebookfrenzy.motionlayoutdemo 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 Java.

ConstraintLayout to MotionLayout Conversion

Android Studio will have placed a ConstraintLayout container as the parent view within the activity_main.xml layout file. The next step is to convert this container to a MotionLayout instance. Within the Component Tree, right-click on the ConstraintLayout entry and select the Convert to MotionLayout menu option:

Figure 43-1

After making the selection, click the Convert button in the confirmation dialog. Once the conversion is complete, the MotionLayout editor will appear within the main Android Studio window, as illustrated in Figure 43-2:

Figure 43-2

As part of the conversion process, Android Studio will create a new folder named res -> xml and place within it a MotionLayout scene file named activity_main_scene.xml:

Figure 43-3

This file contains a top-level MotionScene element containing the ConstraintSet and Transition entries that will define the animations within the main layout. By default, the file will contain empty elements for the start and end constraint sets and an initial transition:

<?xml version="1.0" encoding="utf-8"?>
<MotionScene 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:motion="http://schemas.android.com/apk/res-auto">
 
    <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@id/start"
        motion:duration="1000">
       <KeyFrameSet>
       </KeyFrameSet>
    </Transition>
 
    <ConstraintSet android:id="@+id/start">
    </ConstraintSet>
 
    <ConstraintSet android:id="@+id/end">
    </ConstraintSet>
</MotionScene>Code language: HTML, XML (xml)

Any changes made within the MotionLayout editor will be stored within this file. Similarly, this file may be edited directly to implement and modify animation settings outside the MotionLayout editor.

The animations will be implemented primarily using the MotionLayout editor interface in this tutorial. However, we will review how these changes are reflected in the underlying MotionScene file at each stage. As we progress through the chapter, it will become clear that the MotionScene XML syntax is simple and easy to learn.

The first phase of this tutorial will demonstrate the use of MotionLayout to animate a Button object, including motion (including following a path), rotation, and size scaling.

Configuring Start and End Constraints

With the activity_main.xml file loaded into the MotionLayout editor, make sure that the Motion Layout box (marked E in Figure 43-5 below) is selected, then delete the default TextView before dragging and dropping a Button view from the palette to the top left-hand corner of the layout canvas as shown in Figure 43-4:

Figure 43-4

With the button selected, use the Attributes tool window to change the id to myButton.

As outlined in the previous chapter, MotionLayout animation is primarily a case of specifying how a view transitions between two states. Therefore, the first step in implementing animation is to specify the constraints that define these states.

For this example, the start point will be the top center of the layout view. To configure these constraints, select the start constraint set entry in the editor window (marked A in Figure 43-5):

Figure 43-5

When the start box is selected, all constraint and layout changes will be made to the start point constraint set. To return to the standard constraints and properties for the entire layout, click on the Motion Layout box (E).

Next, select the myButton entry within the ConstraintSet list (B). Note that the Source column shows that the button is positioned based on constraints within the layout file. Instead, we want the button positioned based on the start constraint set. With the myButton entry still selected, click on the Edit button (C) and select Create Constraint from the menu, after which the button entry will indicate that the view is to be positioned based on the start constraint set:

Figure 43-6

The start constraint set must position the button at the top of the layout with an 8dp offset and centered horizontally. With myButton still selected, use the Attributes tool window to set constraints on the top, left, and right sides of the view as follows:

Figure 43-7

Select the end constraint set entry (marked D in Figure 43-5 above) and repeat the steps to create a new constraint, this time placing the button in the horizontal center of the layout but with an 8p offset from the bottom edge of the layout:

Figure 43-8

With the start and end constraints configured, open the activity_main_scene.xml file and note that the constraints have been added to the file:

<?xml version="1.0" encoding="utf-8"?>
<MotionScene 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:motion="http://schemas.android.com/apk/res-auto">
.
.
    <ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@+id/myButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            motion:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="8dp"
            motion:layout_constraintStart_toStartOf="parent"
            motion:layout_constraintEnd_toEndOf="parent" />
    </ConstraintSet>
 
    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@+id/myButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            motion:layout_constraintStart_toStartOf="parent"
            motion:layout_constraintEnd_toEndOf="parent"
            motion:layout_constraintBottom_toBottomOf="parent"
            android:layout_marginBottom="8dp" />
    </ConstraintSet>
</MotionScene>Code language: HTML, XML (xml)

Note also that the Transition element has already been preconfigured to animate the transition between the start and end points over 1000 milliseconds. Although we have yet to add an action to initiate the transition, previewing the animation from within the MotionLayout editor is still possible.

Previewing the MotionLayout Animation

To preview the animation without building and running the app, select the transition arrow within the MotionLayout editor marked A in Figure 43-9 below. This will display the animation timeline panel (marked B):

Figure 43-9

To test the animation, click on the slider (C) and drag it along the timeline. As the slider moves, the button in the layout canvas will move along the dashed path line (D). Use the toolbar button (E) to perform a full animation to repeat the animation continuously at different speeds (either forwards, backward or toggling back and forth).

Adding an OnClick Gesture

Although a simple MotionLayout animation transition has been created, we still need a way to start the animation from within the running app. This can be achieved by assigning either a click or swipe handler. For this example, we will configure the animation to start when the user clicks the button. Within the MotionLayout editor, pause the timeline animation if it runs on a loop setting. Next, select the Transition arrow (marked A in Figure 43-9 above), locate the OnClick attribute section in the Attributes tool window and click on the + button indicated by the arrow in Figure 43-10 below:

Figure 43-10

An empty row will appear in the OnClick panel for the first property. For the property name, enter targetId; for the value field, enter the button’s id (@id/myButton). In the next empty row, enter app:clickAction into the property name field. In the value field, click the down arrow to display a menu of valid options:

Figure 43-11

For this example, select the toggle action. This will cause the view to animate to the opposite position when clicked. Once these settings have been entered, they should match those shown in Figure 43-12:

Figure 43-12

Once again, open the activity_main_scene.xml file and review the OnClick property defined within the Transition entry:

.
.
   <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@id/start"
        motion:duration="1000">
       <KeyFrameSet>
       </KeyFrameSet>
        <OnClick motion:targetId="@id/myButton"
            motion:clickAction="toggle" />
    </Transition>
.
.Code language: HTML, XML (xml)

Compile and run the app on a device or emulator and confirm that clicking on the button causes it to transition back and forth between the start and end points as defined in the MotionScene file.

Adding an Attribute Keyframe to the Transition

So far, the example project is only animating the motion of the button view from one location on the screen to another. Attribute keyframes (KeyAttribute) provide a way to specify points within the transition timeline at which other attribute changes are to have taken effect. A KeyAttribute could, for example, be defined such that the view must have increased in size by 50% by the time the view has moved 30% through the timeline. For this example, we will add a rotation effect positioned at the mid-point of the animation.

Begin by opening the activity_main.xml file in the MotionLayout Editor, selecting the transition connector arrow to display the timeline, then click on the button highlighted in Figure 43-13:

Figure 43-13

From the menu, select the KeyAttribute option:

Figure 43-14

Once selected, the dialog shown in Figure 43-15 will appear. Within the dialog, make sure the ID option is selected and that myButton is referenced. In the position field, enter 50 (this is specified as a percentage where 0 is the start point and 100 is the end). Finally, select the rotation entry from the Attribute drop-down menu before clicking on the Add button:

Figure 43-15

Once the KeyAttribute has been added, a row will appear within the timeline for the attribute. Click on the row to highlight it, then click on the disclosure arrow on the far left edge of the row to unfold the attribute transition graph. Note that a small diamond marker appears in the timeline (as indicated in Figure 43-16 below), indicating the location of the key. The graph indicates the linearity of the effect. In this case, the button will rotate steadily up to the specified number of degrees, reaching maximum rotation at the location of the keyframe. The button will then rotate back to 0 degrees by the time it reaches the end point:

Figure 43-16

To change the properties of a KeyAttribute, select it in the timeline and then refer to the Attributes tool window. Within the KeyAttribute panel, change the rotation property to 360 degrees:

Figure 43-17

Check that the attribute works by moving the slider back and forth and watching the button rotate as it traverses the animation path in the layout canvas. Refer to the activity_main_scene.xml file, which should appear as follows:

.
.
   <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@id/start"
        motion:duration="1000">
       <KeyFrameSet>
           <KeyAttribute
               motion:motionTarget="@+id/myButton"
               motion:framePosition="50"
               android:rotation="360" />
       </KeyFrameSet>
        <OnClick motion:targetId="@id/myButton"
            motion:clickAction="toggle" />
    </Transition>
.
.Code language: HTML, XML (xml)

Test the animation using the transition slider or by compiling and running the app and verify that the button now rotates during the animation.

Adding a CustomAttribute to a Transition

The KeyAttribute property is limited to built-in effects such as resizing and rotation. Additional changes are also possible by declaring CustomAttributes. Unlike KeyAttributes, which are stored in the Transition element, CustomAttributes are located in the start and end constraint sets. As such, these attributes can only be declared to take effect at start and end points (in other words, you cannot specify an attribute keyframe at a position partway through a transition timeline).

For this example, we will configure the button to gradually change color from red to green. Begin by selecting the start box marked A in Figure 43-18, followed by the myButton view constraint set (B):

Figure 43-18

Referring to the Attributes tool window, click on the + button in the CustomAttributes section as highlighted below:

Figure 43-19

In the resulting dialog (Figure 43-20), change the attribute type to Color and enter backgroundColor into the Attribute Name field. Finally, set the value to #F80A1F:

Figure 43-20

Click on OK to commit the changes, then select the end constraint set (marked C in Figure 43-18 above) and repeat the steps to add a custom attribute, this time specifying #33CC33 as the RGB value for the color.

Using the timeline slider or running the app, make sure the button changes color during the animation. The addition of these CustomAttributes will be reflected in the activity_main_scene.xml file as follows:

.
.
   <ConstraintSet android:id="@+id/start">
        <Constraint
.
.
            <CustomAttribute
                motion:attributeName="backgroundColor"
                motion:customColorValue="#F80A1F" />
        </Constraint>
    </ConstraintSet>
 
    <ConstraintSet android:id="@+id/end">
        <Constraint
.
.
            <CustomAttribute
                motion:attributeName="backgroundColor"
                motion:customColorValue="#33CC33" />
        </Constraint>
    </ConstraintSet>
.
.Code language: HTML, XML (xml)

Adding Position Keyframes

The final task for this tutorial is to add two position keyframes (KeyPosition) to the animation path to introduce some lateral movement into the animation. With the transition timeline visible in the MotionLayout editor, click on the button to create a keyframe as highlighted in Figure 43-13 above, and select the KeyPosition option from the menu, as shown in Figure 43-21 below:

Figure 43-21

In the resulting dialog, set the properties as illustrated in Figure 43-22:

Figure 43-22

Click on the Add button to commit the change, then repeat the above steps to add a second position keyframe configured as follows:

  • Position: 75
  • Type: parentRelative
  • PercentX: 0.85
  • PercentY: 0.75

On completion of these changes, the following keyframe entries will have been added to the transition element in the activity_main_scene.xml file:

<KeyFrameSet>
.
.
   <KeyPosition
       motion:motionTarget="@+id/myButton"
       motion:framePosition="25"
       motion:keyPositionType="parentRelative"
       motion:percentX="0.15"
       motion:percentY="0.25" />
   <KeyPosition
       motion:motionTarget="@+id/myButton"
       motion:framePosition="75"
       motion:keyPositionType="parentRelative"
       motion:percentX="0.85"
       motion:percentY="0.75" />
</KeyFrameSet>
.
.Code language: HTML, XML (xml)

Test the app one last time and verify that the button now follows the path shown below while still rotating and changing color:

Figure 43-23

Position keyframes are represented by diamond-shaped markers on the dotted line representing the motion path within the preview canvas as indicated in Figure 43-24 (if the markers are not visible, make sure that the Button view is selected in the preview):

Figure 43-24

To visually adjust the position of a keyframe, click on the marker and drag it to a new position. As the marker moves, the Motion Layout editor will display a grid together with the current x and y coordinates:

Figure 43-25

Summary

This chapter has introduced the MotionLayout editor built into Android Studio and explored how it can be used to add animation to the user interface of an Android app without having to write XML declarations manually. Examples covered in this chapter included the conversion of a ConstraintLayout container to MotionLayout, creating start and end constraint sets and transitions in the MotionScene file, and adding an OnClick handler. The animation previewer, custom attributes, and position keyframes were also covered.

Android Studio MotionLayout in Java

The MotionLayout class provides an easy way to add animation effects to the views of a user interface layout. This chapter will begin by providing an overview of MotionLayout and introduce the concepts of MotionScenes, Transitions, and Keyframes. Once these basics have been covered, the next two chapters (entitled “An Android MotionLayout Editor Tutorial” and “A MotionLayout KeyCycle Tutorial”) will provide additional detail and examples of MotionLayout animation in action through the creation of example projects.

An Overview of MotionLayout

MotionLayout is a layout container, the primary purpose of which is to animate the transition of views within a layout from one state to another. MotionLayout could, for example, animate the motion of an ImageView instance from the top left-hand corner of the screen to the bottom right-hand corner over a specified time. In addition to the position of a view, other attribute changes may also be animated, such as the color, size, or rotation angle. These state changes can also be interpolated (such that a view moves, rotates, and changes size throughout the animation).

The motion of a view using MotionLayout may be performed in a straight line between two points or implemented to follow a path comprising intermediate points at different positions between the start and end points. MotionLayout also supports using touches and swipes to initiate and control animation.

MotionLayout animations are declared entirely in XML and do not typically require writing code. These XML declarations may be implemented manually in the Android Studio code editor, visually using the MotionLayout editor, or combining both approaches.

MotionLayout

When implementing animation, the ConstraintLayout container typically used in a user interface must first be converted to a MotionLayout instance (a task which can be achieved by right-clicking on the ConstraintLayout in the layout editor and selecting the Convert to MotionLayout menu option). MotionLayout also requires at least version 2.0.0 of the ConstraintLayout library.

Unsurprisingly since it is a subclass of ConstraintLayout, MotionLayout supports all of the layout features of the ConstraintLayout. Therefore, a user interface layout can be similarly designed when using MotionLayout for views that do not require animation.

For views that are to be animated, two ConstraintSets are declared, defining the appearance and location of the view at the start and end of the animation. A transition declaration defines keyframes to apply additional effects to the target view between these start and end states and click and swipe handlers used to start and control the animation. The start and end ConstraintSets and the transitions are declared within a MotionScene XML file.

MotionScene

As we have seen in earlier chapters, an XML layout file contains the information necessary to configure the appearance and layout behavior of the static views presented to the user, and this is still the case when using MotionLayout. For non-static views (in other words, the views that will be animated), those views are still declared within the layout file, but the start, end, and transition declarations related to those views are stored in a separate XML file referred to as the MotionScene file (so called because all of the declarations are defined within a MotionScene element). This file is imported into the layout XML file and contains the start and end ConstraintSets and Transition declarations (a single file can contain multiple ConstraintSet pairs and Transition declarations, allowing different animations to be targeted to specific views within the user interface layout). The following listing shows a template for a MotionScene file:

<?xml version="1.0" encoding="utf-8"?>
<MotionScene 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:motion="http://schemas.android.com/apk/res-auto">
 
    <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@id/start"
        motion:duration="1000">
       <KeyFrameSet>
       </KeyFrameSet>
    </Transition>
 
    <ConstraintSet android:id="@+id/start">
    </ConstraintSet>
  
    <ConstraintSet android:id="@+id/end">
    </ConstraiCode language: HTML, XML (xml)

In the above XML, ConstraintSets named start and end (though any name can be used) have been declared, which, at this point, are yet to contain any constraint elements. The Transition element defines that these ConstraintSets represent the animation start and end points and contain an empty KeyFrameSet element ready to be populated with additional animation keyframe entries. The Transition element also includes a millisecond duration property to control the running time of the animation.

ConstraintSets do not have to imply the motion of a view. It is possible to have the start and end sets declare the same location on the screen and then use the transition to animate other property changes, such as scale and rotation angle.

ConstraintSets do not have to imply the motion of a view. It is possible, for example, to have the start and end sets declare the same location on the screen and then use the transition to animate other property changes, such as scale and rotation angle.

Configuring ConstraintSets

The ConstraintSets in the MotionScene file allow the full set of ConstraintLayout settings to be applied to a view regarding positioning, sizing, and relation to the parent and other views. In addition, the following attributes may also be included within the ConstraintSet declarations:

  • alpha
  • visibility
  • elevation
  • rotation
  • rotationX
  • rotationY
  • translationX
  • translationY
  • translationZ
  • scaleX
  • scaleY

For example, to rotate the view by 180° during the animation, the following could be declared within the start and end constraints:

<ConstraintSet android:id="@+id/start">
    <Constraint
.
.
        motion:layout_constraintStart_toStartOf="parent"
        android:rotation="0">
    </Constraint>
</ConstraintSet>
 
<ConstraintSet android:id="@+id/end">
    <Constraint
.
.
        motion:layout_constraintBottom_toBottomOf="parent"
        android:rotation="180">
    </Constraint>
</ConstraintSet>Code language: HTML, XML (xml)

The above changes tell MotionLayout that the view is to start at 0° and then, during the animation, rotate a full 180° before coming to rest upside-down.

Custom Attributes

In addition to the standard attributes listed above, it is possible to specify a range of custom attributes (declared using CustomAttribute). In fact, just about any property available on the view type can be specified as a custom attribute for inclusion in an animation. To identify the attribute’s name, find the getter/setter name from the documentation for the target view class, remove the get/set prefix, and lower the case of the first remaining character. For example, to change the background color of a Button view in code, we might call the setBackgroundColor() setter method as follows:

myButton.setBackgroundColor(Color.RED)Code language: Java (java)

When setting this attribute in a constraint set or keyframe, the attribute name will be backgroundColor. In addition to the attribute name, the value must also be declared using the appropriate type from the following list of options:

  • motion:customBoolean – Boolean attribute values.
  • motion:customColorValue – Color attribute values.
  • motion:customDimension – Dimension attribute values.
  • motion:customFloatValue – Floating point attribute values.
  • motion:customIntegerValue – Integer attribute values.
  • motion:customStringValue – String attribute values

For example, a color setting will need to be assigned using the customColorValue type:

<CustomAttribute
    motion:attributeName="backgroundColor"
    motion:customColorValue="#43CC76" />Code language: HTML, XML (xml)

The following excerpt from a MotionScene file, for example, declares start and end constraints for a view in addition to changing the background color from green to red:

.
.
   <ConstraintSet android:id="@+id/start">
        <Constraint
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            motion:layout_editor_absoluteX="21dp"
            android:id="@+id/button"
            motion:layout_constraintTop_toTopOf="parent"
            motion:layout_constraintStart_toStartOf="parent" >
            <CustomAttribute
                motion:attributeName="backgroundColor"
                motion:customColorValue="#33CC33" />
        </Constraint>
    </ConstraintSet>
 
    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            motion:layout_editor_absoluteY="21dp"
            android:id="@+id/button"
            motion:layout_constraintEnd_toEndOf="parent"
            motion:layout_constraintBottom_toBottomOf="parent" >
            <CustomAttribute
                motion:attributeName="backgroundColor"
                motion:customColorValue="#F80A1F" />
        </Constraint>
    </ConstraintSet>
.
.
Code language: HTML, XML (xml)

Triggering an Animation

Without some event to tell MotionLayout to start the animation, none of the settings in the MotionScene file will affect the layout (except that the view will be positioned based on the setting in the start ConstraintSet).

The animation can be configured to start in response to either screen tap (OnClick) or swipe motion (OnSwipe) gesture. The OnClick handler causes the animation to start and run until completion, while OnSwipe will synchronize the animation to move back and forth along the timeline to match the touch motion. The OnSwipe handler will also respond to “flinging” motions on the screen. The OnSwipe handler also provides options to configure how the animation reacts to dragging in different directions and the side of the target view to which the swipe is to be anchored. This allows, for example, left-ward dragging motions to move a view in the corresponding direction while preventing an upward motion from causing a view to move sideways (unless, of course, that is the required behavior).

The OnSwipe and OnClick declarations are contained within the Transition element of a MotionScene file. In both cases, the view id must be specified. For example, to implement an OnSwipe handler responding to downward drag motions anchored to the bottom edge of a view named button, the following XML would be placed in the Transition element:

.
.
<Transition
    motion:constraintSetEnd="@+id/end"
    motion:constraintSetStart="@id/start"
    motion:duration="1000">
   <KeyFrameSet>
   </KeyFrameSet>
    <OnSwipe
        motion:touchAnchorId="@+id/button"
        motion:dragDirection="dragDown"
        motion:touchAnchorSide="bottom" />
</Transition>
.
.Code language: HTML, XML (xml)

Alternatively, to add an OnClick handler to the same button:

<OnClick motion:targetId="@id/button"
    motion:clickAction="toggle" />Code language: HTML, XML (xml)

In the above example, the action has been set to toggle mode. This mode and the other available options can be summarized as follows:

  • toggle – Animates to the opposite state. For example, if the view is currently at the transition start point, it will transition to the end point, and vice versa.
  • jumpToStart – Changes immediately to the start state without animation.
  • jumpToEnd – Changes immediately to the end state without animation.
  • transitionToStart – Transitions with animation to the start state.
  • transitionToEnd – Transitions with animation to the end state.

Arc Motion

By default, a movement of view position will travel in a straight line between the start and end points. To change the motion to an arc path, use the pathMotionArc attribute as follows within the start constraint, configured with either a startHorizontal or startVertical setting to define whether the arc is to be concave or convex:

<ConstraintSet android:id="@+id/start">
    <Constraint
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        motion:layout_editor_absoluteX="21dp"
        android:id="@+id/button"
        motion:layout_constraintTop_toTopOf="parent"
        motion:layout_constraintStart_toStartOf="parent"
        motion:pathMotionArc="startVertical" >Code language: HTML, XML (xml)

Figure 49-1 illustrates startVertical and startHorizontal arcs in comparison to the default straight line motion:

Figure 42-1

Keyframes

All of the ConstraintSet attributes outlined so far only apply to the start and end points of the animation. In other words, if the rotation property were set to 180° on the end point, the rotation would begin when the animation starts and complete when the end point is reached. It is not, therefore, possible to configure the rotation to reach the full 180° at a point 50% of the way through the animation and then rotate back to the original orientation by the end. Fortunately, this type of effect is available using Keyframes.

Keyframes are used to define intermediate points during the animation at which state changes are to occur. Keyframes could, for example, be declared such that the background color of a view is to have transitioned to blue at a point 50% of the way through the animation, green at the 75% point, and then back to the original color by the end of the animation. Keyframes are implemented within the Transition element of the MotionScene file embedded into the KeyFrameSet element.

MotionLayout supports several types of Keyframe which can be summarized as follows:

Attribute Keyframes

Attribute Keyframes (declared using KeyAttribute) allow view attributes to be changed at intermediate points in the animation timeline. KeyAttribute supports the attributes listed above for ConstraintSets combined with the ability to specify where the change will take effect in the animation timeline. For example, the following Keyframe declaration will gradually cause the button view to double in size horizontally (scaleX) and vertically (scaleY), reaching full size at 50% through the timeline. For the remainder of the timeline, the view will decrease in size to its original dimensions:

<Transition
    motion:constraintSetEnd="@+id/end"
    motion:constraintSetStart="@id/start"
    motion:duration="1000">
   <KeyFrameSet>
       <KeyAttribute
           motion:motionTarget="@+id/button"
           motion:framePosition="50"
           android:scaleX="2.0" />
       <KeyAttribute
           motion:motionTarget="@+id/button"
           motion:framePosition="50"
           android:scaleY="2.0" />
   </KeyFrameSet>Code language: HTML, XML (xml)

Position Keyframes

Position keyframes (KeyPosition) modify the path followed by a view as it moves between the start and end locations. By placing key positions at different points on the timeline, a path of just about any level of complexity can be applied to an animation. Positions are declared using x and y coordinates combined with the corresponding points in the transition timeline. These coordinates must be declared relative to one of the following coordinate systems:

  • parentRelative – The x and y coordinates are relative to the parent container where the coordinates are specified as a percentage (represented as a value between 0.0 and 1.0):
Figure 42-2
  • deltaRelative – Instead of relative to the parent, the x and y coordinates are relative to the start and end positions. For example, the start point is (0, 0) the end point (1, 1). Keep in mind that the x and y coordinates can be negative values):
Figure 42-3
  • pathRelative – The x and y coordinates are relative to the path, where the straight line between the start and end points serves as the graph’s X-axis. Once again, coordinates are represented as a percentage (0.0 to 1.0). This is similar to the deltaRelative coordinate space but takes into consideration the angle of the path. Once again coordinates may be negative:
Figure 42-4

As an example, the following ConstraintSets declare start and end points on either side of a device screen. By default, a view transition using these points would move in a straight line across the screen, as illustrated in Figure 49-5:

Figure 42-5

Suppose, however, that the view is required to follow a path similar to that shown in Figure 49-6 below:

Figure 42-6

To achieve this, keyframe position points could be declared within the transition as follows:

<KeyPosition
   motion:motionTarget="@+id/button"
   motion:framePosition="25"
   motion:keyPositionType="pathRelative"
   motion:percentY="0.3"
   motion:percentX="0.25"/>
 
<KeyPosition
   motion:motionTarget="@+id/button"
   motion:framePosition="75"
   motion:keyPositionType="pathRelative"
   motion:percentY="-0.3"
   motion:percentX="0.75"/>Code language: HTML, XML (xml)

The above elements create keyframe position points 25% and 75% through the path using the pathRelative coordinate system. The first position is placed at coordinates (0.25, 0.3) and the second at (0.75, -0.3). These position keyframes can be visualized as illustrated in Figure 49-7 below:

Figure 42-7

Time Linearity

Without additional settings, the animations outlined above will be performed at a constant speed. To vary the animation speed (for example, so that it accelerates and then decelerates), the transition easing attribute (transitionEasing) can be used within a ConstraintSet or Keyframe.

For complex easing requirements, the linearity can be defined by plotting points on a cubic Bézier curve, for example:

.
.
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:transitionEasing="cubic(0.2, 0.7, 0.3, 1)"
            android:rotation="360">
.
.Code language: Java (java)

If you are unfamiliar with Bézier curves, consider using the curve generator online at the following URL:

https://cubic-bezier.com/

For most requirements, however, easing can be specified using the built-in standard, accelerate and decelerate values:

.
.
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:transitionEasing="decelerate"
            android:rotation="360">
.
.Code language: HTML, XML (xml)

KeyTrigger

The trigger keyframe (KeyTrigger) allows a method on a view to be called when the animation reaches a specified frame position within the animation timeline. This also takes into consideration the direction of the animations. For example, different methods can be called depending on whether the animation runs forward or backward. Consider a button that is to be made visible when the animation moves beyond 20% of the timeline. The KeyTrigger would be implemented within the KeyFrameSet of the Transition element as follows using the onPositiveCross property:

.
.
    <KeyFrameSet>
           <KeyTrigger
               motion:framePosition="20"
               motion:onPositiveCross="show"
               motion:motionTarget="@id/button"/>
.
.Code language: HTML, XML (xml)

Similarly, if the same button is to be hidden when the animation is reversed and drops below 10%, a second key trigger could be added using the onNegativeCross property:

<KeyTrigger
     motion:framePosition="10"
     motion:onNegativeCross="show"
     motion:motionTarget="@id/button2"/>Code language: HTML, XML (xml)

If the animation is using toggle action, use the onCross property:

<KeyTrigger
     motion:framePosition="10"
     motion:onCross="show"
     motion:motionTarget="@id/button2"/>Code language: HTML, XML (xml)

Cycle and Time Cycle Keyframes

While position keyframes can be used to add intermediate state changes into the animation, this would quickly become cumbersome if large numbers of repetitive positions and changes needed to be implemented. For situations where state changes need to be performed repetitively with predictable changes, MotionLayout includes the Cycle and Time Cycle keyframes. The chapter entitled “A MotionLayout KeyCycle Tutorial” will cover this topic in detail.

Starting an Animation from Code

So far in this chapter, we have only looked at controlling an animation using the OnSwipe and OnClick handlers. It is also possible to start an animation from within code by calling methods on the MotionLayout instance. The following code, for example, runs the transition from start to end with a duration of 2000ms for a layout named motionLayout:

motionLayout.setTransitionDuration(2000);
motionLayout.transitionToEnd();Code language: Java (java)

In the absence of additional settings, the start and end states used for the animation will be those declared in the Transition declaration of the MotionScene file. To use specific start and end constraint sets, reference them by id in a call to the setTransition() method of the MotionLayout instance:

motionLayout.setTransition(R.id.myStart, R.id.myEnd);
motionLayout.transitionToEnd();Code language: Java (java)

To monitor the state of an animation while it is running, add a transition listener to the MotionLayout instance as follows:

motionLayout.setTransitionListener(transitionListener);
 
MotionLayout.TransitionListener transitionListener = 
                        new MotionLayout.TransitionListener() {
    @Override
    public void onTransitionStarted(MotionLayout motionLayout, 
                                      int startId, int endId) {
		// Called when the transition starts
    }
 
    @Override
    public void onTransitionChange(MotionLayout motionLayout, int startId, 
                                      int endId, float progress) {
		// Called each time a property changes. Track progress value to find 
		// current position
    }
 
    @Override
    public void onTransitionCompleted(MotionLayout motionLayout, int currentId) {
             // Called when the transition is complete 
    }
 
    @Override
    public void onTransitionTrigger(MotionLayout motionLayout, int triggerId, 
                                     boolean positive, float progress) {
		// Called when a trigger keyframe threshold is crossed
    }
};Code language: Java (java)

Summary

MotionLayout is a subclass of ConstraintLayout designed specifically to add animation effects to the views in user interface layouts. MotionLayout works by animating the transition of a view between two states defined by start and end constraint sets. Additional animation effects may be added between these start and end points using keyframes.

Animations may be triggered via OnClick or OnSwipe handlers or programmatically via method calls on the MotionLayout instance.

An Android Studio Java Navigation Component Tutorial

The previous chapter described the Android Jetpack Navigation Component and how it integrates with the navigation graphing features of Android Studio to provide an easy way to implement navigation between the screens of an Android app. In this chapter, a new Android Studio project will be created that uses these navigation features to implement an example app containing multiple screens. In addition to demonstrating the use of the Android Studio navigation graph editor, the example project will also implement the passing of data between origin and destination screens using type-safe arguments.

Creating the NavigationDemo Project

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

Enter NavigationDemo into the Name field and specify com.ebookfrenzy.navigationdemo 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 Java.

Adding Navigation to the Build Configuration

A new Empty Views Activity project does not include the Navigation component libraries in the build configuration files by default. Before performing any other tasks, therefore, the first step is to modify the app level build.gradle.kts file. Locate this file in the Project tool window (Gradle Scripts -> build.gradle.kts (Module :app)), double-click on it to load it into the code editor, and modify the dependencies section to add the navigation libraries. Also, take this opportunity to enable view binding for this module:

android {
 
    buildFeatures {
        viewBinding = true
    }
.
.
dependencies {
    implementation ("androidx.navigation:navigation-fragment:2.6.0")
    implementation ("androidx.navigation:navigation-ui:2.6.0")
    implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0"))
.
.
}Code language: Gradle (gradle)

Note that newer versions of these libraries may have been released since this book was published. After adding the navigation dependencies to the file, click on the Sync Now link to resynchronize the build configuration for the project.

Creating the Navigation Graph Resource File

With the navigation libraries added to the build configuration, the navigation graph resource file can now be added to the project. As outlined in “An Overview of the Navigation Architecture Component”, this is an XML file containing the fragments and activities through which the user will be able to navigate, together with the actions to perform the transitions and any data to be passed between destinations.

Within the Project tool window, locate the res folder (app -> res), right-click on it, and select the New ->Android Resource File menu option:

Figure 41-1

After selecting the menu item, the New Resource File dialog will appear. In this dialog, name the file navigation_ graph and change the Resource type menu to Navigation as outlined in Figure 41-2 before clicking on the OK button to create the file.

Figure 41-2

After the navigation graph resource file has been added to the project, it will appear in the main panel, ready for adding new destinations. Switch the editor to Code mode and review the XML for the graph before any destinations are added:

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto" 
    android:id="@+id/navigation_graph">
 
</navigation>Code language: HTML, XML (xml)

Switch back to Design mode within the editor and note that the Host section of the Destinations panel indicates that no navigation host fragments have been detected within the project:

Figure 41-3

Before adding any destinations to the navigation graph, the next step is to add a navigation host fragment to the project.

Declaring a Navigation Host

For this project, the navigation host fragment will be contained within the user interface layout of the main activity. First, locate the main activity layout file in the Project tool window (app -> res -> layout -> activity_ main.xml), load it into the layout editor tool, and delete the default TextView component.

With the layout editor in Design mode, drag a NavHostFragment element from the Containers section of the Palette and drop it onto the container area of the activity layout, as indicated by the arrow in Figure 41-4:

Figure 41-4

Select the navigation_graph.xml file created in the previous section from the resulting Navigation Graphs dialog and click on the OK button.

With the newly added NavHostFragment instance selected in the layout, use the Attributes tool window to change the element’s ID to demo_nav_host_fragment before clicking on the Infer constraints button.

Switch the layout editor to Code mode and review the XML file. Note that the editor has correctly configured the navigation graph property to reference the navigation_graph.xml file and that the defaultNavHost property has been set to true:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/demo_nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="409dp"
        android:layout_height="729dp"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/navigation_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>Code language: HTML, XML (xml)

Return to the navigation_graph.xml file and confirm that the NavHostFragment instance has been detected (it may be necessary to close and reopen the file before the change appears):

Figure 41-5

Adding Navigation Destinations

Remaining in the navigation graph, it is time to add the first destination. Click on the new destination button as shown in Figure 41-6 to select or create a destination:

Figure 41-6

Next, select the Create new destination option from the menu. In the resulting dialog, select the Fragment (Blank) template, name the new fragment FirstFragment and the layout fragment_first before clicking on the Finish button. After a short delay while the project rebuilds, the new fragment will appear as a destination within the graph, as shown in Figure 41-7:

Figure 41-7

The home icon above the destination node indicates this is the start destination. This means the destination will be the first displayed when the NavHostFragment activity is created. To change the start destination to another, select that node in the graph and click on the home button in the toolbar.

Review the XML content of the navigation graph by switching the editor to Code mode:

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/navigation_graph"
    app:startDestination="@id/firstFragment">

    <fragment
        android:id="@+id/firstFragment"
        android:name="com.ebookfrenzy.navigationdemo.FirstFragment"
        android:label="fragment_first"
        tools:layout="@layout/fragment_first" />
</navigation>Code language: HTML, XML (xml)

Before any navigation can be performed, the graph needs at least one more destination. Repeat the above steps to add a fragment named SecondFragment with the layout file named fragment_second. The new fragment will appear as another destination within the graph, as shown in Figure 41-8:

Figure 41-8

Designing the Destination Fragment Layouts

Before adding actions to navigate between destinations, now is a good time to add some user interface components to the two destination fragments in the graph. Begin by double-clicking on the firstFragment destination so that the fragment_first.xml file loads into the layout editor, then select and delete the default TextView instance. Within the Component Tree panel, right-click on the FrameLayout entry and select the Convert from FrameLayout to ConstraintLayout menu option, accepting the default settings in the resulting conversion dialog:

Figure 41-9

Using the Attributes tool window, change the ID of the ConstraintLayout to constraintLayout, then drag and drop Button and Plain Text EditText widgets onto the layout so that it resembles that shown in Figure 41-10 below:

Figure 41-10

Once the views are correctly positioned, click on the Infer constraints button in the toolbar to add any missing constraints to the layout. Select the EditText view and use the Attributes tool window to delete the default “Name” text and change the widget’s ID to userText. Next, change the button text property to read “Navigate” and extract it to a string resource.

Return to the navigation_graph.xml file and double-click on the secondFragment destination to load the fragment_second.xml file into the layout editor. Select and delete the default TextView instance and repeat the above steps to convert the FrameLayout to a ConstraintLayout, changing the id to constraintLayout2. Next, drag and drop a new TextView widget to position it in the center of the layout and click on the Infer constraints button to add any missing constraints. With the new TextView selected, use the Attributes panel to change the ID to argText.

Adding an Action to the Navigation Graph

Now that the two destinations have been added to the graph and the corresponding user interface layouts are designed, the project needs a way for the user to navigate from the first fragment to the second. This will be achieved by adding an action to the graph, which can then be referenced from within the app code.

To establish an action connection with the first fragment as the origin and the second fragment as the destination, open the navigation graph and hover the mouse pointer over the vertical center of the right-hand edge of the firstFragment destination so that a circle appears as highlighted in Figure 41-11:

Figure 41-11

Click within the circle and drag the resulting line to the secondFragment destination:

Figure 41-12

Release the line to establish the action connection between the origin and destination, at which point the line will change into an arrow, as shown in Figure 41-13:

Figure 41-13

An action connection may be deleted anytime by selecting it and pressing the keyboard Delete key. With the arrow selected, review the properties available within the Attributes tool window and change the ID to mainToSecond. This is the ID by which the action will be referenced within the code. Switch the editor to Code mode and note that the action is now included within the XML:

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/navigation_graph"
    app:startDestination="@id/firstFragment">

    <fragment
        android:id="@+id/firstFragment"
        android:name="com.ebookfrenzy.navigationdemo.FirstFragment"
        android:label="fragment_first"
        tools:layout="@layout/fragment_first" >
        <action
            android:id="@+id/mainToSecond"
            app:destination="@id/secondFragment" />
    </fragment>
    <fragment
        android:id="@+id/secondFragment"
        android:name="com.ebookfrenzy.navigationdemo.SecondFragment"
        android:label="fragment_second"
        tools:layout="@layout/fragment_second" />
</navigation>Code language: HTML, XML (xml)

Implement the OnFragmentInteractionListener

Before adding code to trigger the action, the MainActivity class must be modified to implement the OnFragmentInteractionListener interface. This interface was generated within the Fragment classes when the blank fragments were created within the navigation graph editor. To conform to the interface, the activity needs a method named onFragmentInteraction() to implement communication between the fragment and the activity. Edit the MainActivity.java file and modify it so that it reads as follows:

.
.
import android.net.Uri;
.
.
public class MainActivity extends AppCompatActivity implements SecondFragment.OnFragmentInteractionListener {
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
 
    @Override
    public void onFragmentInteraction(Uri uri) {
    }
}Code language: Java (java)

If Android Studio reports that OnFragmentInteractionListener is undefined (some versions of Android Studio add it automatically, while others do not), edit the SecondFragment.java file and add the following:

.
.
import android.net.Uri;
.
.
   public interface OnFragmentInteractionListener {
        // TODO: Update argument type and name
        void onFragmentInteraction(Uri uri);
    }
.
.Code language: Java (java)

Adding View Binding Support to the Destination Fragments

Since we will access some views in the fragment layouts, we must modify the current code to enable view binding support. Begin by editing the FirstFragment.java file and making the following changes:

.
.
import com.ebookfrenzy.navigationdemo.databinding.FragmentFirstBinding;
.
.
public class FirstFragment extends Fragment {
 
    private FragmentFirstBinding binding;
.
. 
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
                             @Nullable Bundle savedInstanceState) {
        // return inflater.inflate(R.layout.fragment_first, container, false);
 
        binding = FragmentFirstBinding.inflate(inflater, container, false);
        return binding.getRoot();
    }
 
    @Override
    public void onDestroyView() {
        super.onDestroyView();
        binding = null;
    }
.
.Code language: Java (java)

Repeat the above steps for the SecondFragment.java file, referencing FragmentSecondBinding.

Triggering the Action

Now that the action has been added to the navigation graph, the next step is to add some code within the first fragment to trigger the action when the Button widget is clicked. Locate the FirstFragment.java file, load it into the code editor, and override the onViewCreated() method to obtain a reference to the button instance and to configure an onClickListener instance to be called when the user clicks the button:

.
.
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.widget.Button;
import androidx.navigation.Navigation;
.
.
public class FirstFragment extends Fragment  {
.
.
    @Override
    public void onViewCreated(@NonNull View view, 
                        @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
    
        binding.button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Navigation.findNavController(view).navigate(
                        R.id.mainToSecond);
            }
        });
    }
}Code language: Java (java)

The above code obtains a reference to the navigation controller and calls the navigate() method on that instance, passing through the resource ID of the navigation action as an argument.

Compile and run the app and verify that clicking the button in the first fragment transitions to the second fragment.

As an alternative to this approach to setting up a listener, the Navigation class also includes a method named createNavigateOnClickListener() which provides a more efficient way of setting up a listener and navigating to a destination. The same result can be achieved, therefore, using the following single line of code to initiate the transition:

binding.button.setOnClickListener(Navigation.createNavigateOnClickListener(
                                    R.id.mainToSecond, null));Code language: Java (java)

Passing Data Using Safeargs

The next objective is to pass the text entered into the EditText view in the first fragment to the second fragment, where it will be displayed on the TextView widget. As outlined in the previous chapter, the Android Navigation component supports two approaches to passing data. This chapter will make use of type-safe argument passing.

The first step in using safeargs is to add the safeargs plugin to the Gradle build configuration. Using the Project tool window, locate and edit the project-level build.gradle.kts file (Gradle Scripts -> build.gradle.kts (Project: NavigationDemo)) to add the plugin dependency as follows (once again, keeping in mind that a more recent version may now be available):

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
    dependencies {
        classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.6.0")
    }
}
 
plugins {
.
.Code language: Gradle (gradle)

Next, edit the module level build.gradle.kts file (Gradle Scripts -> build.gradle.kts (Module :app)) to apply the plugin as follows and resync the project:

plugins {
    id("com.android.application")
    id ("androidx.navigation.safeargs")
.
.
android {
.
.Code language: Gradle (gradle)

The next step is to define any arguments that will be received by the destination, which, in this case, is the second fragment. Edit the navigation graph, select the secondFragment destination, and locate the Arguments section within the Attributes tool window. Click on the + button (highlighted in Figure 41-14) to add a new argument to the destination:

Figure 41-14

After the + button has been clicked, a dialog will appear into which the argument name, type, and default value need to be entered. Name the argument message, set the type to String, enter No Message into the default value field, and click the Add button:

Figure 41-15

The newly configured argument will appear in the secondFragment element of the navigation_graph.xml file as follows:

<fragment
    android:id="@+id/secondFragment"
    android:name="com.ebookfrenzy.navigationdemo.SecondFragment"
    android:label="fragment_second"
    tools:layout="@layout/fragment_second" >
    <argument
        android:name="message"
        app:argType="string"
        android:defaultValue="No Message" />
</fragment>Code language: HTML, XML (xml)

The next step is to add code to the FirstFragment.java file to extract the text from the EditText view and pass it to the second fragment during the navigation action. This will involve using some special navigation classes generated automatically by the safeargs plugin. Currently, the navigation involves the FirstFragment class, the SecondFragment class, a navigation action named mainToSecond, and an argument named message.

When the project is built, the safeargs plugin will generate the following additional classes that can be used to pass and receive arguments during navigation.

  • FirstFragmentDirections – This class represents the origin for the navigation action (named using the class name of the navigation origin with “Directions” appended to the end) and provides access to the action object.
  • ActionMainToSecond – The class representing the action used to perform the transition (named based on the ID assigned to the action within the navigation graph file prefixed with “Action”). This class contains a setter method for each argument configured on the destination. For example, since the second fragment destination contains an argument named message, the class includes a method named setMessage(). Once configured, an instance of this class is then passed to the navigate() method of the navigation controller to navigate to the destination.
  • SecondFragmentArgs – The class used in the destination fragment to access the arguments passed from the origin (named using the class name of the navigation destination with “Args” appended to the end). This class includes a getter method for each of the arguments passed to the destination (i.e., getMessage())

Using these classes, the onClickListener code within the onViewCreated() method of the FirstFragment.java file can be modified as follows to extract the current text from the EditText widget, apply it to the action and initiate the transition to the second fragment:

.
.
binding.button.setOnClickListener(view1 -> {
    
    FirstFragmentDirections.MainToSecond action =
            FirstFragmentDirections.mainToSecond();
 
    action.setMessage(binding.userText.getText().toString());
    Navigation.findNavController(view1).navigate(action);
});Code language: Java (java)

The above code obtains a reference to the action object, sets the message argument string using the setMessage() method, and then calls the navigate() method of the navigation controller, passing through the action object. If Android Studio reports FirstFragmentDirections as undefined, rebuild the project using the Build -> Make Project menu option to generate the class.

All that remains is to modify the SecondFragment.java class file to receive the argument after the navigation has been performed and display it on the TextView widget. For this example, the code to achieve these tasks will be added using an onStart() lifecycle method. Edit the SecondFragment.java file and add this method so that it reads as follows:

.
.
@Override
public void onStart() {
    super.onStart();
 
    SecondFragmentArgs args = SecondFragmentArgs.fromBundle(getArguments());
    String message = args.getMessage();
    binding.argText.setText(message);
}Code language: Java (java)

The code in the above method begins by obtaining a reference to the TextView widget. Next, the fromBundle() method of the SecondFragmentArgs class is called to extract the SecondFragmentArgs object received from the origin. Since the argument in this example was named message in the navigation_graph.xml file, the corresponding getMessage() method is called on the args object to obtain the string value. This string is then displayed on the TextView widget.

Compile and run the app and enter some text before clicking on the Button widget. When the second fragment destination appears, the TextView should display the text entered in the first fragment, indicating that the data was successfully passed between navigation destinations.

Summary

This chapter has provided a practical example of implementing Android app navigation using the Navigation Architecture Component and the Android Studio navigation graph editor. Topics covered included the creation of a navigation graph containing both existing and new destination fragments, embedding a navigation host fragment within an activity layout, writing code to trigger navigation events, and passing arguments between destinations using the safeargs plugin.

The Android Navigation Architecture Component in Java

Very few Android apps today consist of just a single screen. In reality, most apps comprise multiple screens through which the user navigates using screen gestures, button clicks, and menu selections. Before the introduction of Android Jetpack, implementing navigation within an app was largely a manual coding process with no easy way to view and organize potentially complex navigation paths. However, this situation has improved considerably with the introduction of the Android Navigation Architecture Component combined with support for navigation graphs in Android Studio.

Understanding Navigation

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

Figure 40-1

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

Figure 40-2

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

Figure 40-3

All of the work involved in navigating between destinations and managing the navigation stack is handled by a navigation controller, represented by the NavController class.

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

Declaring a Navigation Host

A navigation host is a special fragment (NavHostFragment) embedded into the user interface layout of an activity and serves as a placeholder for the destinations through which the user will navigate. Figure 47-4, for example, shows a typical activity screen and highlights the area represented by the navigation host fragment:

Figure 40-4

A NavHostFragment can be placed into an activity layout within the Android Studio layout editor either by dragging and dropping an instance from the Containers section of the palette or by manually editing the XML as follows:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity" >
 
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/demo_nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/navigation_graph" />
</FrameLayout>Code language: HTML, XML (xml)

The points of note in the above navigation host fragment element are the reference to the NavHostFragment in the name property, the setting of defaultNavHost to true, and the assignment of the file containing the navigation graph to the navGraph property.

When the activity launches, this navigation host fragment is replaced by the home destination designated in the navigation graph. As the user navigates through the app screens, the host fragment will be replaced by the appropriate fragment for the destination.

The Navigation Graph

A navigation graph is an XML file that contains the destinations that will be included in the app navigation. In addition to these destinations, the file contains navigation actions that define navigation between destinations and optional arguments for passing data from one destination to another. Android Studio includes a navigation graph editor that can be used to design graphs and implement actions either visually or by manually editing the XML.

Figure 47-5 shows the Android Studio navigation graph editor in Design mode:

Figure 40-5

The destinations list (A) lists all destinations within the graph. Selecting a destination from the list will locate and select the corresponding destination in the graph (particularly useful for locating specific destinations in a large graph). The navigation graph panel (B) contains a dialog for each destination representing the user interface layout. In this example, this graph contains two destinations named mainFragment and secondFragment. Arrows between destinations (C) represent navigation action connections. Actions are added by hovering the mouse pointer over the edge of the origin until a circle appears, then clicking and dragging from the circle to the destination. The Attributes panel (D) allows the properties of the currently selected destination or action connection to be viewed and modified. In the above figure, the attributes for the action are displayed. New destinations are added by clicking on the button marked E and selecting options from a menu. Options are available to add existing fragments or activities as destinations or to create new blank fragment destinations. The Component Tree panel (F) provides a hierarchical overview of the navigation graph.

The underlying XML for the navigation graph can be viewed and modified by switching the editor into Code mode. The following XML listing represents the navigation graph for the destinations and action connection shown in Figure 47-5 above:

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/navigation_graph"
    app:startDestination="@id/mainFragment">
 
    <fragment
        android:id="@+id/mainFragment"
        android:name="com.ebookfrenzy.navigationdemo.ui.main.MainFragment"
        android:label="fragment_main"
        tools:layout="@layout/fragment_main" >
        <action
            android:id="@+id/mainToSecond"
            app:destination="@id/secondFragment" />
    </fragment>
    <fragment
        android:id="@+id/secondFragment"
        android:name="com.ebookfrenzy.navigationdemo.SecondFragment"
        android:label="fragment_second"
        tools:layout="@layout/fragment_second" >
    </fragment>
</navigation>Code language: HTML, XML (xml)

Navigation graphs can also be split over multiple files to improve organization and promote reuse. When structured in this way, nested graphs are embedded into root graphs. To create a nested graph, shift-click on the destinations to be nested, right-click over the first destination and select the Move to Nested Graph -> New Graph menu option. The nested graph will then appear as a new node in the graph. Double-click on the nested graph node to load the graph file into the editor to access the nested graph.

Accessing the Navigation Controller

Navigating from one destination to another usually occurs in response to an event within an app, such as a button click or menu selection. Before a navigation action can be triggered, the code must first obtain a reference to the navigation controller instance. This requires a call to the findNavController() method of the Navigation or NavHostFragment classes. The following code, for example, can be used to access the navigation controller of an activity. Note that for the code to work, the activity must contain a navigation host fragment:

NavController controller = Navigation.findNavController(activity, R.id.demo_nav_host_fragment);Code language: Java (java)

In this case, the method call is passed a reference to the activity and the id of the NavHostFragment embedded in the activity’s layout.

Alternatively, the navigation controller associated with any view may be identified by passing that view to the method:

NavController controller = Navigation.findNavController(binding.button);Code language: Java (java)

The final option finds the navigation controller for a fragment by calling the findNavController() method of the NavHostFragment class, passing through a reference to the fragment:

NavController controller = NavHostFragment.findNavController(fragment);Code language: Java (java)

Triggering a Navigation Action

Once the navigation controller has been found, a navigation action is triggered by calling the controller’s navigate() method and passing through the resource id of the action to be performed. For example:

controller.navigate(R.id.goToContactsList);Code language: Java (java)

The id of the action is defined within the Attributes panel of the navigation graph editor when an action connection is selected.

Passing Arguments

Data may be passed from one destination to another during a navigation action by using arguments declared within the navigation graph file. An argument consists of a name, type, and an optional default value and may be added manually within the XML or using the Attributes panel when an action arrow or destination is selected within the graph. In Figure 47-6, for example, an integer argument named contactsCount has been declared with a default value of 0:

Figure 40-6

Once added, arguments are placed within the XML element of the receiving destination, for example:

<fragment
    android:id="@+id/secondFragment"
    android:name="com.ebookfrenzy.navigationdemo.SecondFragment"
    android:label="fragment_second"
    tools:layout="@layout/fragment_second" >
    <argument
        android:name="contactsCount"
        android:defaultValue=0
        app:type="integer" />
</fragment>Code language: HTML, XML (xml)

The Navigation Architecture Component provides two techniques for passing data between destinations. One approach involves placing the data into a Bundle object that is passed to the destination during an action, where it is then unbundled and the arguments extracted.

The main drawback to this particular approach is that it is not “type safe”. In other words, if the receiving destination treats an argument as a different type than it was declared (for example, treating a string as an integer), this error will not be caught by the compiler and will likely cause problems at runtime.

A better option, which is used in this book, is safeargs. Safeargs is a plugin for the Android Studio Gradle build system which automatically generates special classes that allow arguments to be passed in a type-safe way. The safeargs approach to argument passing will be described and demonstrated in the next chapter (“An Android Jetpack Navigation Component Tutorial”).

Summary

Navigation within the context of an Android app user interface refers to the ability of a user to move back and forth between different screens. Once time-consuming to implement and difficult to organize, Android Studio and the Navigation Architecture Component now make it easier to implement and manage navigation within Android app projects.

The different screens within an app are referred to as destinations and are usually represented by fragments or activities. All apps have a home destination, including the screen displayed when the app first loads. The content area of this layout is replaced by a navigation host fragment which is swapped out for other destination fragments as the user navigates the app. The navigation path is defined by the navigation graph file consisting of destinations and the actions that connect them together with any arguments to be passed between destinations. Navigation is handled by navigation controllers, which, in addition to managing the navigation stack, provide methods to initiate navigation actions from within app code.

A Java Android Jetpack Lifecycle Awareness Tutorial

The previous chapter provided an overview of lifecycle awareness and outlined the key classes and interfaces that make this possible within an Android app project. This chapter will build on this knowledge base by building an Android Studio project to highlight lifecycle awareness in action.

Creating the Example Lifecycle Project

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

Enter LifecycleDemo into the Name field and specify com.ebookfrenzy.lifecycledemo 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 Java.

Creating a Lifecycle Observer

As previously discussed, activities and fragments already implement the LifecycleOwner interface and are ready to be observed by other objects. To see this in practice, the next step in this tutorial is to add a new class to the project that will be able to observe the MainActivity instance.

To add the new class, right-click on app -> java -> com.ebookfrenzy.lifecycledemo in the Project tool window and select New -> Java Class… from the resulting menu. In the New Class dialog, name the class DemoObserver and press the keyboard Return key to create the DemoObserver.java file. The new file should automatically open in the editor, where it will read as follows:

package com.ebookfrenzy.lifecycledemo;
 
public class DemoObserver {
}Code language: Java (java)

Remaining in the editor, modify the class file to declare that it will be implementing the DefaultLifecycleObserver interface:

package com.ebookfrenzy.lifecycledemo;
 
import androidx.lifecycle.DefaultLifecycleObserver;
 
public class DemoObserver implements DefaultLifecycleObserver {
 Code language: Java (java)

The next step is to override the lifecycle methods of the DefaultLifecycleObserver class. For this example, all events will be handled, each outputting a message to the Logcat panel displaying the event type. Update the observer class as outlined in the following listing:

package com.ebookfrenzy.lifecycledemo;
 
import android.util.Log;
 
import androidx.annotation.NonNull; 
import androidx.lifecycle.LifecycleOwner;

.
.
public class DemoObserver implements DefaultLifecycleObserver {
 
    private String LOG_TAG = "DemoObserver";
 
    @Override
    public void onCreate(@NonNull LifecycleOwner owner) {
        Log.i(LOG_TAG, "onCreate");
    }
 
    @Override
    public void onResume(@NonNull LifecycleOwner owner) {
        Log.i(LOG_TAG, "onResume");
    }
 
    @Override
    public void onPause(@NonNull LifecycleOwner owner) {
        Log.i(LOG_TAG, "onPause");
    }
 
    @Override
    public void onStart(@NonNull LifecycleOwner owner) {
        Log.i(LOG_TAG, "onStart");
    }
 
    @Override
    public void onStop(@NonNull LifecycleOwner owner) {
        Log.i(LOG_TAG, "onStop");
    }
 
    @Override
    public void onDestroy(@NonNull LifecycleOwner owner) {
        Log.i(LOG_TAG, "onDestroy");
    }
}Code language: Java (java)

With the DemoObserver class completed, the next step is to add it as an observer on the MainActivity class.

Adding the Observer

Observers are added to lifecycle owners via calls to the addObserver() method of the owner’s Lifecycle object, a reference to which is obtained via a call to the getLifecycle() method. Edit the MainActivity.java class file and edit the onCreate() method to add an observer:

.
.
import com.ebookfrenzy.lifecycledemo.DemoObserver;
.
.
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);
 
    getLifecycle().addObserver(new DemoObserver());
}Code language: Java (java)

With the observer class created and added to the lifecycle owner’s Lifecycle object, the app is ready to be tested.

Testing the Observer

Since the DemoObserver class outputs diagnostic information to the Logcat console, it will be easier to see the output if a filter is configured to display only the DemoObserver messages. Using the steps outlined previously in Android Activity State Changes, display the Logcat panel and enter the following keys into the filter field:

package:mine tag:DemoObserverCode language: plaintext (plaintext)

On successful launch of the app, the Logcat output should indicate the following lifecycle state changes and events:

onCreate
onStart
onResumeCode language: plaintext (plaintext)

With the app still running, perform a device rotation to trigger the destruction and recreation of the activity, generating the following additional output:

onPause
onStop
onDestroy
onCreate
onStart
onResumeCode language: plaintext (plaintext)

Before moving to the next section in this chapter, take some time to compare the output from the app with the flow chart in Figure 45-2 of the previous chapter.

Creating a Lifecycle Owner

The final task in this chapter is to create a custom lifecycle owner class and demonstrate how to trigger events and modify the lifecycle state from within that class.

Add a new class by right-clicking on the app -> java -> com.ebookfrenzy.lifecycledemo entry in the Project tool window and selecting the New -> Java Class… menu option. Name the class DemoOwner in the Create Class dialog before tapping the keyboard Return key. With the new DemoOwner.java file loaded into the code editor, modify it as follows:

package com.ebookfrenzy.lifecycledemo;
 
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LifecycleRegistry;
 
import androidx.annotation.NonNull;
 
public class DemoOwner implements LifecycleOwner {
}Code language: Java (java)

The class will need a LifecycleRegistry instance initialized with a reference to itself and a getLifecycle() method configured to return the LifecycleRegistry instance. Declare a variable to store the LifecycleRegistry reference, a constructor to initialize the LifecycleRegistry instance, and add the getLifecycle() method:

public class DemoOwner implements LifecycleOwner {
 
    private final LifecycleRegistry lifecycleRegistry;
 
    public DemoOwner() {
        lifecycleRegistry = new LifecycleRegistry(this);
    }
 
    @NonNull
    @Override
    public Lifecycle getLifecycle() {
        return lifecycleRegistry;
    }
}Code language: Java (java)

Next, the class must notify the registry of lifecycle state changes. This can be achieved by marking the state with the markState() method of the LifecycleRegistry object or by triggering lifecycle events using the handleLifecycleEvent() method. What constitutes a state change within a custom class will depend on the purpose of the class. For this example, we will add some methods that trigger lifecycle events when called:

.
.
private final LifecycleRegistry lifecycleRegistry;
.
.
    public void startOwner() {
        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START);
    }
 
    public void stopOwner() {
        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP);
    }
 
    @NonNull 
    @Override
    public Lifecycle getLifecycle() {
        return lifecycleRegistry;
    }
.
.Code language: Java (java)

The last change within the DemoOwner class is to add the DemoObserver class as the observer. This call will be made by adding the following constructor to the class:

public DemoOwner() {
    lifecycleRegistry = new LifecycleRegistry(this);
    getLifecycle().addObserver(new DemoObserver());
}Code language: Java (java)

Load the MainActivity.java file into the code editor, locate the onCreate() method, and add code to create an instance of the DemoOwner class and to call the startOwner() and stopOwner() methods. Note also that the call to add the DemoObserver as an observer has been removed. Although a single observer can be used with multiple owners, it is removed in this case to avoid duplicated and confusing output within the Logcat tool window:

.
.
import com.ebookfrenzy.lifecycledemo.DemoOwner;
.
.
private DemoOwner demoOwner;
.
.
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);
 
    demoOwner = new DemoOwner();
    demoOwner.startOwner();
    demoOwner.stopOwner();
    getLifecycle().addObserver(new DemoObserver());
}Code language: Java (java)

Testing the Custom Lifecycle Owner

Build and run the app one final time, refer to the Logcat tool window, and confirm that the observer detected the create, start, and stop lifecycle events in the following order:

onCreate
onStart
onStopCode language: plaintext (plaintext)

Note that the “created” state changes were triggered even though code was not added to the DemoOwner class to do this manually. These were triggered automatically when the owner instance was first created and when the ON_STOP event was handled.

Summary

This chapter has provided a practical demonstration of implementing lifecycle awareness within an Android app, including creating a lifecycle observer and designing and implementing a basic lifecycle owner class.

Working with Android Lifecycle-Aware Components in Java

The earlier chapter, Android App and Activity Lifecycles described the use of lifecycle methods to track lifecycle state changes within a UI controller such as an activity or fragment. One of the main problems with these methods is that they place the burden of handling lifecycle changes onto the UI controller. On the surface, this might seem like the logical approach since the UI controller is, after all, the object going through the state change. However, the fact is that the code typically impacted by the state change invariably resides in other classes within the app. This led to complex code appearing in the UI controller that needed to manage and manipulate other objects in response to changes in the lifecycle state. This scenario is best avoided when following the Android architectural guidelines.

A much cleaner and more logical approach would be for the objects within an app to be able to observe the lifecycle state of other objects and to be responsible for taking any necessary actions in response to the changes. For example, the class responsible for tracking a user’s location could observe the lifecycle state of a UI controller and suspend location updates when the controller enters a paused state. Tracking would then be restarted when the controller enters the resumed state. This is made possible by the classes and interfaces provided by the Lifecycle package bundled with the Android architecture components.

This chapter will introduce the terminology and key components that enable lifecycle awareness to be built into Android apps.

Lifecycle Awareness

An object is said to be lifecycle-aware if it can detect and respond to changes in the lifecycle state of other objects within an app. Some Android components, LiveData being a prime example, are already lifecycle-aware. Configuring any class to be lifecycle-aware is also possible by implementing the LifecycleObserver interface within the class.

Lifecycle Owners

Lifecycle-aware components can only observe the status of objects that are lifecycle owners. Lifecycle owners implement the LifecycleOwner interface and are assigned a companion Lifecycle object responsible for storing the current state of the component and providing state information to lifecycle observers. Most standard Android Framework components (such as activity and fragment classes) are lifecycle owners. Custom classes may also be configured as lifecycle owners using the LifecycleRegistry class and implementing the LifecycleObserver interface. For example:

public class SampleOwner implements LifecycleOwner {
 
    private LifecycleRegistry lifecycleRegistry;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
 
        lifecycleRegistry = new LifecycleRegistry(this);
    }
 
    @NonNull
    @Override
    public Lifecycle getLifecycle() {
        return lifecycleRegistry;
    }
}Code language: Java (java)

Unless the lifecycle owner is a subclass of another lifecycle-aware component, the class will need to trigger lifecycle state changes via calls to methods of the LifecycleRegistry class. The markState() method can be used to trigger a lifecycle state change passing through the new state value:

public void resuming() {
    lifecycleRegistry.markState(Lifecycle.State.RESUMED);
}Code language: Java (java)

The above call will also result in a call to the corresponding event handler. Alternatively, the LifecycleRegistry handleLifecycleEvent() method may be called and passed the lifecycle event to be triggered (which will also result in the lifecycle state changing). For example:

lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START);Code language: Java (java)

Lifecycle Observers

To observe the state of a lifecycle owner, a lifecycle-aware component must implement the DefaultLifecycleObserver interface and override methods for any lifecycle change events it needs to observe.

public class SampleObserver implements DefaultLifecycleObserver {
    // Lifecycle event methods overrides go here
}Code language: Java (java)

An instance of this observer class is then created and added to the list of observers maintained by the Lifecycle object.

getLifecycle().addObserver(new SampleObserver());Code language: Java (java)

An observer may be removed from the Lifecycle object anytime if it no longer needs to track the lifecycle state. Figure 45-1 illustrates the relationship between the key elements that provide lifecycle awareness:

Figure 38-1

Lifecycle States and Events

When the status of a lifecycle owner changes, the assigned Lifecycle object will be updated with the new state. At any given time, a lifecycle owner will be in one of the following five states:

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

The Lifecycle object will trigger events on any observers added to the list as the component transitions through the different states. The following event methods are available to be overridden within the lifecycle observer: • onCreate()

  • onResume()
  • onPause()
  • onStop()
  • onStart()
  • onDestroy()

The following code, for example, overrides the DefaultLifecycleObserver onResume() method:

@Override
public void onResume(@NonNull LifecycleOwner owner) {
    // Perform tasks in response to Resume status event
}Code language: Java (java)

The flowchart in Figure 45-2 illustrates the sequence of state changes for a lifecycle owner and the lifecycle events that will be triggered on observers between each state transition:

Figure 38-2

Summary

This chapter has introduced the basics of lifecycle awareness and the classes and interfaces of the Android Lifecycle package included with Android Jetpack. The package contains several classes and interfaces for creating lifecycle owners, observers, and lifecycle-aware components. A lifecycle owner has assigned to it a Lifecycle object that maintains a record of the owner’s state and a list of subscribed observers. When the owner’s state changes, the observer is notified via lifecycle event methods to respond to the change.

The next chapter will create an Android Studio project that demonstrates how to work with and create lifecycle-aware components, including the creation of both lifecycle observers and owners and the handling of lifecycle state changes and events.

Saving ViewModel Saved State in Java Tutorial

The preservation and restoration of app state is about presenting the user with continuity in appearance and behavior after an app is placed in the background. Users expect to be able to switch from one app to another and, on returning to the original app, find it in the exact state it was in before the switch took place.

As outlined in the chapter entitled Android App and Activity Lifecycles, when the user places an app in the background, that app becomes eligible for termination by the operating system if resources become constrained. When the user attempts to return the terminated app to the foreground, Android relaunches the app in a new process. Since this is all invisible to the user, it is the app’s responsibility to restore itself to the same state it was in when it was originally placed in the background instead of presenting itself in its “initial launch” state. In the case of ViewModel-based apps, much of this behavior can be achieved using the ViewModel Saved State module.

Understanding ViewModel State Saving

As outlined in the previous chapters, the ViewModel brings many benefits to app development, including UI state restoration in the event of configuration changes such as a device rotation. To see this in action, run the ViewModelDemo app (or, if you still need to create the project, load into Android Studio the ViewModelDemo_ LiveData project from the sample code download accompanying the book).

Once running, enter a dollar value and convert it to euros. With both the dollar and euro values displayed, rotate the device or emulator and note that both values are still visible once the app has responded to the orientation change.

Unfortunately, this behavior does not extend to the termination of a background app process. With the app still running, tap the device home button to place the ViewModelDemo app in the background, then terminate it by opening the Terminal tool window and running the following command (where <package name> is the name you used when the project was created, for example, com.ebookfrenzy.viewmodeldemo):

adb shell am kill <package name>Code language: plaintext (plaintext)

If the adb command is not found, refer to the chapter “Setting up an Android Studio Development Environment” for steps to set up your Android Studio environment.

Once the app has been terminated, return to the device or emulator and select the app from the launcher (do not re-run the app from within Android Studio). Once the app appears, it will do so as if it was just launched, with the last dollar and euro values lost. From the user’s perspective, however, the app was restored from the background and should still have contained the original data. In this case, the app has failed to provide the continuity that users have come to expect from Android apps.

Implementing ViewModel State Saving

Basic ViewModel state saving is made possible through the introduction of the ViewModel Saved State library. This library extends the ViewModel class to include support for maintaining state through the termination and subsequent relaunch of a background process.

The key to saving state is the SavedStateHandle class which is used to save and restore the state of a view model instance. A SavedStateHandle object contains a key-value map that allows data values to be saved and restored by referencing corresponding keys.

To support state saving, a different kind of ViewModel subclass needs to be declared, in this case containing a constructor which can receive a SavedStateHandle instance. Once declared, ViewModel instances of this type can be created by including a SavedStateViewModelFactory object at creation time. Consider the following code excerpt from a standard ViewModel declaration:

package com.ebookfrenzy.viewmodeldemo.ui.main;
 
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.MutableLiveData;
 
public class MainViewModel extends ViewModel {
.
.
}Code language: Java (java)

The code to create an instance of this class would likely resemble the following:

private MainViewModel mViewModel;
 
mViewModel = new ViewModelProvider(this).get(MainViewModel.class);
Code language: Java (java)

A ViewModel subclass designed to support saved state, on the other hand, would need to be declared as follows:

package com.ebookfrenzy.viewmodeldemo.ui.main;
 
import android.util.Log;
 
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.SavedStateHandle;
 
public class MainViewModel extends ViewModel {
 
    private SavedStateHandle savedStateHandle;
 
    public MainViewModel(SavedStateHandle savedStateHandle) {
        this.savedStateHandle = savedStateHandle;
    }
.
.
}Code language: Java (java)

When instances of the above ViewModel are created, the ViewModelProvider class initializer must be passed a SavedStateViewModelFactory instance as follows:

SavedStateViewModelFactory factory =
       new SavedStateViewModelFactory(getActivity().getApplication(),this);
 
mViewModel = new ViewModelProvider(this).get(MainViewModel.class);Code language: Java (java)

Saving and Restoring State

An object or value can be saved from within the ViewModel by passing it through to the set() method of the SavedStateHandle instance, providing the key string by which it is to be referenced when performing a retrieval:

private static final String NAME_KEY = "Customer Name";
 
savedStateHandle.set(NAME_KEY, customerName);
Code language: Java (java)

When used with LiveData objects, a previously saved value may be restored using the getLiveData() method of the SavedStateHandle instance, once again referencing the corresponding key as follows:

MutableLiveData<String> restoredName = savedStateHandle.getLiveData(NAME_KEY);Code language: Java (java)

To restore a normal (non-LiveData) object, use the SavedStateHandle get() method:

String restoredName = savedStateHandle.get(NAME_KEY);Code language: Java (java)

Other useful SavedStateHandle methods include the following:

  • contains(String key) – Returns a boolean value indicating whether the saved state contains a value for the specified key.
  • remove(String key) – Removes the value and key from the saved state. Returns the value that was removed. • keys() – Returns a String set of all keys contained within the saved state.

Adding Saved State Support to the ViewModelDemo Project

With the basics of ViewModel Saved State covered, the ViewModelDemo app can be extended to include this support. Begin by loading the ViewModelDemo_LiveData project created in An Android Studio Java LiveData Tutorial into Android Studio (a copy of the project is also available in the sample code download), opening the build.gradle.kts (Module :app) file and adding the Saved State library dependencies (checking, as always, if more recent library versions are available):

.
.
dependencies {
.
.    
    implementation ("androidx.savedstate:savedstate:1.2.1")
    implementation ("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1")
.
.
}Code language: Gradle (gradle)

Next, modify the MainViewModel.java file so the constructor accepts and stores a SavedStateHandle instance. Also, import androidx.lifecycle.SavedStateHandle, declare a key string constant and modify the result LiveData variable so that the value is now obtained from the saved state in the constructor:

package com.ebookfrenzy.viewmodeldemo.ui.main;
 
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.SavedStateHandle;
 
public class MainViewModel extends ViewModel {
 
    private static final String RESULT_KEY = "Euro Value";
    private static final Float rate = 0.74F;
    private String dollarText = "";
    final private SavedStateHandle savedStateHandle;
    final private MutableLiveData<Float> result = new MutableLiveData<>();
 
    public MainViewModel(SavedStateHandle savedStateHandle) {
        this.savedStateHandle = savedStateHandle;
        result = savedStateHandle.getLiveData(RESULT_KEY);
    }
.
.
}Code language: Java (java)

Remaining within the MainViewModel.java file, modify the setAmount() method to include code to save the result value each time a new euro amount is calculated:

public void setAmount(String value) {
    this.dollarText = value;
    result.setValue(Float.valueOf(dollarText)* rate);
    Float convertedValue = Float.parseFloat(dollarText)* rate;
    result.setValue(convertedValue);
    savedStateHandle.set(RESULT_KEY, convertedValue);
}Code language: Java (java)

With the changes to the ViewModel complete, open the FirstFragment.java file and make the following alterations to include a Saved State factory instance during the ViewModel creation process:

.
.
import androidx.lifecycle.SavedStateViewModelFactory;
.
.
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
 
    SavedStateViewModelFactory factory =
            new SavedStateViewModelFactory(
                    getActivity().getApplication(),this);
 
    mViewModel = new ViewModelProvider(this, factory).get(MainViewModel.class);
    // TODO: Use the ViewModel
}Code language: Java (java)

With the screen UI populated with dollar and euro values, place the app into the background, terminate it using the adb tool, and then relaunch it from the device or emulator screen. After restarting, the previous currency amounts should still be visible in the TextView and EditText components, confirming that the state was successfully saved and restored.

Summary

A well-designed app should always present the user with the same state when brought forward from the background, regardless of whether the operating system terminated the process containing the app in the interim. When working with ViewModels, this can be achieved by taking advantage of the ViewModel Saved State module. This involves modifying the ViewModel constructor to accept a SavedStateHandle instance which, in turn, can be used to save and restore data values via a range of method calls. When the ViewModel instance is created, it must be passed a SavedStateViewModelFactory instance. Once these steps have been implemented, the app will automatically save and restore state during a background termination.