Master-Detail. Vista Detalle.


Objetivo

Tenemos por el momento una aplicación que muestra una lista de películas, queremos acceder a la información detallada de una película.

¿Cómo navegamos la aplicación para ver el detalle? Una opción es
  • en la lista, incorporar la acción mediante un botón o link
  • al hacer click sobre un elemento, visualizamos el elemento
Elegimos la segunda opción, porque la primera fuerza a repetir las acciones para cada línea y eso nos quita espacio para mostrar más info de una película. 

User Experience

Estas decisiones forman parte de la “experiencia de usuario” o UX por sus siglas en inglés, User eXperience. La parte visual juega un papel muy importante en el desarrollo de este tipo de aplicaciones, donde podemos 
  • respetar el comportamiento que tienen las otras aplicaciones Android o el sistema operativo sobre el que estemos desarrollando, con la ventaja de que estamos construyendo aplicaciones nativas y no híbridas
  • salirnos del esquema y trabajar de una única manera en la aplicación independientemente del dispositivo / tecnología en el que corra. Esta estrategia es válida si nuestra intención es que los usuarios puedan cambiar de aparato, sistema operativo, etc. sin notar cambios en la manipulación de la aplicación, pero sugiere un período de adaptación del usuario a nuestra aplicación, por lo que hay que invertir tiempo en que sea lo suficientemente intuitiva y permita la menor cantidad de desplazamientos, algo que en las aplicaciones “tradicionales” de escritorio o web no era una variable de tanto peso.
Dejamos algunas lecturas recomendadas:

Pasaje de información entre actividades

Cuando creamos un proyecto de tipo Master/Detail, el IDE nos generó varias líneas que se encargan de resolver este tema. Ahora vamos a estudiarlo para saber cómo funciona y ver si es necesario hacer algún ajuste. Primero que nada tenemos que ver cómo le llega la información desde la actividad Lista hacia la Detalle:


@Override
public void onListItemClick(ListView listView, View view, int position, long id) {
     super.onListItemClick(listView, view, position, id);

     // Notify the active callbacks interface (the activity, if the
     // fragment is attached to one) that an item has been selected.
     mCallbacks.onItemSelected(DummyContent.ITEMS.get(position).id);
}

LibroListFragment.java


Aquí se dispara el evento a los observers o callbacks que están interesados en escuchar cuando el usuario selecciona una película. 
¿Quién es el interesado? La Activity que muestra la lista:

public class PeliculaListActivity extends AppCompatActivity
        implements PeliculaListFragment.Callbacks {
PeliculaListActivity.java

Pero antes de seguir debemos cambiar esta línea:
     mCallbacks.onItemSelected(DummyContent.ITEMS.get(position).id);
porque viene del código boilerplate que genera Android Studio para un proyecto master/detail flow. Recibimos la posicion del elemento seleccionado, y un id entre otras cosas, ¿qué tenemos que pasar?
  • la posición del elemento
  • el identificador de la película
  • un objeto película
¿Qué es lo que resultaría más cómodo? Uno podría valorar a priori tener un objeto película, pero hay que tener en cuenta que la lista de películas puede hacerse mediante un servicio REST, que quizás no nos entregue toda la información de la película, sino que use una representación en json reducida, para bajar la cantidad de datos a transmitir. Aún así, podríamos utilizar a la película como abstracción entre la vista master y la vista detalle, teniendo en cuenta que será necesario una nueva búsqueda para traer la información completa de una película.

Navegación

Para poder obtener el identificador de la película, tenemos que modificar la implementación default que hereda PeliculaAdapter de ArrayAdapter, donde:
@Override
public long getItemId(int position) {
   return position;
}

Ok, entonces lo modificamos para obtener el identificador real de nuestro objeto película. Como ArrayAdapter encapsula la colección de elementos que muestra la ListView, esto nos obliga a utilizar el método getItem para luego pedirle el id:
@Override
public long getItemId(int position) {
   return getItem(position).getId();
}

Ahora sí el método onListItemClick recibe como parámetro el identificador posta de la película y lo puede utilizar:

    @Override
    public void onListItemClick(ListView listView, View view, int position, long id) {
        super.onListItemClick(listView, view, position, id);

        // Notify the active callbacks interface (the activity, if the
        // fragment is attached to one) that an item has been selected.
        mCallbacks.onItemSelected("" + id);
    }
PeliculaListFragment.java

El parámetro id, no obstante es un String, lo que nos obliga a hacer un casteo: String.valueOf(id) o bien "" + id, que tiene el mismo efecto.

La actividad debe implementar el método onItemSelected. Si miramos el método, vemos que hay un if:

   @Override
    public void onItemSelected(String id) {
        if (mTwoPane) {
            // In two-pane mode, show the detail view in this activity by
            // adding or replacing the detail fragment using a
            // fragment transaction.
            Bundle arguments = new Bundle();
            arguments.putString(PeliculaDetailFragment.ARG_ITEM_ID, id);
            PeliculaDetailFragment fragment = new PeliculaDetailFragment();
            fragment.setArguments(arguments);
            getSupportFragmentManager().beginTransaction()
                    .replace(R.id.pelicula_detail_container, fragment)
                    .commit();

        } else {
            // In single-pane mode, simply start the detail activity
            // for the selected item ID.
            Intent detailIntent = new Intent(this, PeliculaDetailActivity.class);
            detailIntent.putExtra(PeliculaDetailFragment.ARG_ITEM_ID, id);
            startActivity(detailIntent);
        }
    }
PeliculaListActivity.java

Esta división se da porque
  • si estamos testeando la aplicación con un dispositivo cuyo tamaño nos permite unificar en una sola actividad el fragmento lista y el detalle, estamos en modo two-pane. Más adelante estudiaremos su comportamiento.
  • los dispositivos como el teléfono, que tienen una pantalla de tamaño chico, trabajan en modo single-pane, entonces hay que navegar hacia la vista de detalle. 
Nos concentraremos por el momento en la solución single-pane, que crea la navegación hacia la vista detalle mediante el concepto Intent, una abstracción que representa cualquier tipo de operación. El intent define un método putExtra donde pasamos parámetros de una actividad a otra, en este caso el id de la película seleccionada.

En la actividad de detalle recibimos el id y se lo pasamos al fragment:

   @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        if (savedInstanceState == null) {
            // Create the detail fragment and add it to the activity
            // using a fragment transaction.
            Bundle arguments = new Bundle();
            arguments.putString(PeliculaDetailFragment.ARG_ITEM_ID,
                    getIntent().getStringExtra(PeliculaDetailFragment.ARG_ITEM_ID));
            PeliculaDetailFragment fragment = new PeliculaDetailFragment();
            fragment.setArguments(arguments);
            getSupportFragmentManager().beginTransaction()
                    .add(R.id.pelicula_detail_container, fragment)
                    .commit();
        }
    }
PeliculaDetailActivity.java

En el fragment lo transformamos en un objeto película para mostrar la información de dicha película. Reemplazamos 
    private DummyContent.DummyItem mItem;
por
    private Pelicula pelicula;
PeliculaDetailFragment.java

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        if (getArguments().containsKey(ARG_ITEM_ID)) {
            String idPelicula = getArguments().getString(ARG_ITEM_ID);
            pelicula = RepoPeliculas.getInstance().getPelicula(new Long(idPelicula).longValue());

            Activity activity = this.getActivity();
            CollapsingToolbarLayout appBarLayout = (CollapsingToolbarLayout) activity.findViewById(R.id.toolbar_layout);
            if (appBarLayout != null) {
                appBarLayout.setTitle(pelicula.getTitulo());
            }
        }
    }
PeliculaDetailFragment.java

Lo que nos falta es
  • modificar el layout del detalle de una película
  • actualizar el fragment

Layout detalle de película

Proponemos un nuevo formato:
  • El título de la película ya aparece como título de la aplicación
  • Una imagen relacionada con el género
  • La descripción del género al costado 
  • Los actores
  • La sinopsis
Tenemos un ImageView y el TextView que muestra el género en un layout horizontal y el resto se completa con TextView con layout vertical.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <ImageView xmlns:android="http://schemas.android.com/apk/res/android"
            android:id="@+id/imgGenero"
            android:layout_width="@dimen/icono"
            android:layout_height="@dimen/icono"
            />

        <TextView xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:tools="http://schemas.android.com/tools"
            android:id="@+id/pelicula_genero"
            style="?android:attr/textAppearanceLarge"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fontFamily="typeface:BOLD"
            android:padding="16dp"
            android:textIsSelectable="true"
            tools:context=".PeliculaDetailFragment" />

    </LinearLayout>

    <TextView xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:id="@+id/pelicula_actores"
        style="?android:attr/textAppearanceMedium"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="16dp"
        android:textIsSelectable="true"
        tools:context=".PeliculaDetailFragment" />

    <TextView xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:id="@+id/pelicula_sinopsis"
        style="?android:attr/textAppearanceMedium"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="16dp"
        android:textIsSelectable="true"
        tools:context=".PeliculaDetailFragment" />

</LinearLayout>
fragment_pelicula_detail.xml

El tamaño del ícono lo externalizamos en un archivo dentro del directorio values/dimens:
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="fab_margin">16dp</dimen>
    <dimen name="app_bar_height">200dp</dimen>
    <dimen name="icono">80dp</dimen>
</resources>
dimens.xml

Entonces sólo falta armar el enlace entre cada propiedad de película y cada control de Android. Esto se hace por supuesto, a mano:
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View rootView = inflater.inflate(R.layout.fragment_pelicula_detail, container, false);

        // Show the dummy content as text in a TextView.
        if (pelicula != null) {
            ((TextView) rootView.findViewById(R.id.pelicula_genero)).setText(pelicula.getDescripcionGenero());
            ImageView imgGenero = ((ImageView) rootView.findViewById(R.id.imgGenero));
            imgGenero.setImageDrawable(getResources().getDrawable(new GeneroAdapter().getIconoGenero(pelicula)));
            ((TextView) rootView.findViewById(R.id.pelicula_actores)).setText(pelicula.getActores());
            ((TextView) rootView.findViewById(R.id.pelicula_sinopsis)).setText(pelicula.getSinopsis());
        }

        return rootView;
    }
PeliculaDetailFragment.java

Para asociar cada género con su correspondiente ícono, utilizamos un Adapter específico, que relaciona una serie de gráficos png que descargamos dentro de la carpeta drawable de nuestro proyecto Android:

public class GeneroAdapter {

    static Map<String, Integer> mapaGeneros;

    private Map<String, Integer> getMapaGeneros() {
        if (mapaGeneros == null) {
            mapaGeneros = new HashMap<String, Integer>();
            mapaGeneros.put("Infantil", R.drawable.infantil);
            mapaGeneros.put("Infantil/Anim", R.drawable.infantil);
            mapaGeneros.put("Accion", R.drawable.accion);
            mapaGeneros.put("Series", R.drawable.default2);
            mapaGeneros.put("Drama", R.drawable.drama);
            mapaGeneros.put("Comedia", R.drawable.comedia);
            mapaGeneros.put("Clasicos", R.drawable.comedia2);
            mapaGeneros.put("Infantil / Peli", R.drawable.infantil);
            mapaGeneros.put("C.Ficcion", R.drawable.sci_fi);
            mapaGeneros.put("Musical", R.drawable.drama);
            mapaGeneros.put("C.Romantica", R.drawable.romantica);
            mapaGeneros.put("Suspenso", R.drawable.suspenso);
            mapaGeneros.put("Terror", R.drawable.horror);
            mapaGeneros.put("Infantil/Peli", R.drawable.infantil);
            mapaGeneros.put("Aventuras", R.drawable.fantasia);
            mapaGeneros.put("Nacional", R.drawable.default3);
            mapaGeneros.put("Familia", R.drawable.comedia2);
            mapaGeneros.put("Belica", R.drawable.horror);
            mapaGeneros.put("Documental", R.drawable.default2);
            mapaGeneros.put("Infantil-Peli", R.drawable.infantil);
            mapaGeneros.put("Infantil-Anim", R.drawable.infantil);
            mapaGeneros.put("Teatral", R.drawable.default3);
        }
        return mapaGeneros;
    }

    public int getIconoGenero(Pelicula pelicula) {
        int result = getMapaGeneros().get(pelicula.getDescripcionGenero());
        if (result == 0) {
            return R.drawable.default3;
        }
        return result;
    }

}
GeneroAdapter.java

Vemos la solución general:

Otra variante: pasando la película

El lector avezado notará que esta forma de trabajo tiene algunas desventajas:
  • hacemos la consulta generando una lista de películas
  • para obtener el identificador de la película, debemos acceder al elemento que está en la posición n de la lista
  • luego convertirlo a String
  • entonces la actividad crea el Intent y almacena en un mapa de argumentos ese String
  • cuando iniciamos la actividad de detalle, el fragment de detalle de una película tiene que tomar el id de película, que es un String
  • y reconvertirlo a Long
  • para volver a hacer la búsqueda al repositorio de películas y recuperar al objeto Película con su género
En realidad hay una conversión que no hace falta, que viene dada por la interfaz definida para Callbacks:

    public interface Callbacks {
        /**
         * Callback for when an item has been selected.
         */
        void onItemSelected(String id);
    }
PeliculaListFragment.Callbacks

Podríamos cambiar la interfaz para que reciba un Long como parámetro. Pero vamos a buscar una variante que trabaje más con el paradigma orientado a objetos:

    public interface Callbacks {
        void onItemSelected(Pelicula pelicula);
    }
PeliculaListFragment.Callbacks

Por supuesto, debemos ajustar algunas cosas: la definición de la interfaz Callbacks que no hace nada cuando no tiene una actividad asociada:

private static Callbacks sDummyCallbacks = new Callbacks() {
        @Override
        public void onItemSelected(Pelicula pelicula) {
        }
};
PeliculaListFragment.java

En el Fragment, además, debemos pasar a los observers una película. Al estar tan desacoplado el adapter de Pelicula y el Fragment no zafamos de volver a hacer el getPelicula del repo:

@Override
    public void onListItemClick(ListView listView, View view, int position, long id) {
        super.onListItemClick(listView, view, position, id);
        mCallbacks.onItemSelected(RepoPeliculas.getInstance().getPelicula(id));
    }
PeliculaListFragment.java

Ahora veamos cómo se implementa el onItemSelected de la Activity de la lista de películas:

   @Override
    public void onItemSelected(Pelicula pelicula) {
        if (mTwoPane) {
           Bundle arguments = new Bundle();
           arguments.putSerializable(PeliculaDetailFragment.ARG_ITEM_ID, pelicula);
            ...
        } else {
            // In single-pane mode, simply start the detail activity
            // for the selected item ID.
            Intent detailIntent = new Intent(this, PeliculaDetailActivity.class);
            detailIntent.putExtra(PeliculaDetailFragment.ARG_ITEM_ID, pelicula);
            startActivity(detailIntent);
        }
    }
PeliculaListActivity.java

El mensaje putExtra está sobrecargado para aceptar tanto strings, como booleans, como enteros y en general, cualquier “serializable”. Hacemos entonces que Pelicula implemente dicha interfaz:

public class Pelicula implements Serializable {
Pelicula.java

Como la película contiene un género, debemos hacer que implemente Serializable:
public class Genero implements Serializable {
Genero.java

En la actividad de detalle, debemos modificar los tipos de los parámetros utilizados:
public class PeliculaDetailActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ...
        if (savedInstanceState == null) {
            Bundle arguments = new Bundle();
            arguments.putSerializable(PeliculaDetailFragment.ARG_ITEM_ID,
                    getIntent().getSerializableExtra(PeliculaDetailFragment.ARG_ITEM_ID));
            PeliculaDetailFragment fragment = new PeliculaDetailFragment();
            fragment.setArguments(arguments);
            getSupportFragmentManager().beginTransaction()
                    .add(R.id.pelicula_detail_container, fragment)
                    .commit();
        }
LibroDetailActivity.java

Sólo nos falta recibir ese parámetro en la actividad de detalle que permite visualizar el libro:

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        if (getArguments().containsKey(ARG_ITEM_ID)) {
            pelicula = (Pelicula) getArguments().get(ARG_ITEM_ID);
LibroDetailFragment.java


Y el método onCreateView del detalle queda exactamente igual.


Puntos extra para hacer

  • Agregar un EditText para que el usuario ingrese un valor a filtrar en la búsqueda, hacerlo
    • primero, con un botón de búsqueda
    • luego, a medida que el usuario va escribiendo
    • y por último que se pueda configurar mediante un checkbox
  • Modificar el layout de manera que quede la imagen y la descripción del género a izquierda y a derecha los actores y la sinopsis
Comments