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.
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.
You should be familiar with:
safeArgs
to pass data between fragments.LiveData
and their observers. Room
database, create a data access object (DAO), and define entities.LiveData
to track button states.LiveData
to trigger the display of a snackbar. 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:
This app uses a simplified architecture, as shown below in the context of the full architecture. The app uses only the following components:
LiveData
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.
SleepQualityFragment
. This class inflates the layout, gets the application, and returns binding.root
.SleepTrackerFragment
to SleepQualityFragment
, and back again from SleepQualityFragment
to SleepTrackerFragment
.<argument>
named sleepNightKey
. SleepTrackerFragment
to the SleepQualityFragment,
the app will pass a sleepNightKey
to the SleepQualityFragment
for the night that needs to be updated. 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.
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. 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
. private val _navigateToSleepQuality = MutableLiveData<SleepNight>()
val navigateToSleepQuality: LiveData<SleepNight>
get() = _navigateToSleepQuality
doneNavigating()
function that resets the variable that triggers navigation. fun doneNavigating() {
_navigateToSleepQuality.value = null
}
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
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 {
})
doneNavigating()
. If your import is ambiguous, import androidx.navigation.fragment.findNavController
.night ->
night?.let {
this.findNavController().navigate(
SleepTrackerFragmentDirections
.actionSleepTrackerFragmentToSleepQualityFragment(night.nightId))
sleepTrackerViewModel.doneNavigating()
}
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
.
sleepquality
package, create or open SleepQualityViewModel.kt.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() {
}
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()
}
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
}
onSetSleepQuality()
, for all the sleep-quality images to use. uiScope
, and switch to the I/O dispatcher.tonight
using the sleepNightKey
.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
}
}
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")
}
}
SleepQualityFragment.kt
. 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!!)
dataSource
. val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
dataSource
and the sleepNightKey
. val viewModelFactory = SleepQualityViewModelFactory(arguments.sleepNightKey, dataSource)
ViewModel
reference. val sleepQualityViewModel =
ViewModelProviders.of(
this, viewModelFactory).get(SleepQualityViewModel::class.java)
ViewModel
to the binding object. (If you see an error with the binding object, ignore it for now.)binding.sleepQualityViewModel = sleepQualityViewModel
androidx.lifecycle.Observer
. sleepQualityViewModel.navigateToSleepTracker.observe(this, Observer {
if (it == true) { // Observed state is true.
this.findNavController().navigate(
SleepQualityFragmentDirections.actionSleepQualityFragmentToSleepTrackerFragment())
sleepQualityViewModel.doneNavigating()
}
})
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>
android:onClick="@{() -> sleepQualityViewModel.onSetSleepQuality(5)}"
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.
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.
fragment_sleep_tracker.xml
layout file.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}"
SleepTrackerViewModel
and create three corresponding variables. Assign each variable a transformation that tests it. tonight
is null
.tonight
is not null
.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()
}
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.
SleepTrackerViewModel
, create the encapsulated event.private var _showSnackbarEvent = MutableLiveData<Boolean>()
val showSnackBarEvent: LiveData<Boolean>
get() = _showSnackbarEvent
doneShowingSnackbar()
. fun doneShowingSnackbar() {
_showSnackbarEvent.value = false
}
SleepTrackerFragment
, in onCreateView()
, add an observer:sleepTrackerViewModel.showSnackBarEvent.observe(this, Observer { })
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()
}
SleepTrackerViewModel
, trigger the event in the onClear()
method. To do this, set the event value to true
inside the launch
block:_showSnackbarEvent.value = true
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:
ViewModel
and a ViewModelFactory
and set up a data source.LiveData
to track and respond to state changes. LiveData
. 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:
onClick
handlers to trigger navigation to a destination fragment. LiveData
value to record if navigation should occur.LiveData
value. Setting the android:enabled attribute
android:enabled
attribute is defined in TextView
and inherited by all subclasses, including Button
.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.enabled
attribute is not the same as the visibility
attribute.enabled
attribute of buttons based on the state of another object or variable. Other points covered in this codelab:
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:
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.
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:
ViewModel
, define the LiveData
value gotoBlueFragment
.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.gotoBlueFragment
variable to the value that triggers navigation whenever the app needs to go from RedFragment
to BlueFragment
.onClick
handler for the View
that the user clicks to navigate to BlueFragment
, where the onClick
handler observes the goToBlueFragment
value.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:
myNumber
has a value greater than 5. 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.
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. ViewModel
, add the following code:val myNumber: LiveData<Int>
val enableUpdateNumberButton = Transformations.map(myNumber) {
myNumber > 5
}
android:enabled
attribute of the update_number_button button
to NumberViewModel.enableUpdateNumbersButton
.android:enabled="@{NumbersViewModel.enableUpdateNumberButton}"
Fragment
that uses the NumbersViewModel
class, add an observer to the enabled
attribute of the button. Fragment
, add the following code:// Observer for the enabled attribute
viewModel.enabled.observe(this, Observer<Boolean> { isEnabled ->
myNumber > 5
})
android:enabled
attribute of the update_number_button button
to "Observable"
.Start to the next lesson:
For links to other codelabs in this course, see the Android Kotlin Fundamentals codelabs landing page.