Setup 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.