Adding a Gallery with multiselection to your Android App

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.

Gallery

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>

Gallery

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.

Gallery

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

Did you like this article?

Please share it

We are Stefan Fidanov & Vasil Lyutskanov. We share actionable advice about software development, freelancing and anything else that might be helpful.

It is everything that we have learned from years of experience working with customers from all over the world on projects of all sizes.

Let's work together
© 2024 Terlici Ltd · Terms · Privacy