Manipulating LiveData data in the ViewModel

Transformations.map takes the input of one LiveData, applies a transformation (a function), and outputs another LiveData.

class MyViewModel : ViewModel() {

    companion object {
        private const val DONE = 0L
        private const val ONE_SECOND = 1000L
        private const val COUNTDOWN_TIME = 6000L
    }

    // Timer value
    private val _currentTime = MutableLiveData<Long>()
    val currentTime: LiveData<Long>
        get() = _currentTime

    val currentTimeString = Transformations.map(currentTime,{time -> // This takes in the current timer value and applies a time format before outputting it. Because it is a MutableLiveData it can be observed in a layout xml via data binding
        DateUtils.formatElapsedTime(time)
    })

    private val timer: CountDownTimer

    init {
        timer = object : CountDownTimer(COUNTDOWN_TIME, ONE_SECOND) {
            override fun onTick(millisUntilFinished: Long) {
                _currentTime.value = millisUntilFinished/ ONE_SECOND
            }

            override fun onFinish() {
                _currentTime.value = DONE
                _eventGameFinish.value = true
            }
        }
        timer.start()
    }
}

 

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="myViewModel"
            type="com.example.android.myapp.MyViewModel" />
    </data>

        <TextView
            android:id="@+id/timer_text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{myViewModel.currentTimeString}" /> // Call the LiveData from the ViewModel

</layout>

For more complex operations you may need to use a SwitchMap or MediatorLiveData. Allows manipulation and combination of more than one LiveData.

Combining data binding with Lifecycle components

We can use data binding along with Lifecycle components (e.g. ViewModel and LiveData) to remove the controller (Activity or Fragment) as middle-man from the process of passing data from the ViewModel to the View.

Without data binding (at least, only using data binding to avoid findViewById):

import ...

class MyFragment : Fragment() {

    private lateinit var viewModel: MyViewModel // Declare a MyViewModel object

    private lateinit var binding: MyFragmentBinding

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {

        // Inflate view and obtain an instance of the binding class.
        val binding: ScoreFragmentBinding = DataBindingUtil.inflate(
                inflater,
                R.layout.my_fragment,
                container,
                false
        )

        viewModel = ViewModelProviders.of(this).get(MyViewModel::class.java) // Initialise the MyViewModel object to a MyViewModel instance
        viewModel.myVar.observe(this, Observer { myVar -> // Observe a variable in the ViewModel class
            binding.myTextview.text = myVar.toString() // Use it to change our UI
        })

        binding.myButton.setOnClickListener { viewModel.changeVar() } // In this example, we have a button with an onClickListener which calls a MyViewModel function to change the variable

        return binding.root
    }
}

With data binding:

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data> // Add a data tag
        <variable // With a variable
            name="myViewModel" // Give it a name
            type="com.example.android.myapp.MyViewModel" /> // Point to the ViewModel class
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/app_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:id="@+id/my_textview"
            android:text="@{@string/string_format(myViewModel.myVar)}" // myVar is a variable within the ViewModel with a public getter. Don't need myVar.value thanks to data binding. Defaults to empty String. This has been wrapped in a String format defined in strings.xml ( <string name="string_format">\"%s\"</string> ) which wraps the String in quotes. Customise this for other types e.g. Ints, or use String.valueOf()
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <Button
            android:id="@+id/my_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="@{() -> myViewModel.changeVar()}" /> // Add an onClick to provide a listener for the button

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

And our Fragment looks like:

import ...

class MyFragment : Fragment() {

    private lateinit var viewModel: MyViewModel // Declare a MyViewModel object

    private lateinit var binding: MyFragmentBinding

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {

        // Inflate view and obtain an instance of the binding class.
        val binding: ScoreFragmentBinding = DataBindingUtil.inflate(
                inflater,
                R.layout.my_fragment,
                container,
                false
        )

        viewModel = ViewModelProviders.of(this).get(MyViewModel::class.java)
        // We have removed our onClickListener and Observer

        binding.myViewModel = viewModel // Link your ViewModel with the data tag in the xml file. Allows 

        binding.setLifecycleOwner(this) // Set the Lifecycleowner of the bindingto the current Fragment. Allows us to automatically update layouts

        return binding.root
    }
}

For anything more complicated than plain text we meed to move the logic and data manipulation to the ViewModel and use the likesĀ  of Transformations.map (see this post).

Kotlin Data Binding in Fragments

If necessary see Data Binding in Activities post for other files.

import ...
import androidx.databinding.DataBindingUtil
import com.example.android.navigation.databinding.FragmentTitleBinding

class TitleFragment : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        val binding: FragmentTitleBinding = DataBindingUtil.inflate(
                inflater, R.layout.fragment_title, container, false
        )
        return binding.root
    }

}

 

Kotlin Data Binding

Data binding avoids repeatedly traversing of the view hierarchy by findViewById (an expensive transaction) by generating a helper class at compile-time which the Activity/Fragment can refer to. It can also be used to bind a data class to a View so the View can access the data easily.

The idea behind data binding is to create an object that connects/maps/binds two pieces of distant information together at compile time, so that you don’t have to look for it at runtime. The object that surfaces these bindings to you is called the Binding object. You create an instance of the binding object, and then reference views through the binding object with no extra overhead.

See how you can combine this with Lifecycle components (ViewModel and LiveData) by going to this post.

In build.gradle (Module: app)

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "com.example.aboutme"
        minSdkVersion 19
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    dataBinding {
        enabled = true
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

In each layout file wrap in <layout> tag, and optionally bind to a data class if you want to:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools">
    
    <data>
        <variable
                name="myName" // Alias for this data variable
                type="com.example.aboutme.MyName" /> // Points to the Kotlin data class
    </data>
    
    <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical"
            android:paddingStart="@dimen/padding"
            android:paddingEnd="@dimen/padding"
            tools:context=".MainActivity">

        <TextView
                android:id="@+id/name_text"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@={myName.name}" // Refers to the variable declared above, with the 'name' property specified
                android:textAlignment="center"/>

        <TextView
                android:id="@+id/nickname_text"
                style="@style/NameStyle"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@={myName.nickname}" // Refers to the variable declared above, with the 'nickname' property specified
                android:textAlignment="center"
                android:visibility="visible"/>

    </LinearLayout>
</layout>

In Activity:

import ...
import com.example.aboutme.databinding.ActivityMainBinding // import this auto-generated class based on Activity name
import android.databinding.DataBindingUtil // This should be imported automatically
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding // Declare this class based on Activity name. A rebuild is required for this to be generated and remove error warning

    private val myName: MyName = MyName("Al", "AldeZu") // Create an instance of the data class

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this,R.layout.activity_main) // Initialise the binding variable with the view

        binding.myName = myName // Set the value of the myName variable used in the layout file to the data class

//        findViewById<Button>(R.id.done_button).setOnClickListener { // Don't do this any more
        binding.doneButton.setOnClickListener { // Use auto-generated view name instead of invoking costly findViewById
            addNickname(it)
        }
    }

    private fun addNickname(view: View) {
        binding.apply { // Use Kotlin's apply function to make the code easier to read when multiple views are referenced
            myName?.nickname = nicknameEdit.text.toString()
            invalidateAll() // After changing data we need to invalidate all the references to force a refresh
            nicknameEdit.visibility = View.GONE
            doneButton.visibility = View.GONE
            nicknameText.visibility = View.VISIBLE
        }

        val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        imm.hideSoftInputFromWindow(view.windowToken, 0)
    }
}

 

data class MyName(var name: String = "", var nickname: String = "")

 

Data Binding Library

Links the UI to actual data without having to use findViewById for every item.

In build.gradle, add 'dataBinding.enabled = true':

apply plugin: 'com.android.application'

android {
    compileSdkVersion 28
    buildToolsVersion "28.0.3"
    ...
    dataBinding.enabled = true;
}

dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')
    implementation'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.2'
}

Create a plain old Java object to act as a placeholder for the data:

/**
 * BoardingPassInfo is a simple POJO that contains information about, you guessed it, a boarding
 * pass! Normally, it is best practice in Java to declare member variables as private and provide
 * getters, but we are leaving these fields public for ease of use.
 */
public class BoardingPassInfo {

    public String passengerName;
    public String flightCode;
    public String originCode;
    public String destCode;

    public Timestamp boardingTime;
    public Timestamp departureTime;
    public Timestamp arrivalTime;

    public String departureTerminal;
    public String departureGate;
    public String seatNumber;

    public int barCodeImageResource;

    public long getMinutesUntilBoarding() {
        long millisUntilBoarding = boardingTime.getTime() - System.currentTimeMillis();
        return TimeUnit.MILLISECONDS.toMinutes(millisUntilBoarding);
    }
}

Add layout as the root tag to the UI (in this case, activity_main.xml file). Android will automatically create a Binding class for any layout with these tags:

<?xml version="1.0" encoding="utf-8"?>
<layout>
<ScrollView xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/scroll"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <android.support.constraint.ConstraintLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:text="@string/passenger_label"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
            
            ...

    </android.support.constraint.ConstraintLayout>
</ScrollView>
</layout>

In your Activity, create a Binding instance, and point the instance to the correct content view using DatabindingUtil:

public class MainActivity extends AppCompatActivity {

    ActivityMainBinding mBinding; // ActivityMainBinding is automatically generated based on the activity_main.xml file name

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mBinding = DataBindingUtil.setContentView(this, R.layout.activity_main); // Passes in the Activity and sets the content view to the correct layout file
        BoardingPassInfo boardingPass = generateBoardingPassInfo(); // Whatever method to produce an instance of your placeholder class populated with data
        displayBoardingPassInfo(boardingPass); // Passes the placeholder instance to the method below
    }

    private void displayBoardingPassInfo(BoardingPassInfo info) {

        mBinding.textViewPassengerName.setText(info.passengerName);
        mBinding.textViewFlightCode.setText(info.flightCode);
        mBinding.textViewOriginAirport.setText(info.originCode);
        mBinding.textViewDestinationAirport.setText(info.destCode);
        mBinding.textViewTerminal.setText(info.departureTerminal);
        mBinding.textViewGate.setText(info.departureGate);
        mBinding.textViewSeat.setText(info.seatNumber);

        SimpleDateFormat formatter = new SimpleDateFormat("hh:mm a", Locale.getDefault());
        String boardingTime = formatter.format(info.boardingTime);
        mBinding.textViewBoardingTime.setText(boardingTime);
        String departureTime = formatter.format(info.departureTime);
        mBinding.textViewDepartureTime.setText(departureTime);
        String arrivalTime = formatter.format(info.arrivalTime);
        mBinding.textViewArrivalTime.setText(arrivalTime);

        long totalMinutesUntilBoarding = info.getMinutesUntilBoarding();
        long hoursUntilBoarding = TimeUnit.MINUTES.toHours(totalMinutesUntilBoarding);
        long minutesMinusHoursUntilBoarding = totalMinutesUntilBoarding - (hoursUntilBoarding*60);
        String hoursAndMinutesUntilBoarding = hoursUntilBoarding + ":" + minutesMinusHoursUntilBoarding;
        mBinding.textViewBoardingInCountdown.setText(hoursAndMinutesUntilBoarding);
    }
}

ud851-Exercises-student\Lesson11-Completeing-The-UI\T11.02