Galería (Parte I)

28 July 2016

En cualquier aplicación puede que tengamos que mostrar al usuario una serie de imágenes de manera gráfica para que pueda seleccionarla como su avatar, imagen de fondo, etc. Si estas imágenes no son predefinidas, la primera aproximación suele ser llevada a cabo usando la aplicación Galería que todos los dispositivos tienen. Lanzando una intención con la acción ACTION_PICKER, mostramos al usuario una interfaz conocida que permite seleccionar cualquier imagen propia.

Pero ¿qué ocurre si necesitamos algo más personalizado? ¿Cómo obtener y pintar en una lista o cuadrícula todas las imágenes o videos que están almacenados en el dispositivo? El hecho de personalizar una galería en lugar de utilizar la app nativa del dispositivo, nos permite jugar con algunas opciones que iré desvelando en próximas entregas.

Galería

El primer paso será construir un grid para pintar todos los ficheros. Para aprovechar al máximo la pantalla, he decidido calcular, a partir de un ancho mínimo, el número de columnas. Así podremos usarla en diferentes modelos de dispositivo con un número dinámico de elementos en pantalla.

int columns = getMaxColumns();
galleryAdapter = new GalleryAdapter(getContext(), columns);
staggeredGridLayoutManager = new StaggeredGridLayoutManager(columns, StaggeredGridLayoutManager.VERTICAL);
galleryRecyclerView.setLayoutManager(staggeredGridLayoutManager);
galleryRecyclerView.setAdapter(galleryAdapter);

Utilizo un StaggeredGridLayoutManager, ya que una de sus funciones es permitir a un elemento ocupar más de una columna. Gracias a esto, podemos tener una cabecera que ocupe todo el espacio y colocar diversas opciones (en nuestro caso será un botón para abrir la cámara).

Galería
Galería de animalitos
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
 View v;
 switch (viewType) {
    case VIEW_HOLDER_TYPE_HEADER:
        v = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.item_gallery_header, viewGroup, false);
        return new GalleryHeaderViewHolder(v);
    case VIEW_HOLDER_TYPE_ITEM:
    default:
        v = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.item_gallery, viewGroup, false);
        return new GalleryItemViewHolder(v);
    }
}

@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
 try {
     final ViewGroup.LayoutParams layoutParams = viewHolder.itemView.getLayoutParams();
     StaggeredGridLayoutManager.LayoutParams sglayoutParams =
     (StaggeredGridLayoutManager.LayoutParams) layoutParams;
     if (viewHolder instanceof GalleryHeaderViewHolder) {
         sglayoutParams.setFullSpan(true);
         fill((GalleryHeaderViewHolder) viewHolder);
     } else {
         sglayoutParams.setFullSpan(false);
         fill((GalleryItemViewHolder) viewHolder, galleryMedias.get(position - 1));
     }
     viewHolder.itemView.setLayoutParams(sglayoutParams);
  } catch (Exception e) {
    Log.e(TAG, "[onBindViewHolder] ", e);
  }
}

Utilizando el método setFullSpan(true) sobre el ViewHolder obtenemos este comportamiento.

Obtener las imágenes

Una vez tenemos configurado nuestra vista, debemos obtener del dispositivo las imágenes a mostrar. Para ello, debemos utilizar una de las herramientas que nos proporciona el SDK de Android: ContentProvider.

Un ContentProvider no es más que un proveedor de contenido entre aplicaciones a través de la interfaz ContentResolver. Un ejemplo de contenido que se comparte entre apps son los contactos, que están almacenados en una base de datos. Este contenido se considera exportable y puede ser solicitado por otra app. En nuestro caso, necesitamos obtener todas las imágenes y videos que estén en la galería del dispositivo.

final String[] columns = {
   MediaStore.Images.Media.DATA, 
   MediaStore.Images.Media._ID, 
   MediaStore.Images.Media.MIME_TYPE,
   MediaStore.Images.Media.DATE_TAKEN };
final String orderBy = MediaStore.Images.Media.DATE_TAKEN;
Cursor imageCursor = context.getContentResolver()
   .query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, columns, null, null, orderBy + " DESC");

Este snippet nos proporciona todo lo que necesitamos:

  • Las columnas data, id, mimeType y dateTaken.
  • En orden descendente del campo dateTaken
  • La uri MediaStore.Images.Media.EXTERNAL_CONTENT_URI en un ContentResolver.

Como veis, tiene una sintaxis parecida a una petición SQL (los parámetros null serían para hacer selection sobre los resultados). Una vez completada la consulta, obtenemos un Cursor que recorremos para mapear todos los ficheros y poder devolvérselos a nuestro adapter.

imageCursor.moveToFirst();
int imageCount = imageCursor.getCount();
for (int i = 0; i < imageCount; i++) {
    ...
    imageCursor.moveToNext();
}
imageCursor.close();

Una vez obtenidas todas las imágenes, solo nos queda solicitar todos los videos; ya que no se pueden obtener en la misma petición. Se pueden obtener utilizando MediaStore.Video.Media.EXTERNAL_CONTENT_URI, como hemos visto con las imágenes.

Es importante resaltar que estas operaciones son de un gran costo en términos de rendimiento y no deben ser nunca ejecutadas en el hilo principal. Existen multitud de opciones, yo he tomado el camino reactivo y he utilizado RxJava.

public void getGalleryAsync() {
    final Observable<List<GalleryMedia>> observable =
           Observable.create((Observable.OnSubscribe<List<GalleryMedia>>) subscriber -> {
               subscriber.onNext(getGallery());
               subscriber.onCompleted();
           }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
    observable.subscribe(this::onGalleryMedia, this::onGalleryError);
}

Obtener nueva foto de la cámara

La cabecera que hemos dejado libre servirá al usuario para lanzar la app de la cámara y hacer una foto nueva. Para ello, simplemente necesitaremos lanzar una intención y que la reciba el sistema. Además, para tener mayor control sobre el fichero nuevo, proporcionaremos la uri donde se escribirá el nuevo contenido. Este paso es opcional, pues podemos permitir al sistema manejarlo directamente.

public static File getOutputMediaFile(int type) {
    File mediaStorageDir =
            new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), MEDIA_FOLDER);
    if (!mediaStorageDir.exists() && !mediaStorageDir.mkdirs()) {
        Log.e(TAG, "Failed to create directory " + MEDIA_FOLDER);
        return null;
    }
    String timeStamp = now();
    File mediaFile;
    switch (type) {
        case MEDIA_TYPE_IMAGE:
            mediaFile = new File(mediaStorageDir.getPath() + File.separator + "VID_" + timeStamp + ".jpeg");
            break;
        case MEDIA_TYPE_VIDEO:
            mediaFile = new File(mediaStorageDir.getPath() + File.separator + "VID_" + timeStamp + ".mp4");
            break;
        default:
            mediaFile = null;
            break;
    }
    return mediaFile;
}
Intent cameraIntent = new Intent();
cameraIntent.setAction(MediaStore.ACTION_IMAGE_CAPTURE);
ContentValues values = new ContentValues(1);
mimeType = MIME_TYPE_IMAGE;
values.put(MediaStore.Images.Media.MIME_TYPE, mimeType);
mediaUri = Uri.fromFile(FileUtils.getOutputMediaFile(FileUtils.MEDIA_TYPE_IMAGE));
cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, mediaUri);
cameraIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
activity.startActivityForResult(cameraIntent, REQUEST_CODE_CAMERA);

Una vez completada la operación, recibiremos los datos directamente desde onActivityResult y deberemos refrescar nuestro grid para añadir la nueva imagen:

@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    GalleryMedia galleryMedia = cameraHelper.onGetPictureIntentResults(requestCode, resultCode, data);
    if (galleryMedia != null) {
        onGalleryMedia(galleryMedia);
    }
}
public GalleryMedia onGetPictureIntentResults(final int requestCode, final int resultCode, final Intent data) {
    if (requestCode == REQUEST_CODE_CAMERA) {
        if (resultCode == Activity.RESULT_OK) {
            galleryAddPic(mediaUri);
            return new GalleryMedia().setMediaUri(mediaUri.getPath()).setMimeType(mimeType);
        }
    }
    return null;
}

GalleryModule

Llegados a este punto, quiero presentaros la librería que estoy realizando y que sirve de base para este post: GalleryModule. Es muy sencillo utilizarla, solamente debéis importarla en vuestro proyecto y utilizar la siguiente sentencia para lanzar la galería:

public void openGallery(View view) {
    startActivityForResult(GalleryActivity.getCallingIntent(this), REQUEST_CODE_GALLERY);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
   super.onActivityResult(requestCode, resultCode, data);
   if (requestCode == REQUEST_CODE_GALLERY && resultCode == RESULT_OK) {
       GalleryMedia galleryMedia = data.getParcelableExtra(GalleryActivity.RESULT_GALLERY_MEDIA);
       Toast.makeText(this, "Gallery Media " + galleryMedia.getMediaUri(), Toast.LENGTH_LONG).show();
   }
}

Aquí os dejo la dependencia para añadirla en vuestro fichero build.gradle:

compile 'es.guiguegon:gallerymodule:1.0.2'

Tengo en mente varias entregas sobre este mismo repositorio para añadir nuevas funcionalidades como multiselección, grabar videos, personalizar los colores o ver la cámara directamente en la galería. Estad atentos a los próximos post.