Android Setup RecylerView GridLayout Multi Select Like Google Photos App

September 30, 2019

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>
This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.