Custom Gallery For Android

Image for post
Image for post
Android Custom gallery with a grid view

Content:

Pre-Requisites:

Why to use ?

Let’s get Started

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
// Recycler View
implementation 'androidx.recyclerview:recyclerview:1.0.0'

def
lifecycle_version = "2.0.0"

// ViewModel and LiveData
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"

// optional - ReactiveStreams support for LiveData
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycle_version"

// Glide (Image caching and management)
def glideVersion = "4.8.0"
kapt "com.github.bumptech.glide:compiler:$glideVersion"
implementation "com.github.bumptech.glide:glide:$glideVersion"

// reactive programming for Android
implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'

Next up

class GalleryViewModel : ViewModel() {
private val compositeDisposable = CompositeDisposable()
private var startingRow = 0
private var rowsToLoad = 0
private var allLoaded = false

fun
getImagesFromGallery(context: Context, pageSize: Int, list: (List<GalleryPicture>) -> Unit) {
compositeDisposable.add(
Single.fromCallable {
fetchGalleryImages(context, pageSize)
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
.subscribe({
list(it)
}, {
it
.printStackTrace()
})
)
}


fun getGallerySize(context: Context): Int {
val columns =
arrayOf(MediaStore.Images.Media.DATA, MediaStore.Images.Media._ID) //get all columns of type images
val orderBy = MediaStore.Images.Media.DATE_TAKEN //order data by date

val cursor = context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, columns, null,
null, "$orderBy DESC"
) //get all data in Cursor by sorting in DESC order

val rows = cursor!!.count
cursor.close()
return rows


}

private fun fetchGalleryImages(context: Context, rowsPerLoad: Int): List<GalleryPicture> {
val galleryImageUrls = LinkedList<GalleryPicture>()
val columns = arrayOf(MediaStore.Images.Media.DATA, MediaStore.Images.Media._ID) //get all columns of type images
val orderBy = MediaStore.Images.Media.DATE_TAKEN //order data by date

val cursor = context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, columns, null,
null, "$orderBy DESC"
) //get all data in Cursor by sorting in DESC order

Log.i("GalleryAllLoaded", "$allLoaded")

if (cursor != null && !allLoaded) {

val totalRows = cursor.count

allLoaded = rowsToLoad == totalRows

if (rowsToLoad < rowsPerLoad) {
rowsToLoad = rowsPerLoad
}







for (i in startingRow until rowsToLoad) {
cursor.moveToPosition(i)
val dataColumnIndex = cursor.getColumnIndex(MediaStore.Images.Media.DATA) //get column index
galleryImageUrls.add(GalleryPicture(cursor.getString(dataColumnIndex))) //get Image from column index

}
Log.i("TotalGallerySize", "$totalRows")
Log.i("GalleryStart", "$startingRow")
Log.i("GalleryEnd", "$rowsToLoad")

startingRow = rowsToLoad

if
(rowsPerLoad > totalRows || rowsToLoad >= totalRows)
rowsToLoad = totalRows
else {
if (totalRows - rowsToLoad <= rowsPerLoad)
rowsToLoad = totalRows
else
rowsToLoad
+= rowsPerLoad


}

cursor.close()
Log.i("PartialGallerySize", " ${galleryImageUrls.size}")
}

return galleryImageUrls
}

override fun onCleared() {
compositeDisposable.clear()
}
}

How the pagination works ?

private lateinit var adapter: GalleryPicturesAdapter
private lateinit var galleryViewModel: GalleryViewModel

private lateinit var pictures: ArrayList<GalleryPicture>

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_multi_gallery_ui)
requestReadStoragePermission()
}

private fun requestReadStoragePermission() {
val readStorage = Manifest.permission.READ_EXTERNAL_STORAGE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && ContextCompat.checkSelfPermission(
this,
readStorage
) != PackageManager.PERMISSION_GRANTED
) {
requestPermissions(arrayOf(readStorage), 3)
} else init()
}

galleryViewModel = ViewModelProviders.of(this)[GalleryViewModel::class.java]
updateToolbar(0)
val layoutManager = GridLayoutManager(this, 3)
rv.layoutManager = layoutManager
rv.addItemDecoration(SpaceItemDecoration(8))
pictures = ArrayList(galleryViewModel.getGallerySize(this))
adapter = GalleryPicturesAdapter(pictures, 10)
rv.adapter = adapter



adapter
.setOnClickListener { galleryPicture ->
showToast(galleryPicture.path)
}


adapter
.setAfterSelectionListener {
updateToolbar(getSelectedItemsCount())
}

rv.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (layoutManager.findLastVisibleItemPosition() == pictures.lastIndex) {
loadPictures(25)
}
}
})

tvDone.setOnClickListener {
super
.onBackPressed()

}

ivBack.setOnClickListener {
onBackPressed()
}
loadPictures(25)

}
private fun loadPictures(pageSize: Int) {
galleryViewModel.getImagesFromGallery(this, pageSize) {
if
(it.isNotEmpty()) {
pictures.addAll(it)
adapter.notifyItemRangeInserted(pictures.size, it.size)
}
Log.i("GalleryListSize", "${pictures.size}")

}

}

How the selection algorithm works ?

class GalleryPicturesAdapter(private val list: List<GalleryPicture>) : RecyclerView.Adapter<GVH>() {

init {
initSelectedIndexList()
}

constructor(list: List<GalleryPicture>, selectionLimit: Int) : this(list) {
setSelectionLimit(selectionLimit)
}

private lateinit var onClick: (GalleryPicture) -> Unit
private lateinit var afterSelectionCompleted: () -> Unit
private var isSelectionEnabled = false
private lateinit var selectedIndexList
: ArrayList<Int> // only limited items are selectable.
private var selectionLimit = 0


private fun initSelectedIndexList() {
selectedIndexList = ArrayList(selectionLimit)
}

fun setSelectionLimit(selectionLimit: Int) {
this.selectionLimit = selectionLimit
removedSelection()
initSelectedIndexList()
}

fun setOnClickListener(onClick: (GalleryPicture) -> Unit) {
this.onClick = onClick
}

fun setAfterSelectionListener(afterSelectionCompleted: () -> Unit) {
this.afterSelectionCompleted = afterSelectionCompleted
}

private fun checkSelection(position: Int) {
if (isSelectionEnabled) {
if (getItem(position).isSelected)
selectedIndexList.add(position)
else {
selectedIndexList.remove(position)
isSelectionEnabled = selectedIndexList.isNotEmpty()
}
}
}

// Useful Methods to provide delete feature.

// fun deletePicture(picture: GalleryPicture) {
// deletePicture(list.indexOf(picture))
// }
//
// fun deletePicture(position: Int) {
// if (File(getItem(position).path).delete()) {
// list.removeAt(position)
// notifyItemRemoved(position)
// } else {
// Log.e("GalleryPicturesAdapter", "Deletion Failed")
// }
// }

override fun onCreateViewHolder(p0: ViewGroup, p1: Int): GVH {
val vh = GVH(LayoutInflater.from(p0.context).inflate(R.layout.multi_gallery_listitem, p0, false))
vh.containerView.setOnClickListener {
val
position = vh.adapterPosition
val picture = getItem(position)
if (isSelectionEnabled) {
handleSelection(position, it.context)
notifyItemChanged(position)
checkSelection(position)
afterSelectionCompleted()

} else
onClick
(picture)


}
vh.containerView.setOnLongClickListener {
val
position = vh.adapterPosition
isSelectionEnabled = true
handleSelection(position, it.context)
notifyItemChanged(position)
checkSelection(position)
afterSelectionCompleted()



isSelectionEnabled
}
return
vh
}

private fun handleSelection(position: Int, context: Context) {

val picture = getItem(position)

picture.isSelected = if (picture.isSelected) {
false
} else {
val selectionCriteriaSuccess = getSelectedItems().size < selectionLimit
if
(!selectionCriteriaSuccess)
selectionLimitReached(context)

selectionCriteriaSuccess
}

}

fun getSelectionLimit() = selectionLimit


private fun
selectionLimitReached(context: Context) {
Toast.makeText(
context,
"${getSelectedItems().size}/$selectionLimit selection limit reached.",
Toast.LENGTH_SHORT
).show()
}

private fun getItem(position: Int) = list[position]

override fun onBindViewHolder(p0: GVH, p1: Int) {
val picture = list[p1]
GlideApp.with(p0.containerView).load(picture.path).into(p0.ivImg)

if (picture.isSelected) {
p0.vSelected.visibility = View.VISIBLE
} else {
p0.vSelected.visibility = View.GONE
}
}

override fun getItemCount() = list.size


fun
getSelectedItems() = selectedIndexList.map {
list
[it]
}


fun
removedSelection(): Boolean {
return if (isSelectionEnabled) {
selectedIndexList.forEach {
list
[it].isSelected = false
}
isSelectionEnabled
= false
selectedIndexList
.clear()
notifyDataSetChanged()
true

} else false
}
adapter.setAfterSelectionListener {
updateToolbar(getSelectedItemsCount())
}
private fun checkSelection(position: Int) {
if (isSelectionEnabled) {
if (getItem(position).isSelected)
selectedIndexList.add(position)
else {
selectedIndexList.remove(position)
isSelectionEnabled = selectedIndexList.isNotEmpty()
}
}
}
private fun handleSelection(position: Int, context: Context) {

val picture = getItem(position)

picture.isSelected = if (picture.isSelected) {
false
} else {
val selectionCriteriaSuccess = getSelectedItems().size < selectionLimit
if
(!selectionCriteriaSuccess)
selectionLimitReached(context)

selectionCriteriaSuccess
}

}
fun removedSelection(): Boolean {
return if (isSelectionEnabled) {
selectedIndexList.forEach {
list
[it].isSelected = false
}
isSelectionEnabled
= false
selectedIndexList
.clear()
notifyDataSetChanged()
true

} else false
}

Algorithmic Breakdown

Last Step

Software Engineer II (Android) @ Careem || Fitness Freak for 9 years

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store