This tutorial will take you through the development of a simple application with the functionality of a shopping list which uses a RecyclerView. RecyclerView is a newer, more efficient way of displaying lists in Android, when the lists are likely to update
binding
class ShoppingListItem(val name: String, var count: Int) {
var purchased : Boolean = false
}
import java.io.Serializable
class ShoppingListItem(val name: String, var count: Int) : Serializable {
var purchased : Boolean = false
}
The application is going to require a menu that provides options for the user to add a new item to the list, and also to remove items which have been located.
android:theme
with the following:
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
Be careful not to remove the closing angular bracket (>
) if it is at the end of this line.
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="?android:attr/actionBarSize"
android:background="@color/colorPrimary"
android:elevation="4dp"
android:minHeight="?android:attr/actionBarSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
onCreate
method:
setSupportActionBar(binding.toolbar)
title
attributes in both cases):
<item
android:id="@+id/menu_insert"
android:icon="@android:drawable/ic_menu_add"
android:title="@string/add_new_item"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/clear_complete"
android:icon="@android:drawable/ic_menu_close_clear_cancel"
android:title="@string/clear_found_items"
app:showAsAction="always" />
onCreationOptionsMenu
method in the MainActivity to inflate the menu. Override this method as follows:
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.menu_main, menu)
return super.onCreateOptionsMenu(menu)
}
Add the import statement for menu when prompted
RecyclerView is part of a support library that lets newer Android features be added to apps supporting older versions of Android. To add this support we need to modify the dependencies for our appliction.
implementation ("androidx.recyclerview:recyclerview:1.3.2")
implementation ("androidx.cardview:cardview:1.0.0")
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvShoppingList"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar" />
In order for a RecyclerView to display a list of items it needs both a LayoutManager and a RecyclerAdapter (which we'll cover later). The LayoutManager determines how the items in the RecyclerView are laid out, for example one after the other (linear) in a regular grid, or even in a staggered grid layout. For each of these scenarios Android provides a pre-existing layout manager (LinearLayoutManager, GridLayoutManager and StaggeredGridLayoutManager respectively).
private lateinit var layoutManager: RecyclerView.LayoutManager
Note the use of lateinit
. Add the import statement as prompted
onCreate
method, add code to instantiate the LayoutManager as follows:
layoutManager = LinearLayoutManager(this)
Again, add the appropriate import statement.
The layout manager just defines how items in the RecyclerView are laid out, it doesn't define the internal structure of individual items within the layout. We can use a CardView to determine how each individual item is laid out. It might help to imagine the LayoutManager as determining how boxes are arranged, and the CardView as determining the arrangement of items within a box.
<TextView android:id="@+id/tvCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toStartOf="@+id/tvProduct"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="123" />
<TextView android:id="@+id/tvProduct"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginRight="16dp"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/tvCount"
app:layout_constraintTop_toTopOf="parent"
tools:text="Oranges" />
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:listitem="@layout/card_layout"
In order to 'connect' the RecyclerView with the items in the CardView, we must implement the RecyclerView.Adapter abstract class. An abstract class is a class which contains one or more abstract methods. Unless you know what an abstract method is, then this definition is somewhat useless. An abstract method is one which is declared, but not implemented. This is similar to how methods are declared in interfaces. It is possible for both abstract classes and interfaces to implement methods in Kotlin, however it is not possible for an interface to store state, whereas an abstract class can. Abstract classes cannot be instantiated, they must be subclassed and any abstract members implemented (overridden).
There is another complexity to the RecyclerView.Adapter class in that it is typed. We've seen similar syntax to this before, in collections - for example we can create ArrayList<String>
but we could also create ArrayList<Banana>
. With RecyclerView.Adapter we are must specify a type when we extend it, though rather than being able to specify any type (like we can do with ArrayList) we are restricted to using a type that extends RecyclerView.ViewHolder (which is itself an abstract class that we must implement)
class RecyclerAdapter : RecyclerView.Adapter<RecyclerAdapter.ViewHolder>() {
}
Be aware that for now it will error because the type RecyclerAdapter.ViewHolder
does not exist, and also because we haven't implemented the abstract members of the class
val binding = CardLayoutBinding.bind(itemView)
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
}
Note that RecyclerView.ViewHolder's primary constructor requires a view to be passed as a parameter. We create our class to follow the same pattern, also requiring a view as a parameter in the primary constructor, and we then pass that view 'up the chain' into the constructor of the base class.
val view = LayoutInflater.from(parent.context).inflate(R.layout.card_layout, parent,false)
return ViewHolder(view)
Add the import statement when prompted.
Look carefully at this code, to see what's happening. The card layout we created earlier is inflated, which creates a view. This view is then passed into the constructor for the ViewHolder. If we wanted to add further functionality that applies to each ViewHolder, we could implement an init method for ViewHolder. Because ViewHolders can be reused, however, we wouldn't configure anything which differs from one cardview to another here.
onBindViewHolder
method. As we don't yet have a list of shopping items, for now, add the following code to that method:
holder.binding.tvCount.text = position.toString()
holder.binding.tvProduct.text = if (position == 1) "Banana" else "Bananas"
You'll be prompted to add an import statement for the card layout view, so do that. The eventual result of this will be that every item in the list will be "Bananas" (or "Banana" when there is just one) but the count will correspond to the items position in the list
getItemCount
method. Remove the boiler plate code and modify this so it returns 125 for now.
private lateinit var adapter: RecyclerAdapter
onCreate
method:
binding.rvShoppingList.layoutManager = layoutManager
adapter = RecyclerAdapter()
binding.rvShoppingList.adapter = adapter
getItemCount
in the RecyclerAdapter to return Integer.MAX_VALUE
At present the application can demonstrate a RecyclerView in action, but little else. At the start of the project, we created a class to represent a shopping item. We'll modify the code so that items from a collection of ShoppingListItem objects are displayed instead.
var list = mutableListOf<ShoppingListItem>()
init{
list.add(ShoppingListItem("bread",2))
val cheese = ShoppingListItem("cheese",1)
cheese.purchased = true
list.add(cheese)
}
getItemCount
method so it returns a count of the items in the list (don't just return 2!)
onBindViewHolder
method with the following which will access the values from the MutableList, such that the index of the RecyclerView is used to retrieve the data at the corresponding index of the MutableList:
val cardView = viewHolder.binding
val item = list[position]
cardView.tvCount.text = item.count.toString()
cardView.tvProduct.text = item.name
paintFlags
on the textview (there's no method on TextView that will toggle strikethrough on and off). paintFlags
is a bitfield (represented as an integer), so we need to flip the appropriate bit to toggle strikethrough on and off, but we do not want to mess with any other bits that are set. Rather than write in the RecyclerAdapter, Kotlin allows us to add methods to existing classes, so we can add our own toggleStrikethrough
method to the TextView class and use it throughout our application. Add a new Kotlin file called "Extensions" to the project
fun TextView.toggleStrikeThrough(on: Boolean) {
if (on){
this.paintFlags = this.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
} else {
this.paintFlags = this.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
}
}
You will also need to add import statements for Paint and TextView. Note that there is no class containing this method, so this method is available on the TextView class anywhere within our project.
onBindViewHolder
method as follows:
if (item.purchased){
cardView.tvProduct.toggleStrikeThrough(true)
cardView.tvCount.toggleStrikeThrough(true)
}
We can now show a strikethrough for items on our list, but we need the user to be able to toggle the strikethrough by tapping on an item in the RecyclerView. The way to do that is to add an onClickListener to each view in the recyclerview.
init
method to the RecyclerAdapter's inner ViewHolder class as follows:
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
init{
itemView.setOnClickListener {
list[bindingAdapterPosition].purchased = !list[bindingAdapterPosition].purchased
binding.tvProduct.toggleStrikeThrough(list[bindingAdapterPosition].purchased )
binding.tvCount.toggleStrikeThrough(list[bindingAdapterPosition].purchased)
}
}
}
The first line uses the position of the ViewHolder within the RecyclerView Adapter to locate the ShoppingListItem object at the corresponding point in the MutableList and invert it's purchased property. Using the value of that property we then toggle the strikethrough for both the count and the name of the item
Once the user has toggled items that they've found during their shopping trip, they should be able to remove them from the list. The menu item with the cross icon should implement this behaviour
fun removeFoundItems(){
val iterator = list.iterator()
while (iterator.hasNext()){
val item = iterator.next()
if (item.purchased){
iterator.remove()
}
}
notifyDataSetChanged()
}
onOptionsItemSelected(item: MenuItem?) : Boolean
method by starting to type it's name and choosing the autocomplete optiononOptionsItemSelected
method before the existing line that returns the base implementation:
if (item.itemId == R.id.clear_complete){
adapter.removeFoundItems()
return true
}
In order to add new items we need to implement logic to add it to the RecyclerView, and also provide an Activity which the user can use to add items
fun addItem(item: ShoppingListItem){
list.add(item)
notifyItemInserted(list.lastIndex)
}
Note that as well as adding it to the list, we also notify the RecyclerAdapter that an item has been inserted at a given position. It is more efficient than notifyDataSetChanged()
as other items in the list are not rebound (though their positions may have changed). As you may expect, there is a corresponding notifyItemRemoved()
method, which we could have used earlier, and which would have been a better approach
binding
in the usual way<?xml version="1.0" encoding="utf-8" ?>
<androidx.constraintlayout.widget.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"
tools:context=".AddItemActivity"
tools:layout_editor_absoluteY="25dp">
<Button android:id="@+id/btnIncrementCount"
android:layout_width="37dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="8dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:text="@string/plus_sign"
app:layout_constraintEnd_toStartOf="@+id/etCount"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText android:id="@+id/etItem"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:layout_marginTop="16dp"
android:ems="10"
android:hint="@string/apples"
android:inputType="text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/etCount"
app:layout_constraintTop_toTopOf="parent"
tools:text="item" />
<EditText android:id="@+id/etCount"
android:layout_width="37dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_marginRight="12dp"
android:layout_marginTop="16dp"
android:ems="10"
android:hint="@string/_1"
android:inputType="number"
android:text="@string/_1"
app:layout_constraintEnd_toStartOf="@+id/etItem"
app:layout_constraintStart_toEndOf="@+id/btnIncrementCount"
app:layout_constraintTop_toTopOf="parent"
tools:text="12" />
</android.support.constraint.ConstraintLayout>
private fun incrementCount() {
var userCount = binding.etCount.text.toString().toIntOrNull()
if (userCount == null) {
userCount = 1
}
binding.etCount.setText((userCount + 1).toString())
}
onCreate
method, so the button calls the above method when pressed as follows:
binding.btnIncrementCount.setOnClickListener {
incrementCount()
}
TextView.OnEditorActionListener
and the method we need to implement in the interface is the 'onEditorAction' Rather than duplicate code, we can create an inner class that implements the interface, and pass the same instance of that class as the listener to both EditText elements. Within AddItemActivity, create an inner class named EnterHandler
which implements the TextView.OnEditorActionListener
interface. You should be able to automatically add method that needs implementing by using the lightbulb popup and choosing 'implement members'. This will result in the code shown below:
inner class EnterHandler : TextView.OnEditorActionListener{
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
}
}
The documentation for OnEditorActionListerner advises that for the onEditorAction
method, we should "Return true if you have consumed the action, else false". We will come back to this shortly.
onCreate
method to create the Handler and attach it to both EditText elements:
val enterHandler = EnterHandler()
binding.etItem.setOnEditorActionListener(enterHandler)
binding.etCount.setOnEditorActionListener(enterHandler)
private fun validProductName() : Boolean {
return binding.etItem.text.length > 0
}
private fun productCount() : Int {
val userCount = binding.etCount.text.toString().toIntOrNull()
return if (userCount == null) 1 else userCount
}
Read through the code of both methods to ensure you understand what they do
onEditorAction
method and add the following implementation:
//user has pressed tick button on soft keyboard, or pressed enter key
if (actionId == EditorInfo.IME_ACTION_DONE || KeyEvent.KEYCODE_ENTER.equals(event?.keyCode)) {
if (validProductName()) {
val product = ShoppingListItem(binding.etItem.text.toString(),productCount())
val intent = Intent()
intent.putExtra("item", product)
setResult(Activity.RESULT_OK, intent)
finish()
//we have consumed (handled) this event (key press)
return true
}
}
//we have not consumed this event (i.e. different key pressed or no valid product entered yet
return false
private val resultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()){
}
Later we will add code to handle the resultprivate fun addItem() {
val intent = Intent(this,AddItemActivity::class.java)
resultLauncher.launch(intent)
}
This is a little lazy as we're supplying 0
instead of a named constant for the request code parameter. If we were to present more than one Activity from this Activity, we would need to revisit this so we could tell their results apart
onOptionsItemSelected
method, before the final return statement:
if (item.itemId == R.id.menu_insert){
addItem()
return true
}
if (it.resultCode == Activity.RESULT_OK) {
val newItem = it.data?.getSerializableExtra("item") as ShoppingListItem
adapter.addItem(newItem)
}
onCreate
method:
binding.etItem.requestFocus()
//display keyboard
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
A couple of problems persist with the application, that it does not persist if the application is terminated, and it's still not really any better than using a paper and pencil. As software developers we often look to optimise things, and why not extend that to shopping? If the user could drag products on the list into the order they want (for example the order they navigate the supermarket) the application would be more useful.
DragCallback
to the MainActivity that implements ItemTouchHelper.Callback
getMovementFlags
method as follows:
val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
return makeMovementFlags(dragFlags, 0)
onMove
method as follows:
adapter.notifyItemMoved(viewHolder.bindingAdapterPosition,target.bindingAdapterPosition)
return true
TODO
boilerplate code from onSwiped (the documentation instructs us that if we don't support swiping, this method will never be called, and as it doesn't return anything, we don't need to implement itprivate fun handleDragging(){
val dragCallback = DragCallback()
val touchHelper = ItemTouchHelper(dragCallback)
touchHelper.attachToRecyclerView(binding.rvShoppingList)
}
onCreate
methodSaving to disk is reasonably simple as our ShoppingListItem already implements Serializable
private fun saveList(){
val fileOutputStream = openFileOutput("list.dat", Context.MODE_PRIVATE)
val objectOutputStream = ObjectOutputStream(fileOutputStream)
objectOutputStream.writeObject(adapter.list)
objectOutputStream.close()
fileOutputStream.close()
}
private fun loadList(){
try {
val fileInputStream = openFileInput("list.dat")
val objectInputStream = ObjectInputStream(fileInputStream)
@Suppress("UNCHECKED_CAST")
val list = objectInputStream.readObject() as? MutableList<ShoppingListItem>
if (list != null) {
adapter.list = list
}
objectInputStream.close()
fileInputStream.close()
}
catch (e: java.io.FileNotFoundException){
//loading has failed, probably first run
Toast.makeText(this,"No existing list found",Toast.LENGTH_LONG).show()
}
}
loadList()
in the onCreate methodonSaveInstanceState
method and add a call to saveList
onPause
and do the sameYou may have discovered that after you remove items that have strikethrough, as other items move up the list, they change to having strikethrough also. If you created a long enough list, you may also have discovered items being struck through erroneously. This is because the views are being recycled. To fix this we need to override the onViewRecycled
method and ensure that the strikethrough is toggled off. To do this override the onViewRecycled
method as follows:
override fun onViewRecycled(holder: ViewHolder) {
super.onViewRecycled(holder)
holder.binding.tvProduct.toggleStrikeThrough(false)
holder.binding.tvCount.toggleStrikeThrough(false)
}
Retest the application - hopefully it should behave as expected now
A completed version of this project can be found on GitHub
notifyItemRemoved()
instead of notifyDataSetChanged()
onBindViewHolder
so overriding onViewRecycled
is not neccessary