Custom Gallery For Android

Mobin Munir
AndroidPub
Published in
8 min readApr 14, 2019

--

Android Custom gallery with a grid view

Hey Guys !! This is my 2nd story here on medium. After writing up on Camera2 Android API I realized another common problem that android developers face, which is a need for a customized gallery inside their android applications. So I came up with a generic solution which has a lot of pros as compared to other 3rd-party libraries and solutions online which implicated limitations when compiled as dependencies in your app. You can find the complete code on Github.

Content:

The purpose of this article is to show you the pros and the magic behind the custom gallery working as well as how the image selection algorithm works with a great run-time complexity. I will also be explaining about the custom pagination I developed to fetch all images from the device storage using Android’s Cursor API asynchronously through reactive programming while following a clean code architecture.

Pre-Requisites:

We will be using Kotlin and AndroidX artifacts so you must have them configured and RxJava for the purpose of fetching images in background and posting result to main thread. So I assume you are familiar with basics of reactive programming.

Why to use ?

  • Shows all images smoothly from device storage.
  • Multiple Image Selection.
  • Open to Support for Image Deletion.
  • Uses Pagination.
  • Its not a fixed dependency to be included in your project to increase redundancy.
  • Its flexible to be converted in any library/SDK or modular form as per your requirement.
  • Modifications/Enhancements can be made as required.
  • Highly decoupled,optimized and clean code.
  • No Obfuscation Required (Proguard/Dexguard).
  • It would be a part of your project while not implying any 3rd-party involvement.

Let’s get Started

1st of all you will need to add read storage permission in your Android Manifest and also ask it at run time before allowing the user to open your gallery activity. This permission is required for reading data from device storage.

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

Then you will need to add the following 5 dependencies in your build.gradle(Module) file.

// 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'

These dependencies above will import RecyclerView, LiveData (support to use view models),Glide to load images asynchronously, and RxJava in your project.

Next up

You will optionally need to create a view model which extends Android’s view model class which will contain the methods to get images from gallery and other helper methods to update the UI (Activity or Fragment).

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()
}
}

The code above loads images asynchronously based on the provided page size from the UI using cursor API.

How the pagination works ?

The pagination algorithm is a simple one. It loads each time the number of images provided by the page size parameter once the user scrolls down to the last image of the list. The code is pretty much self explanatory. See the partial code for Activity below. In onCreate method we initialize the list which will contain all the images from the device storage. We initialize the list with the initial capacity of total number of images in the device. This will cause a big performance boost since the Array List won’t be resized to accommodate more images as they are loaded.

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)

}

After the OnCreate is setup let’s see how we are loading pictures with a page size. If you have noticed in above code we have added a scroll listener on the RecyclerView to know when the user reaches the last partially visible image in the list so that we can load more images gradually. We have provided a value of 25 to the page size parameter which tells the method to load 25 images initially as well as when the user scrolls down. That’s pretty much it on the pagination.

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 ?

Let’s dive into our RecylerView Adapter class to see how the selection algorithm works. To be noted, This is a generic algorithm and can be modified for any type of items in your list to provide the selection feature for your listing UI.

On Long Click, Selection is activated and it will last until no item remains selected by user. Then On Click method can be used to select as many items as prescribed by the selection limit integer. When you attach adapter to your recycler view you can pass the number of items which can be selected as an integer in the constructor or by invoking method setSelectionLimit. If you choose to set selection limit at a later time or again once set then your current selection of items would be immediately cleared/deselected if any.

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
}

There is 1 listener which is triggered and gets result back to UI immediately called after selection listener. Whenever the user selects or deselects any item its called in the end. See below from init method above.

Here I am updating the toolbar to show the items selected as the user does.

adapter.setAfterSelectionListener {
updateToolbar(getSelectedItemsCount())
}

The magic happens in checkSelection method stated above. Which checks if an item at a particular position in the list is selected or not.

If the selection is active, then whenever user selects an item the position of that item is added to a list also declared above called selectedIndexList which is initially constructed with a capacity of the selection limit we provide to it.

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

When the user is done with selection and decides to deselect all items simultaneously or at once the selection mode is then deactivated to resume normal behavior. Now we examine the handleSelection method to see how it ensures selection to a certain limit criteria. Again, using the setSelectionLimit method or by constructor a value can be provided for the total number of items that can be selected.

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
}

}

The above takes care of selection algorithm. Now how does the deselection works. Its simple when the user presses back button we originally have a list containing indices of selected items. The removed selection method checks if the selection is enabled then frees the items and disables selection then returns either true or false. We iterate over the selectedItemsIndex list to update the selection property of each individual item in the original item list and then notify the adapter of the changes and the UI is freed of selection.

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

} else false
}

That’s all on the selection algorithm. Other methods in the adapter class are simple and don’t require any explanation on my end.

Algorithmic Breakdown

Generally, It has a constant run time complexity.

Asymptotically,

n = 1000, (n being number of images/items in the list)

s = 200, ( s being number of images/items selected in the list)

On 1 item basis:

Selection: O(1),Ω(1)

Deselection:

For 1 item:

O(1) , Ω(1)

For all Items:

O(s) , Ω(1)

Last Step

Many thanks to everyone who reads this and even more to those who used this. Let me know if you like this and whatever topics you wish for me to write in regards to Android Development. You can follow me here for updates and/or reach me at LinkedIn or Facebook. You can greatly help by starring and forking on github.

--

--

Mobin Munir
AndroidPub

Lead Software Engineer (Android) @ Yassir || Fitness Freak of 13 years