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:

What you'll learn

What you'll do

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:

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

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:

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

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:

Setting the android:enabled attribute

Other points covered in this codelab:

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:

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:

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:

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.

val myNumber: LiveData<Int>

val enableUpdateNumberButton = Transformations.map(myNumber) {
   myNumber > 5
}
android:enabled="@{NumbersViewModel.enableUpdateNumberButton}"
// Observer for the enabled attribute
viewModel.enabled.observe(this, Observer<Boolean> { isEnabled ->
   myNumber > 5
})

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.