Last time I showed you how to add a gallery to your Android app. Pretty much every app needs one. In that initial version you could only choose a single item. Yet, in the real world, most often than not you need to select multiple media items.
This time we are going to make several improvements
- Multiple ordered selection
- Video duration
The code in this article will not include everything, only the changes from what I showed you last time. They seem like two small updates but there are still some caveats that you need to be aware of.
Videos
The initial version of the gallery shows thumbnails from videos, but they look exactly the same as those from images (not very user friendly).
We already fetch the duration for video items but we don’t use it. Let’s fix that mistake in the UI first.
<TextView
android:id="@+id/duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginBottom="4dp"
android:layout_marginEnd="4dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold"
android:shadowColor="@color/black"
android:shadowRadius="4"
tools:text="11:11" />
This TextView
will be displayed at the bottom right on video items, showing their duration.
Everyone else will hide it.
Next, we are going to update the bind
method of GalleryViewHolder
by appending a few lines.
private class GalleryViewHolder extends RecyclerView.ViewHolder {
...
public void bind(@NonNull GalleryItem item, @NonNull View.OnClickListener onClickListener) {
binding.thumbnail.setOnClickListener(onClickListener);
Glide.with(itemView)
.load(item.uri)
.into(binding.thumbnail);
if (item.type == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
binding.duration.setVisibility(View.VISIBLE);
binding.duration.setText(DateUtils.formatElapsedTime(item.duration / 1000));
} else {
binding.duration.setVisibility(View.GONE);
}
}
}
If it is a video, display the duration, ohtherwise hide it.
Multiple selection
To implement this highly requested feature, we will add support for it to the GalleryViewModel
public class GalleryViewModel extends AndroidViewModel {
...
public final MutableLiveData<ArrayList<GalleryItem>> selectionLiveData = new MutableLiveData<>(new ArrayList<>());
...
public void select(GalleryItem item) {
ArrayList<GalleryItem> selection = selectionLiveData.getValue();
if (!selection.contains(item)) {
selection.add(item);
}
selectionLiveData.postValue(selection);
}
public void deselect(GalleryItem item) {
ArrayList<GalleryItem> selection = selectionLiveData.getValue();
selection.remove(item);
selectionLiveData.postValue(selection);
}
public boolean isSelected(GalleryItem item) {
ArrayList<GalleryItem> selection = selectionLiveData.getValue();
return selection.contains(item);
}
public int selectionIndex(GalleryItem item) {
ArrayList<GalleryItem> selection = selectionLiveData.getValue();
return selection.indexOf(item);
}
}
These methods and the property help us keep track of what is selected and the order of the selection. Unlike some apps (cough..cough..Viber) I prefer the order of selection to be kept and clear.
The gallery item layout needs one more update and now looks like this
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ImageView
android:id="@+id/thumbnail"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:scaleType="centerCrop"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginBottom="4dp"
android:layout_marginEnd="4dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold"
android:shadowColor="@color/black"
android:shadowRadius="4"
tools:text="11:11" />
<TextView
android:id="@+id/counter"
android:layout_width="24dp"
android:layout_height="24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="4dp"
android:layout_marginStart="4dp"
android:background="@drawable/background_gallery_item_counter"
android:textAlignment="center"
android:gravity="center"
android:textColor="@android:color/black"
android:textStyle="bold"
android:textSize="12sp"
tools:text="12" />
</androidx.constraintlayout.widget.ConstraintLayout>
The counter
element of type TextView
will display the order of the current selection
It has a simple yellow round drawable as a background
<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFBF00"/>
<stroke android:width="2dp"
android:color="#EADDCA" />
</shape>
The glue
Let’s add the code that glues it all together
private class GalleryViewHolder extends RecyclerView.ViewHolder {
...
private final ViewOutlineProvider roundRectOutlineProvider = new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), 32);
}
};
private void setSelected(int selectionIndex) {
boolean selected = selectionIndex > -1;
binding.thumbnail.setOutlineProvider(selected ? roundRectOutlineProvider : null);
binding.thumbnail.setClipToOutline(selected);
binding.counter.setVisibility(selected ? View.VISIBLE : View.GONE);
if (selected) {
binding.counter.setText(String.format(Locale.getDefault(), "%d", selectionIndex + 1));
}
}
private void animateSelected(int selectionIndex, Runnable completion) {
float animateScale = selectionIndex > -1 ? .9f : 1.1f;
ScaleAnimation animation = new ScaleAnimation(
1f,
animateScale,
1f,
animateScale,
Animation.RELATIVE_TO_SELF,
.5f,
Animation.RELATIVE_TO_SELF,
.5f);
animation.setDuration(70);
animation.setRepeatCount(1);
animation.setRepeatMode(Animation.REVERSE);
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
completion.run();
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
itemView.startAnimation(animation);
}
}
roundRectOutlineProvider
adds rounded corners to an item.
animateSelected
runs an animation on selection and deselection
setSelected
updates the look of the item depending on selection
There is one final update to the bind
method of GalleryViewHolder
private class GalleryViewHolder extends RecyclerView.ViewHolder {
...
public void bind(@NonNull GalleryItem item, @NonNull View.OnClickListener onClickListener) {
binding.thumbnail.setOnClickListener(v -> {
animateSelected(viewModel.selectionIndex(item), () -> {
onClickListener.onClick(v);
});
});
Glide.with(itemView)
.load(item.uri)
.into(binding.thumbnail);
if (item.type == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
binding.duration.setVisibility(View.VISIBLE);
binding.duration.setText(DateUtils.formatElapsedTime(item.duration / 1000));
} else {
binding.duration.setVisibility(View.GONE);
}
setSelected(viewModel.selectionIndex(item));
}
...
}
The animation will run everytime when the user taps on a gallery item.
The next change is a little tricky. Imagine that you’ve selected 5 items and they are enumerated from 1 to 5. Then you decide to deselect the item number 3. You have to update its view/cell, but you also have to update the views of items 4 & 5 because they are now the new items 3 & 4 respectively.
private class GalleryAdapter extends PagingDataAdapter<GalleryItem, GalleryViewHolder> {
...
private ArrayList<GalleryItem> selection = new ArrayList<>();
public void notifySelectionChanged(@NonNull ArrayList<GalleryItem> updatedSelection) {
for (int i = 0; i < getItemCount(); i++) {
GalleryItem item = getItem(i);
int oldIndex = selection.indexOf(item);
int newIndex = updatedSelection.indexOf(item);
if (oldIndex != newIndex) {
notifyItemChanged(i);
}
}
selection = new ArrayList<>(updatedSelection);
}
}
notifySelectionChanged
notifies the adapter which items need an update, which ultimately
triggers calling the bind method on the view holder.
selection = new ArrayList<>(updatedSelection)
is necessary or otherwise we might end up
comparing the same array with the same reference.
In turn this method is called only when the selection is updated
private void load() {
...
viewModel.selectionLiveData.observe(getViewLifecycleOwner(), adapter::notifySelectionChanged);
}
This is everything you need.
Next
This concludes these two part series on adding a gallery to your Android app. I will show you next how to do the same for iOS.
You can find all the code on GitHub