Android Setup RecylerView GridLayout Multi Select Like Google Photos App

Setup RecyclerView Photo Grid Group by Date Header.

Android Multi Select

Dependencies

dependencies {    // multi selection    implementation 'com.afollestad:drag-select-recyclerview:2.3.0'    // implementation 'com.afollestad:drag-select-recyclerview:2.4.0' // SDK 19 required    // image loading    implementation 'com.facebook.fresco:fresco:2.0.0'}

Layout

res/layout/home.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:context=".view.HomeFragment"
    android:orientation="vertical"
    >

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/selectionToolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:visibility="gone"
        />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

res/layout/home_item_date.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:paddingTop="@dimen/margin16"
        android:paddingBottom="@dimen/margin16"
        android:paddingStart="@dimen/margin8"
        android:paddingEnd="@dimen/margin8"

        android:id="@+id/dateTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textAppearance="@style/TextAppearance.AppCompat.Medium" />
</LinearLayout>

res/layout/home_item_image.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:fresco="http://schemas.android.com/apk/res-auto"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/grid_image_background"
    android:layout_margin="@dimen/margin2"
    >

    <com.facebook.drawee.view.SimpleDraweeView
        android:id="@+id/draweeView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        fresco:placeholderImage="@drawable/img_placeholder_100dp"
        fresco:viewAspectRatio="1"
        />

    <ImageView
        android:id="@+id/selectableImageView"
        android:layout_margin="@dimen/margin8"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:srcCompat="@drawable/ic_radio_button_unchecked_black_24dp"
        android:layout_gravity="top"
        />
</FrameLayout>

Data Class

sealed class ViewItem(val resource: Int) {    class DateItem(val posted: LocalDateTime): ViewItem(R.layout.home_item_date)    class ImageItem(val created: LocalDateTime, val uri: Uri): ViewItem(R.layout.home_item_image)}

Fragment

class HomeFragment : Fragment() {    private lateinit var viewModel: HomeViewModel    private lateinit var adapter: LocalAdapter    private lateinit var touchListener: DragSelectTouchListener    override fun onActivityCreated(savedInstanceState: Bundle?) {        super.onActivityCreated(savedInstanceState)        viewModel = ViewModelProviders.of(this).get(HomeViewModel::class.java)        init()    }    private fun init() {        // init RecyclerView        initList()        // init toolbar to be displayed when in selection mode        selectionToolbar.apply {            // setTitleTextColor(ContextCompat.getColor(context, R.color.selection_icon_passive))            // menu            inflateMenu(R.menu.selection)            setOnMenuItemClickListener {                when(it.itemId) {                    R.id.add_to -> {                        // TODO: do something                        true                    }                    else -> false                }            }            // use navigationIcon as close icon            navigationIcon = ContextCompat.getDrawable(context, R.drawable.selector_ic_close)            setNavigationOnClickListener {                setSelectionMode(false)            }        }        // long click on photo item to enable selection mode        viewModel.longClickItemEvent.observe(this, Observer { event ->            event.getIfPending()?.also { position ->                setSelectionMode(true)                touchListener.setIsActive(true, position)                selectionToolbar.apply {                    isVisible = true                    title = viewModel.selectedIndexes.size.toString()                }            }        })        // update count        viewModel.selectionCount.observe(this, Observer { count ->            selectionToolbar.title = count.toString()        })        // init upon configuration change/rotation        if (viewModel.isSelectionMode) {            setSelectionMode(true)        }    }    private fun initList() {        val spanCount = if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 3 else 6        val layoutManager = GridLayoutManager(context, spanCount)        list.layoutManager = layoutManager        val estimatedImageSize = Resources.getSystem().displayMetrics.widthPixels / layoutManager.spanCount        adapter = LocalAdapter(context!!, viewModel, estimatedImageSize)        list.adapter = adapter        val receiver = LocalReceiver(adapter)        // enable multiselect        touchListener = DragSelectTouchListener.create(context!!, receiver) {        }        list.addOnItemTouchListener(touchListener)        // load data        val items = mutableListOf<ViewItem>()        // TODO: load date and photo items        adapter.replaceItems(items)        // expand columns to single row to show date        layoutManager.spanSizeLookup = object: GridLayoutManager.SpanSizeLookup() {            override fun getSpanSize(position: Int): Int {                return when (adapter.getItemViewType(position)) {                    R.layout.home_item_date -> layoutManager.spanCount                    else -> 1                }            }        }    }    private fun setSelectionMode(selection: Boolean) {        if (selection) {            viewModel.isSelectionMode = true            // optional - hide activity toolbar and bottomNavView            /*            activity?.apply {                bottomNavView?.isVisible = false                toolbar.isVisible = false            }             */            // fragment            selectionToolbar.isVisible = true            // ViewCompat.setNestedScrollingEnabled(list, false)        }        else {            viewModel.isSelectionMode = false           // optional - show activity toolbar and bottomNavView            /*            activity?.apply {                bottomNavView?.isVisible = true                toolbar.isVisible = true            }             */            // fragment            selectionToolbar.isVisible = false            // ViewCompat.setNestedScrollingEnabled(list, true)            adapter.clearSelection()        }        adapter.notifyDataSetChanged()    }}

Receiver for Multi Selection

class LocalReceiver(private val adapter: LocalAdapter) : DragSelectReceiver {    override fun setSelected(index: Int, selected: Boolean) {        // required to prevent invalid selection        if (!isIndexSelectable(index)) {            return        }        adapter.setSelected(index, selected)    }    override fun isSelected(index: Int): Boolean {        // return true if this index is currently selected        return adapter.isSelected(index)    }    override fun isIndexSelectable(index: Int): Boolean {        // if you return false, this index can't be used with setIsActive()        // only image can be selected, not date        return adapter.getItemViewType(index) == R.layout.home_item_image    }    override fun getItemCount(): Int {        // return size of your data set        return adapter.itemCount    }}

Adapter for RecyclerView

class LocalAdapter(private val context: Context, private val viewModel: HomeViewModel, private val estimatedImageSize: Int) : RecyclerView.Adapter<LocalAdapter.ViewHolder>() {    private var resizeOptions: ResizeOptions? = null    private val resources = context.resources    private var items = listOf<ViewItem>()    init {        setHasStableIds(true)    }    fun setSelected(position: Int, selected: Boolean) {        // use viewModel to survive rotation        if (selected)            viewModel.selectedIndexes.add(position)        else            viewModel.selectedIndexes.remove(position)        notifyItemChanged(position)        viewModel.selectionCount.value = viewModel.selectedIndexes.size    }    fun isSelected(position: Int) = position in viewModel.selectedIndexes    fun clearSelection() {        viewModel.selectedIndexes = mutableSetOf()    }    override fun getItemViewType(position: Int): Int {        return items[position].resource    }    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {        val view = LayoutInflater.from(parent.context)            .inflate(viewType, parent, false)        return ViewHolder(view)    }    override fun onBindViewHolder(holder: ViewHolder, position: Int) {        when(val item = items[position]) {            is ViewItem.DateItem -> {                val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)                holder.dateTextView.text = item.posted.format(formatter)            }            is ViewItem.ImageItem -> {                holder.apply {                    if (viewModel.isSelectionMode) { // update view in selection mode                        selectableImageView.isVisible = true                        if (isSelected(position)) {                            // TODO: optimize performance                            draweeView.setPadding(resources.getDimension(R.dimen.margin16).toInt())                            selectableImageView.setImageResource(R.drawable.ic_check_circle_black_24dp)                            ImageViewCompat.setImageTintList(selectableImageView, ContextCompat.getColorStateList(context, R.color.grid_image_selectable_icon_active))                        } else {                            draweeView.setPadding(0)                            selectableImageView.setImageResource(R.drawable.ic_radio_button_unchecked_black_24dp)                            ImageViewCompat.setImageTintList(selectableImageView, ContextCompat.getColorStateList(context, R.color.grid_image_selectable_icon))                        }                        // select on click                        itemView.setOnClickListener {                            setSelected(position, !isSelected(position))                        }                    }                    else {                        draweeView.setPadding(0)                        selectableImageView.isVisible = false                        itemView.setOnClickListener {                            // TODO: view image                        }                    }                }                // optimize image loading via Fresco                val localResizeOptions = if (resizeOptions == null) {                    holder.draweeView.doOnLayout {                        resizeOptions = ResizeOptions(holder.draweeView.width, holder.draweeView.height)                    }                    ResizeOptions(estimatedImageSize, estimatedImageSize)                }                else resizeOptions                val request =                      ImageRequestBuilder.newBuilderWithSource(item.uri)                          .setResizeOptions(localResizeOptions)                          .build()                holder.draweeView.setImageRequest(request)                // enable selection mode on long click                holder.itemView.setOnLongClickListener {                    viewModel.longClickItemEvent.value = Event(position)                    true                }            }        }    }    fun replaceItems(items: List<ViewItem>) {        // prevent screen rotation clear selection        if (!viewModel.isSelectionMode && this.items.isNotEmpty()) {            clearSelection()        }        this.items = items        notifyDataSetChanged()    }    override fun getItemCount(): Int {        return items.size    }    override fun getItemId(position: Int): Long {        return items[position].hashCode().toLong()    }    inner class ViewHolder(override val containerView: View) : RecyclerView.ViewHolder(containerView),        LayoutContainer}

ViewModel

class HomeViewModel : ViewModel() {    internal val longClickItemEvent = MutableLiveData<Event<Int>>()    internal val selectionCount = MutableLiveData<Int>()    // put here to survive rotation    internal var isSelectionMode = false    internal var selectedIndexes = mutableSetOf<Int>()}
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/add_to"
        android:icon="@drawable/ic_add_black_24dp"
        app:iconTint="@color/selection_icon_active"
        android:title="Add to"
        app:showAsAction="always"
        />

    <item
        android:id="@+id/delete"
        android:icon="@drawable/ic_delete_black_24dp"
        app:iconTint="@color/selection_icon_active"
        android:title="Delete"
        app:showAsAction="always"
        />

    <item
        android:id="@+id/share"
        android:title="Share"
        app:showAsAction="never"
        />
</menu>

NOTE: You might be interested with RecyclerView Gridlayout Multi Selection With Toolbar.

❤️ Is this article helpful?

Buy me a coffee ☕ or support my work via PayPal to keep this space 🖖 and ad-free.

Do send some 💖 to @d_luaz or share this article.

✨ By Desmond Lua

A dream boy who enjoys making apps, travelling and making youtube videos. Follow me on @d_luaz

👶 Apps I built

Travelopy - discover travel places in Malaysia, Singapore, Taiwan, Japan.