Codelab — Kotlin Tips calculator
This codelab has been created for a crash course organized by the Belgium Kotlin User Group, GDG Brussels and GDG Namur Android in Brussels on the 12th of August 2017.
This codelab will take you through the creation of a very simple android application used to calculate tips. The end result should look like this :
You will find the base project in a git repository and each step is committed into a branch for you to ensure that everything is going as intended or find answers.
Each step is going to be decomposed into two :
- Explanation
- Solution
Depending on your skills in both Kotlin and Android, You can either only read the explanation of what is going to be achieved and try to do it yourself or go through the solution directly.
Requirements
You need installed on your machine :
- Android Studio 3.0 preview
- An android emulator configured or a phone in developer mode
- Some knowledge of how to use git
- A little bit of time
Let’s begin
- Clone the following repository on your machine : https://github.com/bmonjoie/TipsCalculator_Codelab
- Compile the application and launch it on your device/emulator
- You should see the following screen
Adding Kotlin
Explanation
The base project has been created as a Java Android application to show you the steps of how to add Kotlin to an existing project.
You are asked to :
- Configure Kotlin in the project
- Convert the MainActivity.java class into Kotlin
Configure Kotlin in the project :
Open the menu “Tools -> Kotlin -> Configure Kotlin in project” as shown on the picture below :
A small window appears and ask you to choose between :
- Android with Gradle
- Gradle
Choose “Android with Gradle” , then the final window appears and ask you for which modules you want to configure it :
Leave as is and click on “OK” .
It is possible that Android Studio won’t be able to retrieve the list of Kotlin versions from the internet, if that’s the case, you’ll see a warning icon and the runtime version will be 1.0.0 as shown on the screenshot above. To use the latest version anyway, open the file
build.gradle
which is at the root of you project and change the following lineext.kotlin_version = '1.0.0'
toext.kotlin_version = '1.1.3-2'
.Note that you will need an internet connection, at least the first time, to download the dependencies and be able to compile.
You should see a yellow banner indicating that some gradle files have changed and that Android Studio needs to synchronize. Click on the underlined “Sync now” on the right.
Convert the MainActivity.java class into Kotlin :
Open app/src/main/java/com.appkers.tipscalculator_codelab.MainActivity.java
, use the find action dialog (Ctrl + ⇧ Shift + A or, on Apple, ⌘ + ⇧ Shift + A) to find the action “Convert Java File to Kotlin File” and select it.
Pro-tip : remember the shortcut that is shown in the dialog
The class should have been converted to Kotlin and look like the file MainActivity.kt
in the branch step-1.
Launch the application and everything should look like exactly as it did previously.
Creating a data class
Explanation
In this section, we will create a Kotlin data class that will holds the information for each rows of our UIs.
You are asked to :
- Create a data class called
TipsEntry
in the packagecom.appkers.tipscalculator_codelab.entities
with the following mutable properties :
- amount as Float. By default, the amount value is going to be 0
- percent as Int. By default, the tips is going to be 18% - Add a way to retrieve the value of the tips based on the amount and the percentage.
E.g.: if the amount is 50 and the percentage is 10, tips will return 5 - Add a way to retrieve the total based on the amount and the tips.
E.g.: if the amount is 50 and the percentage is 10, total will return 55
Create a data class :
Create a new package inside “com.appkers.tipscalculator_codelab” called “entities” and create a new Kotlin class by right clicking on the newly created “entities” package than select “New -> Kotlin File/Class”. A dialog opens, enter “TipsEntry” as name and select “Class” for kind. Modify the code to make it look like the following :
data class TipsEntry(var amount: Float = 0F, var percent : Int = 18)
This class represents a new entry in our UI to which a amount and a tip percentage is associated.
Add a way to retrieve the value of the tips and total :
Now, we can add two properties to return the tips amount and the total :
val tips : Float
get() = amount / 100 * percent
val total : Float
get() = amount + tips
If you are used to Java, you would have probably created two methods :
public float getTips() { ... }
public float getTotal() { ... }
Nevertheless, in Kotlin, properties are first class citizens and allow us to reduce the boilerplate code. It is now possible to retrieve the tips by calling entry.tips
and the total amount by callingentry.total
instead of entry.getTips()
and entry.getTotal()
reducing the amount of code we have to write.
You can find the final class in the branch step-2.
Adding the RecyclerView
Explanation
Now that we have our data class to represent a new entry, we should add the corresponding UI.
You are asked to :
- Add the dependency to RecyclerView
- Change the layout of
MainActivity
to useRecyclerView
(creating the adapter will be next step) - Create a layout for our rows called
tips_entry_item
that will look like this :
Don’t forget that the EditText should only allow decimal numbers as input !
Add the dependency to RecyclerView:
Open the file app/build.gradle
and add in dependencies
, around the end of the file, the following line : implementation 'com.android.support:recyclerview-v7:26.0.0-beta2'
Synchronize your changes once again.
Change the layout of MainActivity
:
Open the file app/src/main/res/layout/activity_main.xml
and change it with the following content :
<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.RecyclerView
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"
android:id="@+id/rv_entries"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
tools:listitem="@layout/tips_entry_item"/>
You may have noticed that I already have added the following line app:layoutManager="android.support.v7.widget.LinearLayoutManager
to my layout. This allows me not to define the LayoutManager
in code but in the XML and may be very useful in case we want a different layout in landscape or for tablet, etc.
Also, I’m using tools:listitem="@layout/tips_entry_item
which is going to fill my preview to help visualize the end result.
Create a layout for our rows :
Create new file app/src/main/res/layout/tips_entry_item.xml
with the following content :
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
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"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<EditText
android:id="@+id/etAmount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:hint="Enter amount"
android:inputType="numberDecimal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/spPercent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
tools:text="5"
/>
<TextView
android:id="@+id/tvTips"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:textAlignment="center"
android:layout_marginEnd="16dp"
android:layout_marginStart="16dp"
app:layout_constraintBottom_toBottomOf="@+id/spPercent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@+id/spPercent"
app:layout_constraintTop_toTopOf="@+id/spPercent"
app:layout_constraintVertical_bias="0.6"
tools:text="0.56$"/>
<Spinner
android:id="@+id/spPercent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
app:layout_constraintBottom_toBottomOf="@+id/etAmount"
app:layout_constraintEnd_toStartOf="@+id/tvTips"
app:layout_constraintStart_toEndOf="@+id/etAmount"
app:layout_constraintTop_toTopOf="@+id/etAmount"
app:layout_constraintVertical_bias="0.785"/>
</android.support.constraint.ConstraintLayout>
The line android:inputType="numberDecimal"
will enforce Android to only accept decimal numbers in the EditText
.
We are also using tools:text="..."
to fill our preview and make it easier to visualize.
The result can be found in the branch step-3.
Creating an adapter
Explanation
You may have noticed, if you have launched the application, that nothing is shown. That is because we need to create an adapter. For now, we just want to show one entry to allow people to enter a new amount and calculate the tips.
You are asked to :
- Create an adapter called
TipsAdapter
in the packagecom.appkers.tipscalculator_codelab.adapters
- Display only one item
- Use the layout
tips_entry_item
to display our row - Assign the newly created
TipsAdapter
to the RecyclerView in MainActivity
The result should look like this :
Create an adapter called TipsAdapter
:
Create a new package called adapters
, then create a new Kotlin class called TipsAdapter
and paste the following code into it :
class TipsAdapter(context: Context) : RecyclerView.Adapter<TipsAdapter.ViewHolder>() {
val layoutInflater = LayoutInflater.from(context)
override fun getItemCount() = 1
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int) : ViewHolder {
return ViewHolder(layoutInflater.inflate(R.layout.tips_entry_item, parent, false))
}
override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
}
class ViewHolder(v: View) : RecyclerView.ViewHolder(v)
}
Assign the newly created TipsAdapter
to the RecyclerView in MainActivity :
At the end of the method onCreate
inside MainActivity.kt
, add the following line :
findViewById<RecyclerView>(R.id.rv_entries).adapter = TipsAdapter(this)
Launch the application and you should see the result shown in the explanation section.
The code can be found in the branch step-4.
Select the tip percentage
Explanation
You probably noticed nothing is happening when you enter a value or click on the arrow. It’s normal as we have yet to include any logic.
First, we’ll focus on selecting the tip percentage and showing the amount based on the value entered.
You are asked to :
- Fill the
Spinner
with values displaying percentage from 1% to 100% - Extract the logic of the ViewHolder into its own class called
TipsViewHolder
in the packagecom.appkers.tipscalculator_codelab.adapters.vh
- Display the tips when either the amount or the percentage is changed
Fill the Spinner
:
Create a new Kotlin class called PercentAdapter
in the package adapters
and paste the following into it :
class PercentAdapter(context: Context) : ArrayAdapter<String>(context, android.R.layout.simple_list_item_1) {
override fun getCount() = 100
override fun getItem(position: Int) = "${position + 1}%"
}
We assign this adapter to the Spinner in the TipsViewHolder.
Extract the logic of the ViewHolder into its own class and display the tips when either the amount or the percentage is changed :
Now, create a new package called vh
under the page adapters
and create a new Kotlin class called TipsViewHolder
and paste the following into it :
class TipsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), AdapterView.OnItemSelectedListener, TextWatcher {
val etAmount: EditText = itemView.findViewById<EditText>(R.id.etAmount)
val tvTips: TextView = itemView.findViewById<TextView>(R.id.tvTips)
val spPercent: Spinner = itemView.findViewById<Spinner>(R.id.spPercent)
init {
spPercent.adapter = PercentAdapter(itemView.context)
spPercent.onItemSelectedListener = this
etAmount.addTextChangedListener(this)
}
override fun afterTextChanged(p0: Editable?) {
setTips()
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
setTips()
}
private fun setTips() {
if (etAmount.text.toString().isNotEmpty()) {
val percent = spPercent.selectedItemPosition + 1
val amount = etAmount.text.toString().toFloat()
tvTips.text = TipsEntry(amount, percent).tips.toString()
}
}
override fun onNothingSelected(p0: AdapterView<*>?) {
}
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
}
and change the TipsAdapter
to use our newly created TipsViewHolder
:
class TipsAdapter(context: Context) : RecyclerView.Adapter<TipsViewHolder>() {
val layoutInflater = LayoutInflater.from(context)
override fun getItemCount() = 1
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int) : TipsViewHolder {
return TipsViewHolder(layoutInflater.inflate(R.layout.tips_entry_item, parent, false))
}
override fun onBindViewHolder(holder: TipsViewHolder?, position: Int) {
}
}
You’ll notice that if you enter an amount and select a percentage, the tips amount is shown.
The result of this code can be found in the branch step-5.
Allow multiple amounts
Explanation
Now, we would like to be able to insert multiple amount.
You are asked :
- When a new amount is entered, add a new empty row
- When an amount is removed, delete the row
This is probably the biggest and hardest part of the project. This requires knowledge of how RecyclerView’s adapters work. If you are not familiar enough with this, do not hesitate to look at the solution.
Modify the adapter as follow :
class TipsAdapter(context: Context) : RecyclerView.Adapter<TipsViewHolder>() {
val layoutInflater = LayoutInflater.from(context)
val list = ArrayList<TipsEntry>()
override fun getItemCount() = list.size + 1
override fun onCreateViewHolder(parent: ViewGroup?, type: Int) : TipsViewHolder {
return TipsViewHolder(layoutInflater.inflate(R.layout.tips_entry_item, parent, false), this)
}
override fun onBindViewHolder(holder: TipsViewHolder, position: Int) {
holder.bind(if (position < list.size) list[position] else TipsEntry())
}
fun setAmountFor(entry: TipsEntry, position: Int) {
if (position >= list.size) {
list.add(position, entry)
notifyItemInserted(position + 1)
}
}
fun removeAmountAt(adapterPosition: Int) {
if (adapterPosition < list.size) {
list.removeAt(adapterPosition)
notifyItemRemoved(adapterPosition)
}
}
And TipsViewHolder
as follow :
val etAmount: EditText = itemView.findViewById<EditText>(R.id.etAmount)
val tvTips: TextView = itemView.findViewById<TextView>(R.id.tvTips)
val spPercent: Spinner = itemView.findViewById<Spinner>(R.id.spPercent)
init {
etAmount.addTextChangedListener(this)
spPercent.adapter = PercentAdapter(itemView.context)
spPercent.onItemSelectedListener = this
}
private lateinit var entry: TipsEntry
fun bind(entry: TipsEntry) {
this.entry = entry
spPercent.setSelection(entry.percent - 1)
etAmount.setText((if (entry.amount > 0) {
entry.amount.toString()
} else {
""
}))
setTips()
}
private fun setTips() {
tvTips.text = if (entry.amount > 0) String.format("%.2f", entry.tips) else ""
}
override fun afterTextChanged(s: Editable?) {
if (s != null && !s.isEmpty()) {
entry.amount = s.toString().toFloat()
adapter.setAmountFor(entry, adapterPosition)
setTips()
} else {
adapter.removeAmountAt(adapterPosition)
}
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
entry.percent = position + 1
setTips()
}
override fun onNothingSelected(p0: AdapterView<*>?) {
}
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
As you can see, now, whenever you enter a new amount, a new line is automatically added below.
The result of this code can be found in branch step-6.
Showing total amount
Explanation
Now, we want to show the total amount of all the rows in our RecyclerView.
You are asked to :
- Create a new layout called
tips_total_item
to display the total amount - Create a ViewHolder for this layout called
TotalViewHolder
- Use the newly created ViewHolder in the adapter to display the total
Create a new layout to display the total :
Create a new file app/src/main/res/layout/tips_total_item
with the following content :
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
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"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:layout_marginTop="16dp"
android:text="Total"
android:textAppearance="?android:attr/textAppearanceMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout_editor_absoluteX="7dp"
tools:layout_editor_absoluteY="16dp"/>
<TextView
android:id="@+id/tvTotal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textAppearance="?android:attr/textAppearanceMedium"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
tools:text="126"
app:layout_constraintBaseline_toBaselineOf="@+id/textView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/textView"/>
</android.support.constraint.ConstraintLayout>
Create a ViewHolder for this layout :
Create a new ViewHolder for the total under adapters.vh
and call it TotalViewHolder
with the following content :
class TotalViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val tvTotal = itemView.findViewById<TextView>(R.id.tvTotal)
fun bind(total: Double) {
tvTotal.text = "%.2f".format(total)
}
}
Use the newly created ViewHolder in the adapter to display the total :
Since we now have 2 different ViewHolders, we have to change the generics of TipsAdapter
because we can’t use TipsViewHolder
as generic since TotalViewHolder
doesn’t extend TipsViewHolder
. We could make an interface or an abstract class but we’ll see soon that we can use the power of Kotlin avoid any boilerplate.
So, first, change TipsAdapter
to extends RecyclerView.Adapter<RecyclerView.ViewHolder>()
Finally, change TipsAdapter
‘s method as shown below :
override fun getItemCount() = list.size + 2
override fun onCreateViewHolder(parent: ViewGroup?, type: Int) = when (type) {
R.layout.tips_entry_item -> TipsViewHolder(layoutInflater.inflate(type, parent, false), this)
else -> TotalViewHolder(layoutInflater.inflate(type, parent, false))
}
override fun getItemViewType(position: Int) = when (position) {
list.size + 1 -> R.layout.tips_total_item
else -> R.layout.tips_entry_item
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder?, position: Int) = when (holder) {
is TipsViewHolder -> holder.bind(if (position < list.size) list[position] else TipsEntry())
is TotalViewHolder -> holder.bind(getTotal())
else -> throw IllegalArgumentException("Unknown holder type")
}
fun getTotal() = list.sumByDouble { it.total.toDouble() }
Look at onBindViewHolder
, we are using when
to act depending on the type of the ViewHolder and Kotlin’s smart cast to call directly the correct method bind on the ViewHolder.
We also need to change the following method of TipsAdapter
to warn that the total has changed :
fun setAmountFor(entry: TipsEntry, position: Int) {
if (position >= list.size) {
list.add(position, entry)
notifyItemInserted(position + 1)
}
notifyItemChanged(itemCount - 1)
}
fun removeAmountAt(adapterPosition: Int) {
if (adapterPosition < list.size) {
list.removeAt(adapterPosition)
notifyItemRemoved(adapterPosition)
notifyItemChanged(itemCount - 1)
}
}
And add a similar line in TipsViewHolder
when the percentage has changed :
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
entry.percent = position + 1
setTips()
adapter.notifyItemChanged(adapter.itemCount - 1)
}
The application is now complete and should work as expected.
You can find the result in the branch step-7.
Final improvement (Bonus)
One last tidbit for the road, TipsViewHolder
has 3 empty methods which is polluting our code. To remove them, let’s delegate those calls.
Create a new package common.empties
and add the two following classes :
DefaultOnItemSelectedListener :
class DefaultOnItemSelectedListener : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(p0: AdapterView<*>?) {
}
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, p2: Int, p3: Long) {
}
}
DefaultTextWatcher :
class DefaultTextWatcher : TextWatcher {
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
override fun afterTextChanged(p0: Editable?) {
}
}
Change the first line of TipsViewHolder
to look like this :
class TipsViewHolder(itemView: View, val adapter: TipsAdapter) : RecyclerView.ViewHolder(itemView), AdapterView.OnItemSelectedListener by DefaultOnItemSelectedListener(), TextWatcher by DefaultTextWatcher() {
and remove the empty methods. Voilà !
The result of this can be found in the branch step-8.
If you did everything thing right, you should have something like this :
Conclusion
There are still a lot of functionalities to learn in Kotlin but I hope this codelab has highlighted how you can create an app from scratch and how Kotlin can help speed up the process.
If you wish to go further, here are a few ideas :
- Create a
sealed class
and use it asTipsAdapter
‘s generic to remove theelse
in thewhen
ofonBindViewHolder
- Create a preference activity to set the default value of the percentage instead of using 18%
- Add a save button to insert the current rows in a database and retrieve them from a history list
- The sky is the limit :)