How to select Multiple Item in RecyclerView in Android
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 .
[box type="info" align="" class="" width=""]
Disclaimer
I would like to mentally prepare you, this tutorial a bit lengthy.
To absorb all the concept
If you have ANY question, just ask! I'll be happy to help.[/box]
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
[box type="info" align="" class="" width=""]
Disclaimer
I would like to mentally prepare you, this tutorial a bit lengthy.
To absorb all the concept
- please pay close attention
- make sure you are well rested
- make sure you have eaten. LOL
If you have ANY question, just ask! I'll be happy to help.[/box]
Aim:
- To demystify multiple item selection in a RecyclerView
Goals:
- Demonstrate how to select multiple items in a RecyclerView
- Use actionMode to perform actions with the selected item
Solution Steps:
[box type="download" align="" class="" width=""]Grab Source Code on GitHub[/box]- colors.xml : The color resource on line 6 (colorControlActivated) will overlay selected items.
<?xml version="1.0" encoding="utf-8"?> <android.support.v4.widget.DrawerLayout 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/drawer_layout" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" tools:openDrawer="start"> <include layout="@layout/app_bar_main" android:layout_width="match_parent" android:layout_height="match_parent" /> <android.support.design.widget.NavigationView android:id="@+id/nav_view" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_gravity="start" android:fitsSystemWindows="true" app:headerLayout="@layout/nav_header_main" app:menu="@menu/activity_main_drawer" /> </android.support.v4.widget.DrawerLayout>
[code lang="html"]
<?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>
[/code]
- 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"/>
- 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) } }
- 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>
- 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)
- 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) }
- 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 :
- 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.
- we avoided hard coding the RecyclerViewAdapter to MainActivity's actionMode, hence making the adapter usable in aufragment.
- 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) }
- 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 } }
- MyAdapter.kt : PAY ATTENTION, This is where the Magic Happens!
- MyAdapter requires Context and MainInterface to be passed into its constructor
- This adapter class extends RecyclerView.Adapter<MyViewHolder> as required, in order to qualify as a RecyclerView Adapter.
- 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.
- MyAdapter implements ViewHolderClickListerner with onLongTap and onTap callbacks. Point 6 above
- 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!
- 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.
- 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.
- 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.
- 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.
- 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!
- 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) } }
- MainActivity.kt implements MainInterface. It is the launching activity and it coordinate all what happens in this app (step 7 above).
- 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.
- 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
- 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.
- ActionModeCallback is an inner class of MainActivity that implements ActionMode . Callback. Check step 11 for more info,
- 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).
- 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).
- getDummyData function simply generates and returns a mutable List of dummy data.
- 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() }
- ActionModeCallback implements ActionMode.Callback (there's difference between the two note the 'dot')
An action mode's lifecycle is as follows:
- onCreateActionMode(ActionMode, Menu) is called once on initial creation. Here, we inflated the Action Mode's Menu (action_mode_menu)
- onPrepareActionMode(ActionMode, Menu) is called after creation and any time the ActionMode is invalidated/annulled
- onActionItemClicked(ActionMode, MenuItem) any time a contextual action button is clicked. That is, this callback is executed whenever an actionMode menu item is clicked.
- 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
- RecyclerView MultiSelect - Big Nerd Ranch
- Multi select like WhatsApp in Android - Droid Mentor
- Multiple selection RecyclerView Android - Learn Painless
- How to implement Multi-select in Recyclerview - Stack Overflow
Comments
Post a Comment