Android Kotlin Fundamentals 06.3: Use LiveData to control button states

This codelab is part of the Android Kotlin Fundamentals course. You'll get the most value out of this course if you work through the codelabs in sequence. All the course codelabs are listed on the Android Kotlin Fundamentals codelabs landing page.

Introduction

This codelab recaps how to use ViewModel and fragments together to implement navigation. Remember that the goal is to put the logic of when to navigate into the ViewModel, but define the paths in the fragments and the navigation file. To achieve this goal, you use view models, fragments, LiveData, and observers.

The codelab concludes by showing a clever way to track button states with minimal code, so that each button is enabled and clickable only when it makes sense for the user to tap that button.

What you should already know

You should be familiar with:

  • Building a basic user interface (UI) using an activity, fragments, and views.
  • Navigating between fragments and using safeArgs to pass data between fragments.
  • View models, view model factories, transformations, and LiveData and their observers.
  • How to create a Room database, create a data access object (DAO), and define entities.
  • How to use coroutines for database interactions and other long-running tasks.

What you'll learn

  • How to update an existing sleep-quality record in the database.
  • How to use LiveData to track button states.
  • How to display a snackbar in response to an event.

What you'll do

  • Extend the TrackMySleepQuality app to collect a quality rating, add the rating to the database, and display the result.
  • Use LiveData to trigger the display of a snackbar.
  • Use LiveData to enable and disable buttons.

In this codelab, you build the sleep-quality recording and finalized UI of the TrackMySleepQuality app.

The app has two screens, represented by fragments, as shown in the figure below.

The first screen, shown on the left, has buttons to start and stop tracking. The screen shows all the user's sleep data. The Clear button permanently deletes all the data that the app has collected for the user.

The second screen, shown on the right, is for selecting a sleep-quality rating. In the app, the rating is represented numerically. For development purposes, the app shows both the face icons and their numerical equivalents.

The user's flow is as follows:

  • User opens the app and is presented with the sleep-tracking screen.
  • User taps the Start button. This records the starting time and displays it. The Start button is disabled, and the Stop button is enabled.
  • User taps the Stop button. This records the ending time and opens the sleep-quality screen.
  • User selects a sleep-quality icon. The screen closes, and the tracking screen displays the sleep-ending time and sleep quality. The Stop button is disabled and the Start button is enabled. The app is ready for another night.
  • The Clear button is enabled whenever there is data in the database. When the user taps the Clear button, all their data is erased without recourse—there is no "Are you sure?" message.

This app uses a simplified architecture, as shown below in the context of the full architecture. The app uses only the following components:

  • UI controller
  • View model and LiveData
  • A Room database

This codelab assumes that you know how to implement navigation using fragments and the navigation file. To save you work, a good deal of this code is provided.

Step 1: Inspect the code

  1. To get started, continue with your own code from the end of the last codelab, or download the starter code.
  2. In your starter code, inspect the SleepQualityFragment. This class inflates the layout, gets the application, and returns binding.root.
  3. Open navigation.xml in the design editor. You see that there is a navigation path from SleepTrackerFragment to SleepQualityFragment, and back again from SleepQualityFragment to SleepTrackerFragment.



  4. Inspect the code for navigation.xml. In particular, look for the <argument> named sleepNightKey.

    When the user goes from the SleepTrackerFragment to the SleepQualityFragment, the app will pass a sleepNightKey to the SleepQualityFragment for the night that needs to be updated.

Step 2: Add navigation for sleep-quality tracking

The navigation graph already includes the paths from the SleepTrackerFragment to the SleepQualityFragment and back again. However, the click handlers that implement the navigation from one fragment to the next are not coded yet. You add that code now in the ViewModel.

In the click handler, you set a LiveData that changes when you want the app to navigate to a different destination. The fragment observes this LiveData. When the data changes, the fragment navigates to the destination and tells the view model that it's done, which resets the state variable.

  1. Open SleepTrackerViewModel. You need to add navigation so that when the user taps the Stop button, the app navigates to the SleepQualityFragment to collect a quality rating.
  2. In SleepTrackerViewModel, create a LiveData that changes when you want the app to navigate to the SleepQualityFragment. Use encapsulation to only expose a gettable version of the LiveData to the ViewModel.

    You can put this code anywhere at the top level of the class body.
private val _navigateToSleepQuality = MutableLiveData<SleepNight>()

val navigateToSleepQuality: LiveData<SleepNight>
   get() = _navigateToSleepQuality
  1. Add a doneNavigating() function that resets the variable that triggers navigation.
fun doneNavigating() {
   _navigateToSleepQuality.value = null
}
  1. In the click handler for the Stop button, onStopTracking(), trigger the navigation to the SleepQualityFragment. Set the _navigateToSleepQuality variable at the end of the function as the last thing inside the launch{} block. Note that this variable is set to the night. When this variable has a value, the app navigates to the SleepQualityFragment, passing along the night.
_navigateToSleepQuality.value = oldNight
  1. The SleepTrackerFragment needs to observe _navigateToSleepQuality so that the app knows when to navigate. In the SleepTrackerFragment, in onCreateView(), add an observer for navigateToSleepQuality(). Note that the import for this is ambiguous and you need to import androidx.lifecycle.Observer.
sleepTrackerViewModel.navigateToSleepQuality.observe(this, Observer {
})

  1. Inside the observer block, navigate and pass along the ID of the current night, and then call doneNavigating(). If your import is ambiguous, import androidx.navigation.fragment.findNavController.
night ->
night?.let {
   this.findNavController().navigate(
           SleepTrackerFragmentDirections
                   .actionSleepTrackerFragmentToSleepQualityFragment(night.nightId))
   sleepTrackerViewModel.doneNavigating()
}
  1. Build and run your app. Tap Start, then tap Stop, which takes you to the SleepQualityFragment screen. To get back, use the system Back button.

In this task, you record the sleep quality and navigate back to the sleep tracker fragment. The display should update automatically to show the updated value to the user. You need to create a ViewModel and a ViewModelFactory, and you need to update the SleepQualityFragment.

Step 1: Create a ViewModel and a ViewModelFactory

  1. In the sleepquality package, create or open SleepQualityViewModel.kt.
  2. Create a SleepQualityViewModel class that takes a sleepNightKey and database as arguments. Just as you did for the SleepTrackerViewModel, you need to pass in the database from the factory. You also need to pass in the sleepNightKey from the navigation.
class SleepQualityViewModel(
       private val sleepNightKey: Long = 0L,
       val database: SleepDatabaseDao) : ViewModel() {
}
  1. Inside the SleepQualityViewModel class, define a Job and uiScope, and override onCleared().
private val viewModelJob = Job()
private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)

override fun onCleared() {
   super.onCleared()
   viewModelJob.cancel()
}
  1. To navigate back to the SleepTrackerFragment using the same pattern as above, declare _navigateToSleepTracker. Implement navigateToSleepTracker and doneNavigating().
private val _navigateToSleepTracker = MutableLiveData<Boolean?>()

val navigateToSleepTracker: LiveData<Boolean?>
   get() = _navigateToSleepTracker

fun doneNavigating() {
   _navigateToSleepTracker.value = null
}
  1. Create one click handler, onSetSleepQuality(), for all the sleep-quality images to use.

    Use the same coroutine pattern as in the previous codelab:
  • Launch a coroutine in the uiScope, and switch to the I/O dispatcher.
  • Get tonight using the sleepNightKey.
  • Set the sleep quality.
  • Update the database.
  • Trigger navigation.

Notice that the code sample below does all the work in the click handler, instead of factoring out the database operation in the different context.

fun onSetSleepQuality(quality: Int) {
        uiScope.launch {
            // IO is a thread pool for running operations that access the disk, such as
            // our Room database.
            withContext(Dispatchers.IO) {
                val tonight = database.get(sleepNightKey) ?: return@withContext
                tonight.sleepQuality = quality
                database.update(tonight)
            }

            // Setting this state variable to true will alert the observer and trigger navigation.
            _navigateToSleepTracker.value = true
        }
    }
  1. In the sleepquality package, create or open SleepQualityViewModelFactory.kt and add the SleepQualityViewModelFactory class, as shown below. This class uses a version of the same boilerplate code you've seen before. Inspect the code before you move on.
class SleepQualityViewModelFactory(
       private val sleepNightKey: Long,
       private val dataSource: SleepDatabaseDao) : ViewModelProvider.Factory {
   @Suppress("unchecked_cast")
   override fun <T : ViewModel?> create(modelClass: Class<T>): T {
       if (modelClass.isAssignableFrom(SleepQualityViewModel::class.java)) {
           return SleepQualityViewModel(sleepNightKey, dataSource) as T
       }
       throw IllegalArgumentException("Unknown ViewModel class")
   }
}

Step 2: Update the SleepQualityFragment

  1. Open SleepQualityFragment.kt.
  2. In onCreateView(), after you get the application, you need to get the arguments that came with the navigation. These arguments are in SleepQualityFragmentArgs. You need to extract them from the bundle.
val arguments = SleepQualityFragmentArgs.fromBundle(arguments!!)
  1. Next, get the dataSource.
val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
  1. Create a factory, passing in the dataSource and the sleepNightKey.
val viewModelFactory = SleepQualityViewModelFactory(arguments.sleepNightKey, dataSource)
  1. Get a ViewModel reference.
val sleepQualityViewModel =
       ViewModelProviders.of(
               this, viewModelFactory).get(SleepQualityViewModel::class.java)
  1. Add the ViewModel to the binding object. (If you see an error with the binding object, ignore it for now.)
binding.sleepQualityViewModel = sleepQualityViewModel
  1. Add the observer. When prompted, import androidx.lifecycle.Observer.
sleepQualityViewModel.navigateToSleepTracker.observe(this, Observer {
   if (it == true) { // Observed state is true.
       this.findNavController().navigate(
               SleepQualityFragmentDirections.actionSleepQualityFragmentToSleepTrackerFragment())
       sleepQualityViewModel.doneNavigating()
   }
})

Step 3: Update the layout file and run the app

  1. Open the fragment_sleep_quality.xml layout file. In the <data> block, add a variable for the SleepQualityViewModel.
 <data>
       <variable
           name="sleepQualityViewModel"
           type="com.example.android.trackmysleepquality.sleepquality.SleepQualityViewModel" />
   </data>
  1. For each of the six sleep-quality images, add a click handler like the one below. Match the quality rating to the image.
android:onClick="@{() -> sleepQualityViewModel.onSetSleepQuality(5)}"
  1. Clean and rebuild your project. This should resolve any errors with the binding object. Otherwise, clear the cache (File > Invalidate Caches / Restart) and rebuild your app.

Congratulations! You just built a complete Room database app using coroutines.

Now your app works great. The user can tap Start and Stop as many times as they want. When the user taps Stop, they can enter a sleep quality. When the user taps Clear, all the data is cleared silently in the background. However, all the buttons are always enabled and clickable, which does not break the app, but it does allow users to create incomplete sleep nights.

In this last task, you learn how to use transformation maps to manage button visibility so that users can only make the right choice. You can use a similar method to display a friendly message after all data has been cleared.

Step 1: Update button states

The idea is to set the button state so that in the beginning, only the Start button is enabled, which means it is clickable.

After the user taps Start, the Stop button becomes enabled and Start is not. The Clear button is only enabled when there is data in the database.

  1. Open the fragment_sleep_tracker.xml layout file.
  2. Add the android:enabled property to each button. The android:enabled property is a boolean value that indicates whether or not the button is enabled. (An enabled button can be tapped; a disabled button can't.) Give the property the value of a state variable that you'll define in a moment.

start_button:

android:enabled="@{sleepTrackerViewModel.startButtonVisible}"

stop_button:

android:enabled="@{sleepTrackerViewModel.stopButtonVisible}"

clear_button:

android:enabled="@{sleepTrackerViewModel.clearButtonVisible}"
  1. Open SleepTrackerViewModel and create three corresponding variables. Assign each variable a transformation that tests it.
  • The Start button should be enabled when tonight is null.
  • The Stop button should be enabled when tonight is not null.
  • The Clear button should only be enabled if nights, and thus the database, contains sleep nights.
val startButtonVisible = Transformations.map(tonight) {
   it == null
}
val stopButtonVisible = Transformations.map(tonight) {
   it != null
}
val clearButtonVisible = Transformations.map(nights) {
   it?.isNotEmpty()
}
  1. Run your app, and experiment with the buttons.

Step 2: Use a snackbar to notify the user

After the user clears the database, show the user a confirmation using the Snackbar widget. A snackbar provides brief feedback about an operation through a message at the bottom of the screen. A snackbar disappears after a timeout, after a user interaction elsewhere on the screen, or after the user swipes the snackbar off the screen.

Showing the snackbar is a UI task, and it should happen in the fragment. Deciding to show the snackbar happens in the ViewModel. To set up and trigger a snackbar when the data is cleared, you can use the same technique as for triggering navigation.

  1. In the SleepTrackerViewModel, create the encapsulated event.
private var _showSnackbarEvent = MutableLiveData<Boolean>()

val showSnackBarEvent: LiveData<Boolean>
   get() = _showSnackbarEvent
  1. Then implement doneShowingSnackbar().
fun doneShowingSnackbar() {
   _showSnackbarEvent.value = false
}
  1. In the SleepTrackerFragment, in onCreateView(), add an observer:
sleepTrackerViewModel.showSnackBarEvent.observe(this, Observer { })
  1. Inside the observer block, display the snackbar and immediately reset the event.
   if (it == true) { // Observed state is true.
       Snackbar.make(
               activity!!.findViewById(android.R.id.content),
               getString(R.string.cleared_message),
               Snackbar.LENGTH_SHORT // How long to display the message.
       ).show()
       sleepTrackerViewModel.doneShowingSnackbar()
   }
  1. In SleepTrackerViewModel, trigger the event in the onClear() method. To do this, set the event value to true inside the launch block:
_showSnackbarEvent.value = true
  1. Build and run your app!

Android Studio project: TrackMySleepQualityFinal

Implementing sleep quality tracking in this app is like playing a familiar piece of music in a new key. While details change, the underlying pattern of what you did in previous codelabs in this lesson remains the same. Being aware of these patterns makes coding much faster, because you can reuse code from existing apps. Here are some of the patterns used in this course so far:

  • Create a ViewModel and a ViewModelFactory and set up a data source.
  • Trigger navigation. To separate concerns, put the click handler in the view model and the navigation in the fragment.
  • Use encapsulation with LiveData to track and respond to state changes.
  • Use transformations with LiveData.
  • Create a singleton database.
  • Set up coroutines for database operations.

Triggering navigation

You define possible navigation paths between fragments in a navigation file. There are some different ways to trigger navigation from one fragment to the next. These include:

  • Define onClick handlers to trigger navigation to a destination fragment.
  • Alternatively, to enable navigation from one fragment to the next:
  • Define a LiveData value to record if navigation should occur.
  • Attach an observer to that LiveData value.
  • Your code then changes that value whenever navigation needs to be triggered or is complete.

Setting the android:enabled attribute

  • The android:enabled attribute is defined in TextView and inherited by all subclasses, including Button.
  • The android:enabled attribute determines whether or not a View is enabled. The meaning of "enabled" varies by subclass. For example, a non-enabled EditText prevents the user from editing the contained text, and a non-enabled Button prevents the user from tapping the button.
  • The enabled attribute is not the same as the visibility attribute.
  • You can use transformation maps to set the value of the enabled attribute of buttons based on the state of another object or variable.

Other points covered in this codelab:

  • To trigger notifications to the user, you can use the same technique as you use to trigger navigation.
  • You can use a Snackbar to notify the user.

Udacity course:

Android developer documentation:

This section lists possible homework assignments for students who are working through this codelab as part of a course led by an instructor. It's up to the instructor to do the following:

  • Assign homework if required.
  • Communicate to students how to submit homework assignments.
  • Grade the homework assignments.

Instructors can use these suggestions as little or as much as they want, and should feel free to assign any other homework they feel is appropriate.

If you're working through this codelab on your own, feel free to use these homework assignments to test your knowledge.

Answer these questions

Question 1

One way to enable your app to trigger navigation from one fragment to the next is to use a LiveData value to indicate whether or not to trigger navigation.

What are the steps for using a LiveData value, called gotoBlueFragment, to trigger navigation from the red fragment to the blue fragment? Select all that apply:

  • In the ViewModel, define the LiveData value gotoBlueFragment.
  • In the RedFragment, observe the gotoBlueFragment value. Implement the observe{} code to navigate to BlueFragment when appropriate, and then reset the value of gotoBlueFragment to indicate that navigation is complete.
  • Make sure your code sets the gotoBlueFragment variable to the value that triggers navigation whenever the app needs to go from RedFragment to BlueFragment.
  • Make sure your code defines an onClick handler for the View that the user clicks to navigate to BlueFragment, where the onClick handler observes the goToBlueFragment value.

Question 2

You can change whether a Button is enabled (clickable) or not by using LiveData. How would you ensure that your app changes the UpdateNumber button so that:

  • The button is enabled if myNumber has a value greater than 5.
  • The button is not enabled if myNumber is equal to or less than 5.

Assume that the layout that contains the UpdateNumber button includes the <data> variable for the NumbersViewModel as shown here:

<data>
   <variable
       name="NumbersViewModel"
       type="com.example.android.numbersapp.NumbersViewModel" />
</data>

Assume that the ID of the button in the layout file is the following:

android:id="@+id/update_number_button"

What else do you need to do? Select all that apply.

  • In the NumbersViewModel class, define a LiveData variable, myNumber, that represents the number. Also define a variable whose value is set by calling Transform.map() on the myNumber variable, which returns a boolean indicating whether or not the number is greater than 5.

    Specifically, in the ViewModel, add the following code:
val myNumber: LiveData<Int>

val enableUpdateNumberButton = Transformations.map(myNumber) {
   myNumber > 5
}
  • In the XML layout, set the android:enabled attribute of the update_number_button button to NumberViewModel.enableUpdateNumbersButton.
android:enabled="@{NumbersViewModel.enableUpdateNumberButton}"
  • In the Fragment that uses the NumbersViewModel class, add an observer to the enabled attribute of the button.

    Specifically, in the Fragment, add the following code:
// Observer for the enabled attribute
viewModel.enabled.observe(this, Observer<Boolean> { isEnabled ->
   myNumber > 5
})
  • In the layout file, set the android:enabled attribute of the update_number_button button to "Observable".

Start to the next lesson: 7.1 RecyclerView fundamentals

For links to other codelabs in this course, see the Android Kotlin Fundamentals codelabs landing page.