Home / Programming / Android / RecyclerView Multi-Select using KOTLIN

RecyclerView Multi-Select using KOTLIN

Implementing multi-select on a Recycler View can be tricky and complicated. However, by the end of this tutorial, you’ll understand how to implement multi selection of items in a recycler view and do whatever you want (delete, share, copy etc) with the selected items .

Disclaimer

I would like to mentally prepare you, this is a very detailed tutorial, hence make this tutorial a bit lengthy.

Rememeber “Everyone loves an Underdog, Just Do It!”

To absorb all the concept

  • please pay close attention
  • make sure you are well rested
  • make sure you have eaten. LOL,
  • avoid distractions

This tutorial will be very useful if you aim to understand how to implement multi selection on a recycler view and modify the code to your taste (recommended approach to learn), but if you’re in ‘BEAST mode’ you might want to get the gitHub repository instead.

If you have ANY question, just ask! I’ll be happy to help.

Aim:

  • To demystify multiple item selection in a RecyclerView

Goals:

  1. Demonstrate how to select multiple items in a RecyclerView
  2. Use actionMode to perform actions with the selected item

Solution Steps:

  1. colors.xml : The color resource on line 6 (colorControlActivated) will overlay selected items.
    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <color name="colorPrimary">#3F51B5</color>
        <color name="colorPrimaryDark">#303F9F</color>
        <color name="colorAccent">#FF4081</color>
        <color name="colorControlActivated">#50FF4081</color>
    </resources>
    
  2. activity_main.xml : Activity layout
    <?xml version="1.0" encoding="utf-8"?>
    <android.support.v7.widget.RecyclerView
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context="com.edgedevstudio.example.recyclerviewmultiselect.MainActivity"/>

    recycler view layout

  3. MainActivity.kt : Set content view. Declare and access RecyclerView. ApplyLinearLayoutManager to RecyclerView.
    class MainActivity : AppCompatActivity(){
        var actionMode: ActionMode? = null
        var myAdapter: MyAdapter? = null
    
        companion object {
            var isMultiSelectOn = false
            val TAG = "MainActivity"
        }
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            
            setContentView(R.layout.activity_main)
            
            val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
            
            //LinearLayoutManager to arrange recyclerView items Linearly
            recyclerView.layoutManager = LinearLayoutManager(this)
        }
    }
  4. view_holder_layout.xml : Layout for ViewHolder to Inflate items
    <FrameLayout
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/root_layout"
        android:background="#ffffff"
        android:layout_marginBottom="1dp"
        android:orientation="horizontal">
        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@mipmap/ic_launcher"/>
        <TextView
            android:id="@+id/myTextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="22sp"
            android:layout_gravity="center"
            android:layout_margin="16dp"
            tools:text="Hello World"/>
    </FrameLayout>
  5. MyModel.kt : this is data class will model the data to be inflated in our recycler view. It will hold a string (Title) and an Id (to identify a specific model).
    data class MyModel(val id: String, var title: String)
  6. ViewHolderClickListener.kt : this interface contains callbacks to notify MainActivity.kt of clicks/taps or long clicks(or long taps) in the view holder of RecyclerView
    interface ViewHolderClickListerner{
        fun onLongTap(index : Int)
        fun onTap(index : Int)
    }
  7. MainInterface.kt : the main function of this interface is to update actionMode (to show the number of items selected) in MainActivity.kt. The callback (in this interface) is required to be passed into the constructor of RecyclerViewAdapter.kt. This pattern is employed because :
    1. RecyclerViewAdapter is not an inner class hence we can’t update the actionMode directly and it’ll be a bad idea to make actionMode static because doing that might cause memory leak.
    2. we avoided hard coding the RecyclerViewAdapter to MainActivity’s actionMode, hence making the adapter usable in aufragment.
    3. It makes our code much more neater and less buggyMainInterface.kt : the main function of this interface is to update  actionMode (to show the number of items selected) in MainActivity.kt. The callback (in this interface) is required to be passed into the  constructor of RecyclerViewAdapter.kt. This pattern is employed because :
    interface MainInterface {
        fun mainInterface (size : Int)
    }
  8. MyViewHolder.kt : RecyclerView mandates the viewHolder pattern – all it does is make our RecyclerView memory efficient by re-using views to inflate modeled data instead of creating views for every model data in the recyclerview (which will consume a lot of  memory and ultimately lead to OutOfMemoryException) hence the name View Holder! View.onClickListener and View.onLongClickListener was implemented on our ViewHolder (instead of setting anonymous click listener) and click listeners were set to the view (in this case, the frame layout)
    class MyViewHolder(itemView: View, val r_tap: ViewHolderClickListerner) : RecyclerView.ViewHolder(itemView),
            View.OnLongClickListener, View.OnClickListener {
    
        val textView: TextView
        val frameLayout: FrameLayout
    
        init {//initialization block
            textView = itemView.findViewById(R.id.myTextView)
            frameLayout = itemView.findViewById(R.id.root_layout)
            frameLayout.setOnClickListener(this)
            frameLayout.setOnLongClickListener(this)
        }
    
        override fun onClick(v: View?) {
            r_tap.onTap(adapterPosition)
        }
    
        override fun onLongClick(v: View?): Boolean {
            r_tap.onLongTap(adapterPosition)
            return true
        }
    }
  9. MyAdapter.kt : PAY ATTENTION, This is where the Magic Happens!
    1. MyAdapter requires Context and MainInterface to be passed into its constructor
    2. This adapter class  extends RecyclerView.Adapter<MyViewHolder> as required, in order to qualify as a RecyclerView Adapter.
    3. selectedIds is a list that stores the Ids of selected models. It is MANDATORY we hold a reference to Ids because it is a sure way to ascertain which model we’re refrencing! Remember, viewHolders are created only once and are recycled/re-used throughout the recyclerview, hence, if you keep only a reference to the viewHolder without the model’s Id in it you’re going to be referencing another model when the recyclerview is scrolled off the screen.  Summary: you must use an Id to reference your model instead of viewHolders as done in this tutorial.
    4. MyAdapter implements ViewHolderClickListerner with onLongTap and onTap callbacks. Point 6 above
    5. onLongTap callback is executed when a user long clicks a viewHolder. It accepts the position of the viewHolder that was long clicked. The code here checks if MultiSelection mode is enabled, if not set it to true then calls addIDIntoSelectedIds while passing the position of the viewHolder that was clicked. Why did create another function to add the id of the selected item into selectedId’s list? This was done because onTap callback will execute similar code with only slight difference. In code, it’s highly recommended to follow the DRY (Don’t Repeat Yourself) principle!
    6. onTap callback accepts the index of the inflated Model that was clicked and is executed when user clicks on the viewHolder. The code here check if multi selection mode is on, if it is execute addIDIntoSelectedIds  function while passing the index of the model.
    7. addIDIntoSelectedIds(indexOfModel : Int) is a function that gets the Id of the model which is got from the position of indexOfModel in modelList in the recyclerView. I hope that makes sense? if it does not make snece, please read it again, SLOWLY. Since both onTap and onLongTap callbacks executes the same function, DRY principle was applied to avoid duplicate code, hence this function was born.
    8. deleteSelectedIds() is a function that is executed when we wish to delete models whose Ids are present in the selectedIds list.A for-loop would have been convenient to remove items from the modelList i.e for each singleID in selectedIds, for each model in model list, check if singleID equals model.getId(), if yes, remove the model from the modelList, if no, continue looping until it finds the Ids that match BUT we would not be able to tell the position/index from which a model was removed (which will lead to loss of cool animation that notifyItemRemoved(index : Int) will provide) and it will throw a ConcurrentModificationException meaning you cannot modify (add or remove items) a list while iterating with a for loop. INSTEAD a list Iterator was used which allows us to get the index of the removed item (which is helpful, so that we can call notifyItemRemoved(indexOfRemovedItem) ) and also safely modify the list without throwing exceptions. The code is simply read as follows: if there are no id’s in the selectedIds list return and don’t execute the function.
    9. getItemCount() returns the size of Models in the RecyclerView in order to let the onBindViewHolder know how many models it will bind to the viewHolder.
    10. onCreateViewHolder creates ViewHolders by inflating view_holder_layout. Limited numbers of viewHolders are created (depending on screen size) only ONCE and is reused (in onBindViewHolder) as user scrolls through the recyclerView. It also passes in the required ViewHolderClickListerner into the constructor of the ViewHolder in order to track clicks on the viewHolder!
    11. onBindViewHolder binds Models to ViewHolder (new or recycled) and is responsible for adding or removing overlay effect (foreground color) to the viewHolder depending on whether the Id of the model to be inflated is present in the list of selectedIDs.
    class MyAdapter(val context: Context, val mainInterface: MainInterface) : RecyclerView.Adapter<MyViewHolder>(), ViewHolderClickListerner {
        override fun onLongTap(index: Int) {
            if (!MainActivity.isMultiSelectOn) {
                MainActivity.isMultiSelectOn = true
            }
            addIDIntoSelectedIds(index)
        }
    
        override fun onTap(index: Int) {
            if (MainActivity.isMultiSelectOn) {
                addIDIntoSelectedIds(index)
            } else {
                Toast.makeText(context, "Clicked Item @ Position ${index + 1}", Toast.LENGTH_SHORT).show()
            }
        }
    
        fun addIDIntoSelectedIds(index: Int) {
            val id = modelList[index].id
            if (selectedIds.contains(id))
                selectedIds.remove(id)
            else
                selectedIds.add(id)
    
            notifyItemChanged(index)
            if (selectedIds.size < 1) MainActivity.isMultiSelectOn = false
            mainInterface.mainInterface(selectedIds.size)
        }
    
        var modelList: MutableList<MyModel> = ArrayList<MyModel>()
        val selectedIds: MutableList<String> = ArrayList<String>()
    
        override fun getItemCount() = modelList.size
    
        override fun onBindViewHolder(holder: MyViewHolder?, index: Int) {
            holder?.textView?.setText(modelList[index].title)
    
            val id = modelList[index].id
    
            if (selectedIds.contains(id)) {
                //if item is selected then,set foreground color of FrameLayout.
                holder?.frameLayout?.foreground = ColorDrawable(ContextCompat.getColor(context, R.color.colorControlActivated))
            } else {
                //else remove selected item color.
                holder?.frameLayout?.foreground = ColorDrawable(ContextCompat.getColor(context, android.R.color.transparent))
            }
        }
    
        fun deleteSelectedIds() {
            if (selectedIds.size < 1) return
            val selectedIdIteration = selectedIds.listIterator();
    
            while (selectedIdIteration.hasNext()) {
                val selectedItemID = selectedIdIteration.next()
                var indexOfModelList = 0
                val modelListIteration: MutableListIterator<MyModel> = modelList.listIterator();
                while (modelListIteration.hasNext()) {
                    val model = modelListIteration.next()
                    if (selectedItemID.equals(model.id)) {
                        modelListIteration.remove()
                        selectedIdIteration.remove()
                        notifyItemRemoved(indexOfModelList)
                    }
                    indexOfModelList++
                }
    
                MainActivity.isMultiSelectOn = false
            }
        }
    
    
        override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): MyViewHolder {
            val inflater = LayoutInflater.from(parent?.context)
            val itemView = inflater.inflate(R.layout.view_holder_layout, parent, false)
            return MyViewHolder(itemView, this)
        }
    }
  10. MainActivity.kt implements MainInterface. It is the launching activity and it coordinate all what happens in this app  (step 7 above).
    1. the companion object is much like using the ‘static’ keyword in java. It contains object we would like to modify from different classes eg. modifying and accessing isMultiSelectOn in MyAdapter (step 9) above.
    2. overriden mainInterface callback starts action mode if not started and passes in the required Action Mode Callback. If the number of items (size) got from the calling back mainInterface (size : Int) is zero finish the actionMode else set the title with number of items
    3. ActionMode.Callback is Callback interface for action modes. Supplied to startActionMode(Callback), a Callback configures and handles events raised by a user’s interaction with an action mode.
    4. ActionModeCallback is an inner class of MainActivity that implements ActionMode . Callback. Check step 11 for more info,
    5. in overriden onCreate function we content view, initialize isMultiSelectOn to be false, grab an recycler view from xml (using it’s id) and initialize it to a value (recyclerView), set recyclerView to LinearLayoutManager, initialize my adapter and pass in required parameters, we generate dummy data and put it into our recyclerView Adapter and finally we notify the adapter of new content (i.e we tell the adapter to refresh).
    6. isMultiSelectOn is initialized to be false in onCreate because objects in companion object are not ‘recreated/reset’ when device is rotated. To keep things simple in this tutorial we’re not going to handle screen rotation, so, in order not to leave isMultiSelectOn (boolean) to chance we reset it (to false) because all other data (MyAdapter) has been reset (regenerated in onCreate).
    7. getDummyData function simply generates and returns a mutable List of dummy data.
    8. getRandomId generates and returns unique String Ids
    class MainActivity : AppCompatActivity(), MainInterface {
        var actionMode: ActionMode? = null
        var myAdapter: MyAdapter? = null
    
        companion object {
            var isMultiSelectOn = false
            val TAG = "MainActivity"
        }
    
        override fun mainInterface(size: Int) {
            if (actionMode == null) actionMode = startActionMode(ActionModeCallback())
            if (size > 0) actionMode?.setTitle("$size")
            else actionMode?.finish()
        }
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
    
            isMultiSelectOn = false
    
            val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
            recyclerView.layoutManager = LinearLayoutManager(this)
            myAdapter = MyAdapter(this, this)
            recyclerView.adapter = myAdapter
            myAdapter?.modelList = getDummyData()
            myAdapter?.notifyDataSetChanged()
        }
    
        inner class ActionModeCallback : ActionMode.Callback {
            var shouldResetRecyclerView = true
            override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
                when (item?.getItemId()) {
                    R.id.action_delete -> {
                        shouldResetRecyclerView = false
                        myAdapter?.deleteSelectedIds()
                        actionMode?.setTitle("") //remove item count from action mode.
                        actionMode?.finish()
                        return true
                    }
                }
                return false
            }
    
            override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
                val inflater = mode?.getMenuInflater()
                inflater?.inflate(R.menu.action_mode_menu, menu)
                return true
            }
    
            override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
                menu?.findItem(R.id.action_delete)?.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
                return true
            }
    
            override fun onDestroyActionMode(mode: ActionMode?) {
                if (shouldResetRecyclerView) {
                    myAdapter?.selectedIds?.clear()
                    myAdapter?.notifyDataSetChanged()
                }
                isMultiSelectOn = false
                actionMode = null
                shouldResetRecyclerView = true
            }
        }
    
        private fun getDummyData(): MutableList<MyModel> {
            Log.d(TAG, "inside getDummyData")
            val list = ArrayList<MyModel>()
            list.add(MyModel(getRandomID(), "1. GridView"))
            list.add(MyModel(getRandomID(), "2. Switch"))
            list.add(MyModel(getRandomID(), "3. SeekBar"))
            list.add(MyModel(getRandomID(), "4. EditText"))
            list.add(MyModel(getRandomID(), "5. ToggleButton"))
            list.add(MyModel(getRandomID(), "6. ProgressBar"))
            list.add(MyModel(getRandomID(), "7. ListView"))
            list.add(MyModel(getRandomID(), "8. RecyclerView"))
            list.add(MyModel(getRandomID(), "9. ImageView"))
            list.add(MyModel(getRandomID(), "10. TextView"))
            list.add(MyModel(getRandomID(), "11. Button"))
            list.add(MyModel(getRandomID(), "12. ImageButton"))
            list.add(MyModel(getRandomID(), "13. Spinner"))
            list.add(MyModel(getRandomID(), "14. CheckBox"))
            list.add(MyModel(getRandomID(), "15. RadioButton"))
            Log.d(TAG, "The size is ${list.size}")
            return list
        }
    
        fun getRandomID() = UUID.randomUUID().toString()
    }
  11. ActionModeCallback implements ActionMode.Callback (there’s difference between the two note the ‘dot’)
    An action mode’s lifecycle is as follows:

    1. onCreateActionMode(ActionMode, Menu) is called once on initial creation. Here, we inflated the Action Mode’s Menu (action_mode_menu)
    2. onPrepareActionMode(ActionMode, Menu) is called after creation and any time the ActionMode is invalidated/annulled
    3. onActionItemClicked(ActionMode, MenuItem) any time a contextual action button is clicked. That is, this callback is executed whenever an actionMode menu item is clicked.
    4. onDestroyActionMode(ActionMode) when the action mode is closed. onDestroyActionMode can be executed when actionMode.finish() is called or back-arrow actionMode button is pressed or if the android back button is pressed while the actionMode has started or still in operation.

Congratulations for making it thus far.

I admit, It’s not easy to consume all that technical jargon without getting mentally drained/exhausted hence, you deserve a Medal of Honor because you read all and digested it!

Congratulations once again, You did it i’m proud of you!

References

About Edge Developer

Hello there, my name is Opeyemi Olorunleke. I am a Software Developer (majorly Android, GitHub Profile), Digital Marketer, Udemy Instructor, Technical Writer, Blogger & Webmaster.

Check Also

Android Admob Consent SDK : All you need to know + Example

First of all, let me address Google’s complacency to help app developers implement the GDPR …

Leave a Reply

Your email address will not be published. Required fields are marked *