Android Kotlin Fundamentals 05.4: LiveData transformations

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

The GuessTheWord app that you worked on in the previous three codelabs implements the LiveData observer pattern to observe ViewModel data. The views in the UI controller observe the LiveData in the ViewModel and update the data to be displayed.

When passing LiveData between the components, sometimes you might want to map or transform the data. Your code might need to perform calculations, display only a subset of the data, or change the rendition of the data. For example, for the word LiveData, you could create a transformation that returns the number of letters in the word rather than the word itself.

You can transform LiveData using the helper methods in the Transformations class:

In this codelab, you add a countdown timer in the app. You learn how to use Transformations.map() on the LiveData to transform elapsed time into a format to display on the screen.

What you should already know

  • How to create basic Android apps in Kotlin
  • How to use ViewModel objects in your app
  • How to store data using LiveData in a ViewModel
  • How to add LiveData observer methods to observe the changes in the data
  • How to use data binding with ViewModel and LiveData

What you'll learn

  • How to use Transformations with LiveData

What you'll do

  • Add a timer to end the game.
  • Use Transformations.map() to transform one LiveData into another.

In the Lesson 5 codelabs, you develop the GuessTheWord app, beginning with starter code. GuessTheWord is a two-player charades-style game, where the players collaborate to achieve the highest score possible.

The first player looks at the words in the app and acts each one out in turn, making sure not to show the word to the second player. The second player tries to guess the word.

To play the game, the first player opens the app on the device and sees a word, for example "guitar," as shown in the screenshot below.

The first player acts out the word, being careful not to actually say the word itself.

  • When the second player guesses the word correctly, the first player presses the Got It button, which increases the count by one and shows the next word.
  • If the second player can't guess the word, the first player presses the Skip button, which decreases the count by one and skips to the next word.
  • To end the game, press the End Game button. (This functionality isn't in the starter code for the first codelab in the series.)

In this codelab, you improve the GuessTheWord app by adding a one-minute countdown timer that appears above the score. The timer ends the game when the countdown reaches 0.

You also use a transformation to format the elapsed time LiveData object into a timer string LiveData object. The transformed LiveData is the data binding source for the timer's text view.

In this task, you locate and run your starter code for this codelab. You can use the GuessTheWord app that you built in previous codelab as your starter code, or you can download a starter app.

  1. (Optional) If you're not using your code from the previous codelab, download the starter code for this codelab. Unzip the code, and open the project in Android Studio.
  2. Run the app and play the game.
  1. Notice that the Skip button displays the next word and decreases the score by one, and the Got It button shows the next word and increases the score by one. The End Game button ends the game.
  1. Cycle through all the words, and notice that the app navigates automatically to the score screen.

In this task, you add a countdown timer to the app. Instead of the game ending when the word list is empty, the game ends when the timer finishes. Android provides a utility class called CountDownTimer that you use to implement the timer.

Add the logic for the timer in the GameViewModel so that the timer does not get destroyed during configuration changes. The fragment contains the code to update the timer text view as the timer ticks.

Implement the following steps in the GameViewModel class:

  1. Create a companion object to hold the timer constants.
companion object {

   // Time when the game is over
   private const val DONE = 0L

   // Countdown time interval
   private const val ONE_SECOND = 1000L

   // Total time for the game
   private const val COUNTDOWN_TIME = 60000L

}
  1. To store the countdown time of the timer, add a MutableLiveData member variable called _currentTime and a backing property, currentTime.
// Countdown time
private val _currentTime = MutableLiveData<Long>()
val currentTime: LiveData<Long>
   get() = _currentTime
  1. Add a private member variable called timer of the type CountDownTimer. You resolve the initialization error in the next step.
private val timer: CountDownTimer
  1. Inside the init block, initialize and start the timer. Pass in the total time, COUNTDOWN_TIME. For the time interval, use ONE_SECOND. Override the callback methods onTick() and onFinish() and start the timer.
// Creates a timer which triggers the end of the game when it finishes
timer = object : CountDownTimer(COUNTDOWN_TIME, ONE_SECOND) {

   override fun onTick(millisUntilFinished: Long) {
       
   }

   override fun onFinish() {
       
   }
}

timer.start()
  1. Implement the onTick() callback method, which is called on every interval or on every tick. Update the _currentTime, using the passed-in parameter millisUntilFinished. The millisUntilFinished is the amount of time until the timer is finished in milliseconds. Convert millisUntilFinished to seconds and assign it to _currentTime.
override fun onTick(millisUntilFinished: Long)
{
   _currentTime.value = millisUntilFinished/ONE_SECOND
}
  1. The onFinish() callback method is called when the timer is finished. Implement onFinish() to update the _currentTime and trigger the game finish event.
override fun onFinish() {
   _currentTime.value = DONE
   onGameFinish()
}
  1. Update the nextWord() method to reset the word list when the list is empty, instead of finishing the game.
private fun nextWord() {
   // Shuffle the word list, if the list is empty 
   if (wordList.isEmpty()) {
       resetList()
   } else {
   // Remove a word from the list
   _word.value = wordList.removeAt(0)
}
  1. Inside the onCleared() method, cancel the timer to avoid memory leaks. You can remove the log statement, because it's no longer needed. The onCleared() method is called before the ViewModel is destroyed.
override fun onCleared() {
   super.onCleared()
   // Cancel the timer
   timer.cancel()
}
  1. Run your app and play the game. Wait 60 seconds, and the game finishes automatically. However, the timer text is not displayed on the screen. You fix that next.

The Transformations.map() method provides a way to perform data manipulations on the source LiveData and return a result LiveData object. These transformations aren't calculated unless an observer is observing the returned LiveData object.

This method takes the source LiveData and a function as parameters. The function manipulates the source LiveData.

In this task, you format the elapsed time LiveData object into a new string LiveData object in "MM:SS" format. You also display the formatted elapsed time on the screen.

The game_fragment.xml layout file already includes the timer text view. So far, the text view has had no text to display, so the timer text has not been visible.

  1. In the GameViewModel class, after instantiating the currentTime, create a new LiveData object named currentTimeString. This object is for the formatted string version of the currentTime.
  2. Use Transformations.map() to define currentTimeString. Pass in the currentTime and a lambda function to format the time. You can implement the lambda function using the DateUtils.formatElapsedTime() utility method, which takes a long number of milliseconds and formats it to "MM:SS" string format.
// The String version of the current time
val currentTimeString = Transformations.map(currentTime) { time ->
   DateUtils.formatElapsedTime(time)
}
  1. In the game_fragment.xml file, in the timer text view, bind the text attribute to the currentTimeString of the gameViewModel.
<TextView
   android:id="@+id/timer_text"
   ...
   android:text="@{gameViewModel.currentTimeString}"
   ... />
  1. Run your app and play the game. The timer text updates once a second. Notice that the game does not finish when you have cycled through all the words. The game now finishes when the timer is up.

Congratulations! You successfully added a timer to the app that ends the game automatically. You also learned how to use Transformations.map() to convert one LiveData object to another.

Android Studio project: GuessTheWord

Challenge: Create a hint about the word, and display the hint in a text view above the timer. This hint can say how many characters the word has, and can reveal one of the letters at a random position.

Hints: Use Transformations.map() on the current word LiveData object. Add an extra TextView to display the word hint.

  1. In GameViewModel class, add a val to transform the current word into the hint.
// The Hint for the current word
val wordHint = Transformations.map(word) { word ->
   val randomPosition = (1..word.length).random()
   "Current word has " + word.length + " letters" +
           "\nThe letter at position " + randomPosition + " is " +
           word.get(randomPosition - 1).toUpperCase()
}
  1. In game_fragment.xml, add a new text view above the timer text view to display the hint. Bind the text attribute to the wordHint that you added above.
android:text="@{gameViewModel.wordHint}"

Transforming LiveData

  • Sometimes you want to transform the results of LiveData. For example, you might want to format a Date string as "hours:mins:seconds," or return the number of items in a list rather than returning the list itself. To perform transformations on LiveData, use helper methods in the Transformations class.
  • The Transformations.map() method provides an easy way to perform data manipulations on the LiveData and return another LiveData object. The recommended practice is to put data-formatting logic that uses the Transformations class in the ViewModel along with the UI data.

Displaying the result of a transformation in a TextView

  • Make sure the source data is defined as LiveData in the ViewModel.
  • Define a variable, for example newResult. Use Transformation.map() to perform the transformation and return the result to the variable.
val newResult = Transformations.map(someLiveData) { input ->
   // Do some transformation on the input live data
   // and return the new value
}
  • Make sure the layout file that contains the TextView declares a <data> variable for the ViewModel.
<data>
   <variable
       name="MyViewModel"
       type="com.example.android.something.MyViewModel" />
</data>
  • In the layout file, set the text attribute of the TextView to the binding of the newResult of the ViewModel. For example:
android:text="@{SomeViewModel.newResult}"

Formatting dates

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

In which class should you add the data-formatting logic that uses the Transformations.map() method to convert LiveData to a different value or format?

  • ViewModel
  • Fragment
  • Activity
  • MainActivity

Question 2

The Transformations.map() method provides an easy way to perform data manipulations on the LiveData and returns __________ .

  • A ViewModel object
  • A LiveData object
  • A formatted String
  • A RoomDatabase object

Question 3

What are the parameters for the Transformations.map() method?

  • A source LiveData and a function to be applied to the LiveData
  • Only a source LiveData
  • No parameters
  • ViewModel and a function to be applied

Question 4

The lambda function passed into the Transformations.map() method is executed in which thread?

  • Main thread
  • Background thread
  • UI thread
  • In a coroutine

Start the next lesson: 6.1: Create a Room database

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