Hard Prerequisites |
|
The starting sleep-tracker app has two screens, represented by fragments, as shown in the figure below.
The first screen, shown on the left, has buttons for starting and stopping tracking. The screen shows some of 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.
This app uses a simplified architecture with a UI controller, view model and LiveData, and a Room database to persist sleep data.
In this project, you add the ability to respond when a user taps an item in the grid, which brings up a detail screen like the one below. The code for this screen (fragment, view model, and navigation) is provided with the starter app, and you will implement the click-handling mechanism.
Important: The starter app for this project provides additional layouts, resources, and utilities that are not part of the final TrackMySleepQuality app from the previous project. We recommend that you use the provided starter code to work through this project.
Download the RecyclerViewClickHandler-Starter code from GitHub and open the project in Android Studio.
Build and run the starter sleep-tracker app.
For using your own sleep-tracker app that you built in the previous project, follow the instructions below to update your existing app so that it has the code for the details-screen fragment.
Tip: To copy files from the file system to Android Studio, you can copy and paste them, or drag and drop them.
/**
* Selects and returns the night with given nightId.
*/
@Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
fun getNightWithId(key: Long): LiveData<SleepNight>
<string name="close">Close</string>
Clean and rebuild your app to update data binding.
Step 2: Inspect the code for the sleep details screen
In this project, you implement a click handler that navigates to a fragment that shows details about the clicked sleep night. Your starter code already contains the fragment and navigation graph for this SleepDetailFragment, because it’s quite a bit of code, and fragments and navigation are not part of this project. Familiarize yourself with the following code:
In your app, find the sleepdetail package. This package contains the fragment, view model, and view model factory for a fragment that displays details for one night of sleep.
In the sleepdetail package, open and inspect the code for the SleepDetailViewModel. This view model takes the key for a SleepNight and a DAO in the constructor.
The body of the class has code to get the SleepNight for the given key, and the navigateToSleepTracker variable to control navigation back to the SleepTrackerFragment when the Close button is pressed.
The getNightWithId() function returns a LiveData
In the sleepdetail package, open and inspect the code for the SleepDetailFragment. Notice the setup for data binding, the view model, and the observer for navigation.
In the sleepdetail package, open and inspect the code for the SleepDetailViewModelFactory.
In the layout folder, inspect fragment_sleep_detail.xml. Notice the sleepDetailViewModel variable defined in the tag to get the data to display in each view from the view model.
The layout contains a ConstraintLayout that contains an ImageView for the sleep quality, a TextView for a quality rating, a TextView for the sleep length, and a Button to close the detail fragment.
The new action, action_sleep_tracker_fragment_to_sleepDetailFragment, is the navigation from the sleep tracker fragment to the details screen.
In this task, you update the RecyclerView to respond to user taps by showing a details screen for the tapped item.
Receiving clicks and handling them is a two-part task: First, you need to listen to and receive the click and determine which item has been clicked. Then, you need to respond to the click with an action.
So, what is the best place for adding a click listener for this app?
While the ViewHolder is a great place to listen for clicks, it’s not usually the right place to handle them. So, what is the best place for handling the clicks?
Tip: There are other patterns for implementing click listeners in RecyclerViews, but the one you work with in this project is easier to explain and more straightforward to implement. As you work on Android apps, you’ll encounter different patterns for using click listeners in RecyclerViews. All the patterns have their advantages.
class SleepNightListener() {
}
class SleepNightListener() {
fun onClick() =
}
class SleepNightListener() {
fun onClick(night: SleepNight) =
}
Giving the lambda that handles the click a name, clickListener , helps keep track of it as it is passed between classes. The clickListener callback only needs the night.nightId to access data from the database. Your finished SleepNightListener class should look like the code below.
class SleepNightListener(val clickListener: (sleepId: Long) -> Unit) {
fun onClick(night: SleepNight) = clickListener(night.nightId)
}
Open list_item_sleep_night.xml.
Inside the data block, add a new variable to make the SleepNightListener class available through data binding. Give the new
<variable
name="clickListener"
type="com.example.android.trackmysleepquality.sleeptracker.SleepNightListener" />
Set the attribute to clickListener:onClick(sleep) using a data binding lambda, as shown below:
android:onClick="@{() -> clickListener.onClick(sleep)}"
class SleepNightAdapter(val clickListener: SleepNightListener):
ListAdapter<SleepNight, SleepNightAdapter.ViewHolder>(SleepNightDiffCallback()) {
holder.bind(getItem(position)!!, clickListener)
binding.clickListener = clickListener
You now have the code in place to capture a click, but you haven’t implemented what happens when a list item is tapped. The simplest response is to display a toast showing the nightId when an item is clicked. This verifies that when a list item is clicked, the correct nightId is captured and passed on.
val adapter = SleepNightAdapter(SleepNightListener { nightId ->
Toast.makeText(context, "${nightId}", Toast.LENGTH_LONG).show()
})
In this task, you change the behavior when an item in the RecyclerView is clicked, so that instead of showing a toast, the app will navigate to a detail fragment that shows more information about the clicked night.
In this step, instead of just displaying a toast, you change the click listener lambda in onCreateView() of the SleepTrackerFragment to pass the nightId to the SleepTrackerViewModel and trigger navigation to the SleepDetailFragment.
fun onSleepNightClicked(id: Long) {
}
fun onSleepNightClicked(id: Long) {
_navigateToSleepDetail.value = id
}
private val _navigateToSleepDetail = MutableLiveData<Long>()
val navigateToSleepDetail
get() = _navigateToSleepDetail
Define the method to call after the app has finished navigating. Call it onSleepDetailNavigated() and set its value to null.
fun onSleepDetailNavigated() {
_navigateToSleepDetail.value = null
}
Add the code to call the click handler:
Open SleepTrackerFragment.kt and scroll down to the code that creates the adapter and defines SleepNightListener to show a toast.
val adapter = SleepNightAdapter(SleepNightListener { nightId ->
Toast.makeText(context, "${nightId}", Toast.LENGTH_LONG).show()
})
sleepTrackerViewModel.onSleepNightClicked(nightId)
Add the code to observe clicks:
Open SleepTrackerFragment.kt.
In onCreateView(), right above the declaration of manager, add code to observe the new navigateToSleepDetail LiveData. When navigateToSleepDetail changes, navigate to the SleepDetailFragment, passing in the night, then call onSleepDetailNavigated() afterwards. Since you ’ve done this before in a previous project, here is the code:
sleepTrackerViewModel.navigateToSleepDetail.observe(this, Observer { night ->
night?.let {
this.findNavController().navigate(
SleepTrackerFragmentDirections
.actionSleepTrackerFragmentToSleepDetailFragment(night))
sleepTrackerViewModel.onSleepDetailNavigated()
}
})
Caused by: java.lang.IllegalArgumentException: Parameter specified as non-null is null: method kotlin.jvm.internal.Intrinsics.checkParameterIsNotNull, parameter item
Unfortunately, the stack trace does not make it obvious where this error is triggered. One disadvantage of data binding is that it can make it harder to debug your code. The app crashes when you click an item, and the only new code is for handling the click.
However, it turns out that with this new click-handling mechanism, it is now possible for the binding adapters to get called with a null value for item. In particular, when the app starts, the LiveData starts as null, so you need to add null checks to each of the adapters.
@BindingAdapter("sleepQualityString")
fun TextView.setSleepQualityString(item: SleepNight?) {
item?.let {
text = convertNumericQualityToString(item.sleepQuality, context.resources)
}
}