Android Setup RecylerView GridLayout Multi Select Like Google Photos App
September 30, 2019Setup RecyclerView Photo Grid Group by Date Header.
- Use afollestad/drag-select-recyclerview for drag multi selection.
- Simulate behaviour of Google Photos image selection (show selectionToolbar in selection mode)
- Survive rotation/configuration change
- Use RecyclerView with GridLayout
- Image loading via Fresco, with RecyclerView optimization
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>()
}
Menu for selectionToolbar
<?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.
- algolia
- analytics
- android
- android-ktx
- android-permission
- android-studio
- apps-script
- bash
- bootstrap
- bootstrapvue
- chartjs
- chrome
- cloud-functions
- coding-interview
- coroutines
- crashlytics
- css
- dagger2
- datastore
- datetime
- docker
- eslint
- firebase
- firebase-auth
- firebase-hosting
- firestore
- firestore-security-rules
- flask
- fontawesome
- fresco
- git
- github
- glide
- google-app-engine
- google-cloud-storage
- google-colab
- google-drive
- google-maps
- google-places
- google-play
- google-sheets
- gradle
- html
- hugo
- inkscape
- java
- java-time
- javascript
- jetson-nano
- kotlin
- layout
- lets-encrypt
- lifecycle
- linux
- logging
- lubuntu
- markdown
- mate
- material-design
- matplotlib
- md5
- mongodb
- moshi
- mplfinance
- mysql
- navigation
- nginx
- nodejs
- npm
- nuxtjs
- nvm
- pandas
- payment
- pip
- pwa
- pyenv
- python
- recylerview
- regex
- room
- rxjava
- scoped-storage
- selenium
- social-media
- ssh
- ssl
- static-site-generator
- static-website-hosting
- sublime-text
- ubuntu
- unit-test
- uwsgi
- viewmodel
- viewpager2
- virtualbox
- vue-chartjs
- vue-cli
- vue-router
- vuejs
- vuelidate
- vuepress
- web-development
- web-hosting
- webpack
- windows
- workmanager
- wsl
- yarn