1. Before you begin
You have learned how to use activities, fragments, intents, data binding, navigation components, and the basics of architecture components. In this codelab, you will put everything together and work on an advanced sample, a cupcake ordering app.
You will learn how to use a shared ViewModel
to share data between the fragments of the same activity and new concepts like LiveData
transformations.
Prerequisites
- Comfortable with reading and understanding Android layouts in XML
- Familiar with the basics of the Jetpack Navigation Component
- Able to create a navigation graph with fragment destinations in an app
- Have previously used fragments within an activity
- Can create a
ViewModel
to store app data - Can use data binding with
LiveData
to keep the UI up-to-date with the app data in theViewModel
What you'll learn
- How to implement recommended app architecture practices within a more advanced use case
- How to use a shared
ViewModel
across fragments in an activity - How to apply a
LiveData
transformation
What you'll build
- A Cupcake app that displays an order flow for cupcakes, allowing the user to choose the cupcake flavor, quantity, and pickup date.
What you need
- A computer with Android Studio installed.
- Starter code for the Cupcake app.
2. Starter app overview
Cupcake app overview
The cupcake app demonstrates how to design and implement an online ordering app. At the end of this pathway, you will have completed the Cupcake app with the following screens. The user can choose the quantity, flavor, and other options for the cupcake order.
Download the starter code for this codelab
This codelab provides starter code for you to extend with features taught in this codelab. The starter code will contain code that is familiar to you from previous codelabs.
If you download the starter code from GitHub, note that the folder name of the project is android-basics-kotlin-cupcake-app-starter
. Select this folder when you open the project in Android Studio.
To get the code for this codelab and open it in Android Studio, do the following.
Get the code
- Click on the provided URL. This opens the GitHub page for the project in a browser.
- On the GitHub page for the project, click the Code button, which brings up a dialog.
- In the dialog, click the Download ZIP button to save the project to your computer. Wait for the download to complete.
- Locate the file on your computer (likely in the Downloads folder).
- Double-click the ZIP file to unpack it. This creates a new folder that contains the project files.
Open the project in Android Studio
- Start Android Studio.
- In the Welcome to Android Studio window, click Open an existing Android Studio project.
Note: If Android Studio is already open, instead, select the File > New > Import Project menu option.
- In the Import Project dialog, navigate to where the unzipped project folder is located (likely in your Downloads folder).
- Double-click on that project folder.
- Wait for Android Studio to open the project.
- Click the Run button
to build and run the app. Make sure it builds as expected.
- Browse the project files in the Project tool window to see how the app is set-up.
Starter code walk through
- Open the downloaded project in Android Studio. The folder name of the project is
android-basics-kotlin-cupcake-app-starter
. Then run the app. - Browse the files to understand the starter code. For layout files, you can use the Split option in the top right corner to see a preview of the layout and the XML at the same time.
- When you compile and run the app, you'll notice the app is incomplete. The buttons don't do much (except for displaying a
Toast
message) and you can't navigate to the other fragments.
Here's a walkthrough of important files in the project.
MainActivity:
The MainActivity
has similar code to the default generated code, which sets the activity's content view as activity_main.xml
. This code uses a parameterized constructor AppCompatActivity(@LayoutRes int contentLayoutId)
which takes in a layout that will be inflated as part of super.onCreate(savedInstanceState)
.
Code in the MainActivity
class
class MainActivity : AppCompatActivity(R.layout.activity_main)
is same as the following code using the default AppCompatActivity
constructor:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
Layouts (res/layout folder):
The layout
resource folder contains activity and fragment layout files. These are simple layout files, and the XML is familiar from the previous codelabs.
fragment_start.xml
is the first screen shown in the app. It has a cupcake image and three buttons to choose the number of cupcakes to order: one cupcake, six cupcakes, and twelve cupcakes.fragment_flavor.xml
shows a list of cupcake flavors as radio button options with a Next button.fragment_pickup.xml
provides an option to select pickup day and a Next button to go to the summary screen.fragment_summary.xml
displays a summary of the order details such as quantity, flavor and a button to send the order to another app.
Fragment classes:
StartFragment.kt
is the first screen shown in the app. This class contains the view binding code and a click handler for the three buttons.FlavorFragment.kt
,PickupFragment.kt
, andSummaryFragment.kt
classes contain mostly boilerplate code and a click handler for the Next or Send Order to Another App button, which show a toast message.
Resources (res folder):
drawable
folder contains the cupcake asset for the first screen, as well as the launcher icon files.navigation/nav_graph.xml
contains four fragment destinations (startFragment
,flavorFragment
,pickupFragment
, andsummaryFragment
) without Actions, which you will define later in the codelab.values
folder contains the colors, dimensions, strings, styles, and themes used for customizing the app theme. You should be familiar with these resource types from previous codelabs.
3. Complete the Navigation Graph
In this task, you'll connect the screens of the Cupcake app together and finish implementing proper navigation within the app.
Do you remember what we need to use the Navigation component? Follow this guide for a refresher on how to set up your project and app to:
- Include the Jetpack Navigation library
- Add a
NavHost
to the activity - Create a navigation graph
- Add fragment destinations to the navigation graph
Connect destinations in navigation graph
- In Android Studio, in the Project window, open res > navigation > nav_graph.xml file. Switch to the Design tab, if it's not already selected.
- This opens the Navigation Editor to visualize the navigation graph in your app. You should see the four fragments that already exist in the app.
- Connect the fragment destinations in the nav graph. Create an action from the
startFragment
to theflavorFragment
, a connection from theflavorFragment
to thepickupFragment
, and a connection from thepickupFragment
to thesummaryFragment
. Follow the next few steps if you need more detailed instructions. - Hover over the startFragment until you see the gray border around the fragment and the gray circle appear over the center of the right edge of the fragment. Click on the circle and drag to the flavorFragment, and then release the mouse.
- An arrow between the two fragments indicates a successful connection, meaning you will be able to navigate from the startFragment to the flavorFragment. This is called a Navigation action, which you have learned in a previous codelab.
- Similarly add navigation actions from flavorFragment to pickupFragment and from pickupFragment to summaryFragment. When you're done creating the navigation actions, the completed navigation graph should look like the following.
- The three new actions you created should be reflected in the Component Tree pane as well.
- When you define a navigation graph, you also want to specify the start destination. Currently you can see that startFragment has a little house icon next to it.
That indicates that startFragment will be the first fragment to be shown in the NavHost
. Leave this as the desired behavior for our app. For future reference, you can always change the start destination by right clicking on a fragment and selecting the menu option Set as Start Destination.
Navigate from start fragment to flavor fragment
Next, you will add code to navigate from startFragment to flavorFragment by tapping the buttons in the first fragment, instead of displaying a Toast
message. Below is the reference of the start fragment layout. You will pass the quantity of cupcakes to the flavor fragment in a later task.
- In the Project window, open the app > java > com.example.cupcake > StartFragment Kotlin file.
- In the
onViewCreated()
method, notice the click listeners are set on the three buttons. When each button is tapped, theorderCupcake()
method is called with the quantity of cupcakes (either 1, 6, or 12 cupcakes) as its parameter.
Reference code:
orderOneCupcake.setOnClickListener { orderCupcake(1) }
orderSixCupcakes.setOnClickListener { orderCupcake(6) }
orderTwelveCupcakes.setOnClickListener { orderCupcake(12) }
- In the
orderCupcake()
method, replace the code displaying the toast message with the code to navigate to the flavor fragment. Get theNavController
usingfindNavController()
method and callnavigate()
on it, passing in the action ID,R.id.action_startFragment_to_flavorFragment
. Make sure this action ID matches the action declared in yournav_graph.xml.
Replace
fun orderCupcake(quantity: Int) {
Toast.makeText(activity, "Ordered $quantity cupcake(s)", Toast.LENGTH_SHORT).show()
}
with
fun orderCupcake(quantity: Int) {
findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
- Add the Import
import
androidx.navigation.fragment.findNavController
or you can select from the options provided by Android Studio.
Add Navigation to the flavor and pickup fragments
Similar to the previous task, in this task you will add the navigation to the other fragments: flavor and the pickup fragments.
- Open app > java > com.example.cupcake > FlavorFragment.kt. Notice the method called within the Next button click listener is
goToNextScreen()
method. - In
FlavorFragment.kt
, inside thegoToNextScreen()
method, replace the code displaying the toast to navigate to the pickup fragment. Use the action ID,R.id.action_flavorFragment_to_pickupFragment
and make sure this ID matches the action declared in yournav_graph.xml.
fun goToNextScreen() {
findNavController().navigate(R.id.action_flavorFragment_to_pickupFragment)
}
Remember to import androidx.navigation.fragment.findNavController
.
- Similarly in
PickupFragment.kt
, inside thegoToNextScreen()
method, replace the existing code to navigate to the summary fragment.
fun goToNextScreen() {
findNavController().navigate(R.id.action_pickupFragment_to_summaryFragment)
}
Import androidx.navigation.fragment.findNavController
.
- Run the app. Make sure the buttons work to navigate from screen to screen. The information displayed on each fragment may be incomplete, but don't worry, you'll be populating those fragments with the correct data in upcoming steps.
Update title in app bar
As you navigate through the app, notice the title in the app bar. It is always displayed as Cupcake.
It would be a better user experience to provide a more relevant title based on the functionality of the current fragment.
Change the title in the app bar (also known as action bar) for each fragment using the NavController
and display an Up (←) button.
- In
MainActivity.kt
, override theonCreate()
method to set up the navigation controller. Get an instance ofNavController
from theNavHostFragment
. - Make a call to
setupActionBarWithNavController(navController)
passing in the instance ofNavController
. This will do the following: Show a title in the app bar based off of the destination's label, and display the Up button whenever you're not on a top-level destination.
class MainActivity : AppCompatActivity(R.layout.activity_main) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val navHostFragment = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHostFragment.navController
setupActionBarWithNavController(navController)
}
}
- Add necessary imports when prompted by Android Studio.
import android.os.Bundle
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupActionBarWithNavController
- Set the app bar titles for each fragment. Open
navigation/nav_graph.xml
and switch to Code tab. - In
nav_graph.xml
, modify theandroid:label
attribute for each fragment destination. Use the following string resources that have already been declared in the starter app.
For start fragment, use @string/app_name
with value Cupcake
.
For flavor fragment, use @string/choose_flavor
with value Choose Flavor
.
For pickup fragment, use @string/choose_pickup_date
with value Choose Pickup Date
.
For summary fragment, use @string/order_summary
with value Order Summary
.
<navigation ...>
<fragment
android:id="@+id/startFragment"
...
android:label="@string/app_name" ... >
<action ... />
</fragment>
<fragment
android:id="@+id/flavorFragment"
...
android:label="@string/choose_flavor" ... >
<action ... />
</fragment>
<fragment
android:id="@+id/pickupFragment"
...
android:label="@string/choose_pickup_date" ... >
<action ... />
</fragment>
<fragment
android:id="@+id/summaryFragment"
...
android:label="@string/order_summary" ... />
</navigation>
- Run the app. Notice the title in the app bar changes as you navigate to each fragment destination. Also notice that the Up button (arrow ←) is now showing in the app bar. If you tap on it, it doesn't do anything. You will implement the Up button behavior in the next codelab.
4. Create a shared ViewModel
Let's move onto populating the correct data in each of the fragments. You'll be using a shared ViewModel
to save the app's data in a single ViewModel
. Multiple fragments in the app will access the shared ViewModel
using their activity scope.
It is a common use case to share data between fragments in most production apps. For example in the final version(of this codelab) of the Cupcake app (notice the screenshots below), the user selects the quantity of cupcakes in the first screen, and in the second screen the price is calculated and displayed based on the quantity of the cupcakes. Similarly other app data such as flavor and pickup date are also used in summary screen.
From looking at the app features, you can reason that it would be useful to store this order information in a single ViewModel
, which can be shared across the fragments in this activity. Recollect that ViewModel
is a part of the Android Architecture Components. The app data saved within the ViewModel
is retained during configuration changes. To add a ViewModel
to your app, you create a new class that extends from the ViewModel
class.
Create OrderViewModel
In this task, you will create a shared ViewModel
for the Cupcake app called OrderViewModel
. You will also add the app data as properties inside the ViewModel
and methods to update and modify the data. Here are the properties of the class:
- Order quantity (
Integer
) - Cupcake flavor (
String
) - Pickup date (
String
) - Price (
Double
)
Follow ViewModel
best practices
In a ViewModel
, it is a recommended practice to not expose view model data as public
variables. Otherwise the app data can be modified in unexpected ways by the external classes and create edge cases your app didn't expect to handle. Instead, make these mutable properties private
, implement a backing property, and expose a public
immutable version of each property, if needed. The convention is to prefix the name of the private
mutable properties with an underscore (_
).
Here are the methods to update the properties above, depending on the user's choice:
setQuantity(numberCupcakes: Int)
setFlavor(desiredFlavor: String)
setDate(pickupDate: String)
You don't need a setter method for the price because you will calculate it within the OrderViewModel
using other properties. The steps below walk you through how to implement the shared ViewModel
.
You will create a new package in your project called model
and add the OrderViewModel
class. This will separate out the view model code from the rest of your UI code (fragments and activities). It is a coding best practice to separate code into packages depending on the functionality.
- In the Project window of Android Studio, right click on com.example.cupcake > New > Package.
- A New Package dialog will be opened, give the package name as
com.example.cupcake.model
.
- Create the
OrderViewModel
Kotlin class under themodel
package. In the Project window, right-click on themodel
package and select New > Kotlin File/Class. In the new dialog, give the filenameOrderViewModel
.
- In
OrderViewModel.kt
, change the class signature to extend fromViewModel
.
import androidx.lifecycle.ViewModel
class OrderViewModel : ViewModel() {
}
- Inside the
OrderViewModel
class, add the properties that were discussed above asprivate
val
. - Change the property types to
LiveData
and add backing fields to the properties, so that these properties can be observable and UI can be updated when the source data in the view model changes.
private val _quantity = MutableLiveData<Int>(0)
val quantity: LiveData<Int> = _quantity
private val _flavor = MutableLiveData<String>("")
val flavor: LiveData<String> = _flavor
private val _date = MutableLiveData<String>("")
val date: LiveData<String> = _date
private val _price = MutableLiveData<Double>(0.0)
val price: LiveData<Double> = _price
You will need to import these classes:
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
- In
OrderViewModel
class, add the methods that were discussed above. Inside the methods, assign the argument passed in to the mutable properties. - Since these setter methods need to be called from outside the view model, leave them as
public
methods (meaning noprivate
or other visibility modifier needed before thefun
keyword). The default visibility modifier in Kotlin ispublic
.
fun setQuantity(numberCupcakes: Int) {
_quantity.value = numberCupcakes
}
fun setFlavor(desiredFlavor: String) {
_flavor.value = desiredFlavor
}
fun setDate(pickupDate: String) {
_date.value = pickupDate
}
- Build and run your app to make sure there are no compile errors. There should be no visible change in your UI yet.
Nice work! Now you have the start to your view model. You'll incrementally add more to this class as you build out more features in your app and realize you need more properties and methods in your class.
If you see the class names, property names, or method names in gray font in Android Studio, that's expected. That means the class, properties, or methods or not being used at the moment, but they will be! That's coming up next.
5. Use the ViewModel to update the UI
In this task, you will use the shared view model you created to update the app's UI. The main difference in the implementation of a shared view model is the way we access it from the UI controllers. You will use the activity instance instead of the fragment instance, and you will see how to do this in the coming sections.
That means the view model can be shared across fragments. Each fragment could access the view model to check on some detail of the order or update some data in the view model.
Update StartFragment to use view model
To use the shared view model in StartFragment
you will initialize the OrderViewModel
using activityViewModels()
instead of viewModels()
delegate class.
viewModels()
gives you theViewModel
instance scoped to the current fragment. This will be different for different fragments.activityViewModels()
gives you theViewModel
instance scoped to the current activity. Therefore the instance will remain the same across multiple fragments in the same activity.
Use Kotlin property delegate
In Kotlin, each mutable (var
) property has default getter and setter functions automatically generated for it. The setter and getter functions are called when you assign a value or read the value of the property. (For a read-only property (val
), only the getter function is generated by default. This getter function is called when you read the value of a read-only property.)
Property delegation in Kotlin helps you to handoff the getter-setter responsibility to a different class.
This class (called delegate class) provides getter and setter functions of the property and handles its changes.
A delegate property is defined using the by
clause and a delegate class instance:
// Syntax for property delegation
var <property-name> : <property-type> by <delegate-class>()
- In
StartFragment
class, get a reference to the shared view model as a class variable. Use theby activityViewModels()
Kotlin property delegate from thefragment-ktx
library.
private val sharedViewModel: OrderViewModel by activityViewModels()
You may need these new imports:
import androidx.fragment.app.activityViewModels
import com.example.cupcake.model.OrderViewModel
- Repeat the above step for
FlavorFragment
,PickupFragment
,SummaryFragment
classes, you will use thissharedViewModel
instance in later sections of the codelab. - Going back to the
StartFragment
class, you can now use the view model. At the beginning of theorderCupcake()
method, call thesetQuantity()
method in the shared view model to update quantity, before navigating to the flavor fragment.
fun orderCupcake(quantity: Int) {
sharedViewModel.setQuantity(quantity)
findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
- Within the
OrderViewModel
class, add the following method to check if the flavor for the order has been set or not. You will use this method in theStartFragment
class in a later step.
fun hasNoFlavorSet(): Boolean {
return _flavor.value.isNullOrEmpty()
}
- In
StartFragment
class, insideorderCupcake()
method, after setting the quantity, set the default flavor as Vanilla if no flavor is set, before navigating to the flavor fragment. Your complete method will look like this:
fun orderCupcake(quantity: Int) {
sharedViewModel.setQuantity(quantity)
if (sharedViewModel.hasNoFlavorSet()) {
sharedViewModel.setFlavor(getString(R.string.vanilla))
}
findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
- Build the app to make sure there are no compile errors. There should be no visible change in your UI though.
6. Use ViewModel with data binding
Next you will use data binding to bind the view model data to the UI. You will also update the shared view model based on the selections the user makes in the UI.
Refresher on Data binding
Recall that the Data Binding Library is a part of Android Jetpack. Data binding binds the UI components in your layouts to data sources in your app using a declarative format. In simpler terms, data binding is binding data (from code) to views + view binding (binding views to code). By setting up these bindings and having updates be automatic, this helps you reduce the chance for errors if you forget to manually update the UI from your code.
Update flavor with user choice
- In
layout/fragment_flavor.xml
, add a<data>
tag inside the root<layout>
tag. Add a layout variable calledviewModel
of the typecom.example.cupcake.model.OrderViewModel
. Make sure the package name in the type attribute matches with the package name of the shared view model class,OrderViewModel
in your app.
<layout ...>
<data>
<variable
name="viewModel"
type="com.example.cupcake.model.OrderViewModel" />
</data>
<ScrollView ...>
...
- Similarly, repeat the above step for
fragment_pickup.xml
, andfragment_summary.xml
to add theviewModel
layout variable. You will use this variable in later sections. You don't need to add this code infragment_start.xml
, because this layout doesn't use the shared view model. - In the
FlavorFragment
class, insideonViewCreated()
, bind the view model instance with the shared view model instance in the layout. Add the following code inside thebinding?.
apply
block.
binding?.apply {
viewModel = sharedViewModel
...
}
Apply scope function
This may be the first time you're seeing the apply
function in Kotlin. apply
is a scope function in the Kotlin standard library. It executes a block of code within the context of an object. It forms a temporary scope, and in that scope, you can access the object without its name. The common use case for apply
is to configure an object. Such calls can be read as "apply the following assignments to the object."
Example:
clark.apply {
firstName = "Clark"
lastName = "James"
age = 18
}
// The equivalent code without apply scope function would look like the following.
clark.firstName = "Clark"
clark.lastName = "James"
clark.age = 18
- Repeat the same step for the
onViewCreated()
method inside thePickupFragment
andSummaryFragment
classes.
binding?.apply {
viewModel = sharedViewModel
...
}
- In
fragment_flavor.xml
, use the new layout variable,viewModel
to set thechecked
attribute of the radio buttons based on theflavor
value in the view model. If the flavor represented by a radio button is the same as the flavor that's saved in the view model, then display the radio button as selected (checked
= true). The binding expression for the checked state of the VanillaRadioButton
would look like the following:
@{viewModel.flavor.equals(@string/vanilla)}
Essentially, you are comparing the viewModel.flavor
property with the corresponding string resource using the equals
function, to determine if the checked state should be true or false.
<RadioGroup
...>
<RadioButton
android:id="@+id/vanilla"
...
android:checked="@{viewModel.flavor.equals(@string/vanilla)}"
.../>
<RadioButton
android:id="@+id/chocolate"
...
android:checked="@{viewModel.flavor.equals(@string/chocolate)}"
.../>
<RadioButton
android:id="@+id/red_velvet"
...
android:checked="@{viewModel.flavor.equals(@string/red_velvet)}"
.../>
<RadioButton
android:id="@+id/salted_caramel"
...
android:checked="@{viewModel.flavor.equals(@string/salted_caramel)}"
.../>
<RadioButton
android:id="@+id/coffee"
...
android:checked="@{viewModel.flavor.equals(@string/coffee)}"
.../>
</RadioGroup>
Listener bindings
Listener bindings are lambda expressions that run when an event happens, such as an onClick
event. They are similar to method references such as textview.setOnClickListener(clickListener)
but listener bindings let you run arbitrary data binding expressions.
- In
fragment_flavor.xml
, add event listeners to the radio buttons using listener bindings. Use a lambda expression with no parameters and make a call to theviewModel
.setFlavor()
method by passing in the corresponding flavor string resource.
<RadioGroup
...>
<RadioButton
android:id="@+id/vanilla"
...
android:onClick="@{() -> viewModel.setFlavor(@string/vanilla)}"
.../>
<RadioButton
android:id="@+id/chocolate"
...
android:onClick="@{() -> viewModel.setFlavor(@string/chocolate)}"
.../>
<RadioButton
android:id="@+id/red_velvet"
...
android:onClick="@{() -> viewModel.setFlavor(@string/red_velvet)}"
.../>
<RadioButton
android:id="@+id/salted_caramel"
...
android:onClick="@{() -> viewModel.setFlavor(@string/salted_caramel)}"
.../>
<RadioButton
android:id="@+id/coffee"
...
android:onClick="@{() -> viewModel.setFlavor(@string/coffee)}"
.../>
</RadioGroup>
- Run the app and notice how the Vanilla option is selected by default in the flavor fragment.
Great! Now you can move onto the next fragments.
7. Update pickup and summary fragment to use view model
Navigate through the app and notice that in the pickup fragment, the radio button option labels are blank. In this task, you will calculate the 4 pickup dates available and display them in the pickup fragment. There are different ways to display a formatted date, and here are some helpful utilities provided by Android to do this.
Create pickup options list
Date formatter
The Android framework provides a class called SimpleDateFormat
, which is a class for formatting and parsing dates in a locale-sensitive manner. It allows for formatting (date → text) and parsing (text → date) of dates.
You can create an instance of SimpleDateFormat
by passing in a pattern string and a locale:
SimpleDateFormat("E MMM d", Locale.getDefault())
A pattern string like "E MMM d"
is a representation of Date and Time formats. Letters from 'A'
to 'Z'
and from 'a'
to 'z'
are interpreted as pattern letters representing the components of a date or time string. For example, d
represents day in a month, y
for year and M
for month. If the date is January 4 in 2018, the pattern string "EEE, MMM d"
parses to "Wed, Jul 4"
. For a complete list of pattern letters, please see the documentation.
A Locale
object represents a specific geographical, political, or cultural region. It represents a language/country/variant combination. Locales are used to alter the presentation of information such as numbers or dates to suit the conventions in the region. Date and time are locale-sensitive, because they are written differently in different parts of the world. You will use the method Locale.getDefault()
to retrieve the locale information set on the user's device and pass it into the SimpleDateFormat
constructor.
Locale in Android is a combination of language and country code. The language codes are two-letter lowercase ISO language codes, such as "en" for english. The country codes are two-letter uppercase ISO country codes, such as "US" for the United States.
Now use SimpleDateFormat
and Locale
to determine the available pickup dates for the Cupcake app.
- In
OrderViewModel
class, add the following function calledgetPickupOptions()
to create and return the list of pickup dates. Within the method, create aval
variable calledoptions
and initialize it tomutableListOf
<String>()
.
private fun getPickupOptions(): List<String> {
val options = mutableListOf<String>()
}
- Create a formatter string using
SimpleDateFormat
passing pattern string"E MMM d"
, and the locale. In the pattern string,E
stands for day name in week and it parses to "Tue Dec 10".
val formatter = SimpleDateFormat("E MMM d", Locale.getDefault())
Import java.text.SimpleDateFormat
and java.util.Locale
, when prompted by Android Studio.
- Get a
Calendar
instance and assign it to a new variable. Make it aval
. This variable will contain the current date and time. Also, importjava.util.Calendar
.
val calendar = Calendar.getInstance()
- Build up a list of dates starting with the current date and the following three dates. Because you'll need 4 date options, repeat this block of code 4 times. This
repeat
block will format a date, add it to the list of date options, and then increment the calendar by 1 day.
repeat(4) {
options.add(formatter.format(calendar.time))
calendar.add(Calendar.DATE, 1)
}
- Return the updated
options
at the end of the method. Here is your completed method:
private fun getPickupOptions(): List<String> {
val options = mutableListOf<String>()
val formatter = SimpleDateFormat("E MMM d", Locale.getDefault())
val calendar = Calendar.getInstance()
// Create a list of dates starting with the current date and the following 3 dates
repeat(4) {
options.add(formatter.format(calendar.time))
calendar.add(Calendar.DATE, 1)
}
return options
}
- In
OrderViewModel
class, add a class property calleddateOptions
that's aval
. Initialize it using thegetPickupOptions()
method you just created.
val dateOptions = getPickupOptions()
Update the layout to display pickup options
Now that you have the four available pickup dates in the view model, update the fragment_pickup.xml
layout to display these dates. You will also use data binding to display the checked status of each radio button and to update the date in the view model when a different radio button is selected. This implementation is similar to the data binding in the flavor fragment.
In fragment_pickup.xml
:
Radio button option0
represents dateOptions[0]
in viewModel
(today)
Radio button option1
represents dateOptions[1]
in viewModel
(tomorrow)
Radio button option2
represents dateOptions[2]
in viewModel
(the day after tomorrow)
Radio button option3
represents dateOptions[3]
in viewModel
(two days after tomorrow)
- In
fragment_pickup.xml
, for theoption0
radio button, use the new layout variable,viewModel
to set thechecked
attribute based on thedate
value in the view model. Compare theviewModel.date
property with the first string in thedateOptions
list, which is the current date. Use theequals
function to compare and the final binding expression looks like the following:
@{viewModel.date.equals(viewModel.dateOptions[0])}
- For the same radio button, add an event listener using listener binding to the
onClick
attribute. When this radio button option is clicked, make a call tosetDate()
onviewModel
, passing indateOptions[0]
. - For the same radio button, set the
text
attribute value to the first string in thedateOptions
list.
<RadioButton
android:id="@+id/option0"
...
android:checked="@{viewModel.date.equals(viewModel.dateOptions[0])}"
android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[0])}"
android:text="@{viewModel.dateOptions[0]}"
...
/>
- Repeat the above steps for the other radio buttons, change the index of the
dateOptions
accordingly.
<RadioButton
android:id="@+id/option1"
...
android:checked="@{viewModel.date.equals(viewModel.dateOptions[1])}"
android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[1])}"
android:text="@{viewModel.dateOptions[1]}"
... />
<RadioButton
android:id="@+id/option2"
...
android:checked="@{viewModel.date.equals(viewModel.dateOptions[2])}"
android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[2])}"
android:text="@{viewModel.dateOptions[2]}"
... />
<RadioButton
android:id="@+id/option3"
...
android:checked="@{viewModel.date.equals(viewModel.dateOptions[3])}"
android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[3])}"
android:text="@{viewModel.dateOptions[3]}"
... />
- Run the app and you should see the next few days as pickup options available. Your screenshot will differ depending on what the current day is for you. Notice that there is no option selected by default. You will implement this in the next step.
- Within the
OrderViewModel
class, create a function calledresetOrder()
, to reset theMutableLiveData
properties in the view model. Assign the current date value from thedateOptions
list to_date.
value.
fun resetOrder() {
_quantity.value = 0
_flavor.value = ""
_date.value = dateOptions[0]
_price.value = 0.0
}
- Add an
init
block to the class, and call the new methodresetOrder()
from it.
init {
resetOrder()
}
- Remove the initial values from the declaration of the properties in the class. Now you are using the
init
block to initialize the properties when an instance ofOrderViewModel
is created.
private val _quantity = MutableLiveData<Int>()
val quantity: LiveData<Int> = _quantity
private val _flavor = MutableLiveData<String>()
val flavor: LiveData<String> = _flavor
private val _date = MutableLiveData<String>()
val date: LiveData<String> = _date
private val _price = MutableLiveData<Double>()
val price: LiveData<Double> = _price
- Run your app again, notice today's date is selected by default.
Update Summary fragment to use view model
Now let's move onto the last fragment. The order summary fragment is intended to show a summary of the order details. In this task, you take advantage of all the order information from the shared view model and update the onscreen order details using data binding.
- In
fragment_summary.xml
, make sure you have the view model data variable,viewModel
declared.
<layout ...>
<data>
<variable
name="viewModel"
type="com.example.cupcake.model.OrderViewModel" />
</data>
<ScrollView ...>
...
- In
SummaryFragment
, inonViewCreated()
, make surebinding.viewModel
is initialized. - In
fragment_summary.xml
, read from the view model to update the screen with the order summary details. Update the quantity, flavor, and dateTextViews
by adding the following text attributes. Quantity is of the typeInt
, so you need to convert it to a string.
<TextView
android:id="@+id/quantity"
...
android:text="@{viewModel.quantity.toString()}"
... />
<TextView
android:id="@+id/flavor"
...
android:text="@{viewModel.flavor}"
... />
<TextView
android:id="@+id/date"
...
android:text="@{viewModel.date}"
... />
- Run and test the app to verify that the order options you selected show up in the order summary.
8. Calculate price from order details
Looking at the final app screenshots of this codelab, you'll notice that the price is actually displayed on each fragment (except the StartFragment
) so the user knows the price as they create the order.
Here are the rules from our cupcake shop on how to calculate price.
- Each cupcake is $2.00 each
- Same day pickup adds an extra $3.00 to the order
Hence, for an order of 6 cupcakes, the price would be 6 cupcakes x $2 each = $12. If the user wants same day pickup, the extra $3 cost would lead to a total order price of $15.
Update price in view model
To add support for this functionality in your app, first tackle the price per cupcake and ignore the same day pickup cost for now.
- Open
OrderViewModel.kt
, and store the price per cupcake in a variable. Declare it as a top-level private constant at the top of the file, outside the class definition (but after the import statements). Use theconst
modifier and to make it read-only useval
.
package ...
import ...
private const val PRICE_PER_CUPCAKE = 2.00
class OrderViewModel : ViewModel() {
...
Recollect that constant values (marked with the const
keyword in Kotlin) do not change and the value is known at compile time. To learn more about constants, check out the documentation.
- Now that you have defined a price per cupcake, create a helper method to calculate the price. This method can be
private
because it's only used within this class. You will change the price logic to include same day pickup charges in the next task.
private fun updatePrice() {
_price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
}
This line of code multiplies the price per cupcake by the quantity of cupcakes ordered. For the code in parentheses, since the value of quantity.value
could be null, use an elvis operator (?:
) . The elvis operator (?:
) means that if the expression on the left is not null, then use it. Otherwise if the expression on the left is null, then use the expression to the right of the elvis operator (which is 0
in this case).
- In the same
OrderViewModel
class, update the price variable when the quantity is set. Make a call to the new function in thesetQuantity()
function.
fun setQuantity(numberCupcakes: Int) {
_quantity.value = numberCupcakes
updatePrice()
}
Bind the price property to the UI
- In the layouts for
fragment_flavor.xml
,fragment_pickup.xml
, andfragment_summary.xml
, make sure the data variableviewModel
of typecom.example.cupcake.model.OrderViewModel
is defined.
<layout ...>
<data>
<variable
name="viewModel"
type="com.example.cupcake.model.OrderViewModel" />
</data>
<ScrollView ...>
...
- In the
onViewCreated()
method of each fragment class, make sure you bind the view model object instance in the fragment to the view model data variable in the layout.
binding?.apply {
viewModel = sharedViewModel
...
}
- Within each fragment layout, use the
viewModel
variable to set the price if it's shown in the layout. Start with modifying thefragment_flavor.xml
file. For thesubtotal
text view, set the value of theandroid:text
attribute to be"@{@string/subtotal_price(viewModel.price)}".
This data binding layout expression uses the string resource@string/subtotal_price
and passes in a parameter, which is the price from the view model, so the output will show Subtotal 12.0 for example.
...
<TextView
android:id="@+id/subtotal"
android:text="@{@string/subtotal_price(viewModel.price)}"
... />
...
You're using this string resource that was already declared in the strings.xml
file:
<string name="subtotal_price">Subtotal %s</string>
- Run the app. If you select One cupcake in the start fragment, the flavor fragment will show Subtotal 2.0. If you select Six cupcakes, the flavor fragment will show Subtotal 12.0, and etc... You will format the price into the proper currency format later, so this behavior is expected for now.
- Now make a similar change for the pickup and summary fragments. In
fragment_pickup.xml
andfragment_summary.xml
layouts, modify the text views to use theviewModel
price
property as well.
fragment_pickup.xml
...
<TextView
android:id="@+id/subtotal"
...
android:text="@{@string/subtotal_price(viewModel.price)}"
... />
...
fragment_summary.xml
...
<TextView
android:id="@+id/total"
...
android:text="@{@string/total_price(viewModel.price)}"
... />
...
- Run the app. Make sure the price shown in the order summary is calculated correctly for an order quantity of 1, 6, and 12 cupcakes. As mentioned, it's expected that the price formatting isn't correct at the moment (it'll show up as 2.0 for $2 or 12.0 for $12).
Charge extra for same day pickup
In this task, you will implement the second rule which is that same day pickup adds an extra $3.00 to the order.
- In
OrderViewModel
class, define a new top-level private constant for the same day pickup cost.
private const val PRICE_FOR_SAME_DAY_PICKUP = 3.00
- In
updatePrice()
, check if the user selected the same day pickup. Check if the date in the view model (_date.
value
) is the same as the first item in thedateOptions
list which is always the current day.
private fun updatePrice() {
_price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
if (dateOptions[0] == _date.value) {
}
}
- To make these calculations simpler, introduce a temporary variable,
calculatedPrice
. Calculate the updated price and assign it back to_price.
value
.
private fun updatePrice() {
var calculatedPrice = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
// If the user selected the first option (today) for pickup, add the surcharge
if (dateOptions[0] == _date.value) {
calculatedPrice += PRICE_FOR_SAME_DAY_PICKUP
}
_price.value = calculatedPrice
}
- Call
updatePrice()
helper method fromsetDate()
method to add the same day pickup charges.
fun setDate(pickupDate: String) {
_date.value = pickupDate
updatePrice()
}
- Run your app, navigate through the app. You will notice that changing the pickup date does not remove the same day pickup charges from the total price. This is because the price is changed in the view model but it is not notified to the binding layout.
Set Lifecycle owner to observe LiveData
LifecycleOwner
is a class that has an Android lifecycle, such as an activity or a fragment. A LiveData
observer observes the changes to the app's data only if the lifecycle owner is in active states (STARTED
or RESUMED
).
In your app, the LiveData
object or the observable data is the price
property in the view model. The lifecycle owners are the flavor, pickup and the summary fragments. The LiveData
observers are the binding expressions in layout files with observable data like price. With Data Binding, when an observable value changes, the UI elements it's bound to are updated automatically.
Example of binding expression: android:text="@{@string/subtotal_price(viewModel.price)}"
For the UI elements to automatically update, you have to associate binding.
lifecycleOwner
with the lifecycle owners in the app. You will implement this next.
- In the
FlavorFragment
,PickupFragment
,SummaryFragment
classes, inside theonViewCreated()
method, add the following in thebinding?.apply
block. This will set the lifecycle owner on the binding object. By setting the lifecycle owner, the app will be able to observeLiveData
objects.
binding?.apply {
lifecycleOwner = viewLifecycleOwner
...
}
- Run your app again. In the pickup screen, change the pickup date and notice the difference in how the price changes automatically. And the pick up charges are correctly reflected in the summary screen.
- Notice that when you select today's date for pickup, the price of the order is increased by $3.00. The price for selecting any future date should still be the quantity of cupcakes x $2.00.
- Test different cases with different cupcake quantities, flavors, and pickup dates. Now you should see the price updating from the view model on each fragment. The best part is that you didn't have to write extra Kotlin code to keep the UI updated with the price each time.
To finish implementing the price feature, you'll need to format the price to the local currency.
Format price with LiveData transformation
The LiveData
transformation method(s) provides a way to perform data manipulations on the source LiveData
and return a resulting LiveData
object. In simple terms, it transforms the value of LiveData
into another value. These transformations aren't calculated unless an observer is observing the LiveData
object.
The Transformations.map()
is one of the transformation functions, this method takes the source LiveData
and a function as parameters. The function manipulates the source LiveData
and returns an updated value which is also observable.
Some real-time examples where you may use a LiveData transformation:
- Format date, time strings for display
- Sorting a list of items
- Filtering or grouping the items
- Calculate the result from a list like sum of all the items, number of items, return the last item, and so on.
In this task, you will use Transformations.map()
method to format the price to use the local currency. You'll transform the original price as a decimal value (LiveData<Double>
) into a string value (LiveData<String>
).
- In
OrderViewModel
class, change the backing property type toLiveData<String>
instead ofLiveData<Double>.
The formatted price will be a string with a currency symbol such as a ‘$'. You will fix the initialization error in the next step.
private val _price = MutableLiveData<Double>()
val price: LiveData<String>
- Use
Transformations.map()
to initialize the new variable, pass in the_price
and a lambda function. UsegetCurrencyInstance()
method in theNumberFormat
class to convert the price to local currency format. The transformation code will look like this.
private val _price = MutableLiveData<Double>()
val price: LiveData<String> = Transformations.map(_price) {
NumberFormat.getCurrencyInstance().format(it)
}
You'll need to import androidx.lifecycle.Transformations
and java.text.NumberFormat
.
- Run the app. Now you should see the formatted price string for subtotal and total. This is much more user-friendly!
- Test that it works as expected. Test cases like: Order one cupcake, order six cupcakes, order 12 cupcakes. Make sure the price is correctly updated on each screen. It should say Subtotal $2.00 for the Flavor and Pickup fragments, and Total $2.00 for the order summary. Also, make sure the order summary shows the correct order details.
9. Setup click listeners using listener binding
In this task, you will use listener binding to bind the button click listeners in the fragment classes to the layout.
- In the layout file
fragment_start.xml
, add a data variable calledstartFragment
of the typecom.example.cupcake.StartFragment
. Make sure the package name of the fragment matches with your app's package name.
<layout ...>
<data>
<variable
name="startFragment"
type="com.example.cupcake.StartFragment" />
</data>
...
<ScrollView ...>
- In
StartFragment.kt
, inonViewCreated()
method, bind the new data variable to the fragment instance. You can access the fragment instance inside the fragment usingthis
keyword. Remove thebinding?.
apply
block and along with the code within. The completed method should look like this.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.startFragment = this
}
- In
fragment_start.xml
, add event listeners using listener binding to theonClick
attribute for the buttons, make a call toorderCupcake()
onstartFragment
, passing in the number of cupcakes.
<Button
android:id="@+id/order_one_cupcake"
android:onClick="@{() -> startFragment.orderCupcake(1)}"
... />
<Button
android:id="@+id/order_six_cupcakes"
android:onClick="@{() -> startFragment.orderCupcake(6)}"
... />
<Button
android:id="@+id/order_twelve_cupcakes"
android:onClick="@{() -> startFragment.orderCupcake(12)}"
... />
- Run the app. Notice the button click handlers in the start fragment are working as expected.
- Similarly add the above data variable in other layouts as well to bind the fragment instance,
fragment_flavor.xml
,fragment_pickup.xml
, andfragment_summary.xml
.
In fragment_flavor.xml
<layout ...>
<data>
<variable
... />
<variable
name="flavorFragment"
type="com.example.cupcake.FlavorFragment" />
</data>
<ScrollView ...>
In fragment_pickup.xml
:
<layout ...>
<data>
<variable
... />
<variable
name="pickupFragment"
type="com.example.cupcake.PickupFragment" />
</data>
<ScrollView ...>
In fragment_summary.xml
:
<layout ...>
<data>
<variable
... />
<variable
name="summaryFragment"
type="com.example.cupcake.SummaryFragment" />
</data>
<ScrollView ...>
- In the rest of the fragment classes, in
onViewCreated()
methods, delete the code that manually sets the click listener on the buttons. - In the
onViewCreated()
methods bind the fragment data variable with the fragment instance. You will usethis
keyword differently here, because inside thebinding?.apply
block, the keywordthis
refers to the binding instance, not the fragment instance. Use@
and explicitly specify the fragment class name, for examplethis@FlavorFragment
. The completedonViewCreated()
methods should look as follows:
The onViewCreated()
method in FlavorFragment
class should look like this:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.apply {
lifecycleOwner = viewLifecycleOwner
viewModel = sharedViewModel
flavorFragment = this@FlavorFragment
}
}
The onViewCreated()
method in PickupFragment
class should look like this:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.apply {
lifecycleOwner = viewLifecycleOwner
viewModel = sharedViewModel
pickupFragment = this@PickupFragment
}
}
The resulting onViewCreated()
method in SummaryFragment
class method should look like this:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.apply {
lifecycleOwner = viewLifecycleOwner
viewModel = sharedViewModel
summaryFragment = this@SummaryFragment
}
}
- Similarly in the other layout files, add listener binding expressions to the
onClick
attribute for the buttons.
In fragment_flavor.xml
:
<Button
android:id="@+id/next_button"
android:onClick="@{() -> flavorFragment.goToNextScreen()}"
... />
In fragment_pickup.xml
:
<Button
android:id="@+id/next_button"
android:onClick="@{() -> pickupFragment.goToNextScreen()}"
... />
In fragment_summary.xml
:
<Button
android:id="@+id/send_button"
android:onClick="@{() -> summaryFragment.sendOrder()}"
...>
- Run the app to verify the buttons still work as expected. There should be no visible change in behavior, but now you've used listener bindings to set up the click listeners!
Congratulations on completing this codelab and building out the Cupcake app! However, the app is not quite done yet. In the next codelab, you will add a Cancel button and modify the backstack. You will also learn what is a backstack and other new topics. See you there!
10. Solution code
The solution code for this codelab is in the project shown below. Use the viewmodel branch to pull or download the code.
To get the code for this codelab and open it in Android Studio, do the following.
Get the code
- Click on the provided URL. This opens the GitHub page for the project in a browser.
- On the GitHub page for the project, click the Code button, which brings up a dialog.
- In the dialog, click the Download ZIP button to save the project to your computer. Wait for the download to complete.
- Locate the file on your computer (likely in the Downloads folder).
- Double-click the ZIP file to unpack it. This creates a new folder that contains the project files.
Open the project in Android Studio
- Start Android Studio.
- In the Welcome to Android Studio window, click Open an existing Android Studio project.
Note: If Android Studio is already open, instead, select the File > New > Import Project menu option.
- In the Import Project dialog, navigate to where the unzipped project folder is located (likely in your Downloads folder).
- Double-click on that project folder.
- Wait for Android Studio to open the project.
- Click the Run button
to build and run the app. Make sure it builds as expected.
- Browse the project files in the Project tool window to see how the app is set-up.
11. Summary
- The
ViewModel
is a part of the Android Architecture Components and the app data saved within theViewModel
is retained during configuration changes. To add aViewModel
to your app, you create a new class and extend it from theViewModel
class. - Shared
ViewModel
is used to save the app's data from multiple fragments in a singleViewModel
. Multiple fragments in the app will access the sharedViewModel
using their activity scope. LifecycleOwner
is a class that has an Android lifecycle, such as an activity or a fragment.LiveData
observer observes the changes to the app's data only if the lifecycle owner is in active states (STARTED
orRESUMED
).- Listener bindings are lambda expressions that run when an event happens such as an
onClick
event. They are similar to method references such astextview.setOnClickListener(clickListener)
but listener bindings let you run arbitrary data binding expressions. - The
LiveData
transformation method(s) provides a way to perform data manipulations on the sourceLiveData
and return a resultingLiveData
object. - Android frameworks provides a class called
SimpleDateFormat
, a class for formatting and parsing dates in a locale-sensitive manner. It allows for formatting (date → text) and parsing (text → date) dates.