Diseño Android: menú inferior emergente con Bottom Sheet y Material Design

android

Seguimos en el blog echando un vistazo a componentes de Material Design de los que disponemos de implementaciones proporcionadas por Google. En este tutorial veremos el componente Bottom Sheet, un panel que se desliza hacia arriba desde la parte inferior de la pantalla. Su uso más habitual es mostrar información contextual o funcionar a modo de menú de selección. Este segundo caso es el que vamos a implementar con dos diseños distintos (modo lista y modo grid) y encapsulado en un fragment. El primero de ellos es una copia del utilizado en la versión 2.0 de Muspy for Android. La siguiente imagen muestra lo que vamos a implementar en este tutorial.

Proyecto de ejemplo (dependencias)

El proyecto de ejemplo de este tutorial constará de una única Activity con una Toolbar y tiene las siguientes dependencias.

allprojects {
    repositories {
        jcenter()
        maven {
            url "https://maven.google.com"
        }
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:26.0.0'
    compile 'com.android.support:design:26.0.0'
    compile 'com.android.support:percent:26.0.0'

    compile 'com.jakewharton:butterknife:8.5.1'
    annotationProcessor 'com.jakewharton:butterknife-compiler:8.5.1'
}

Las necesarias para utilizar el componente Bottom Sheet son las dos primeras (los módulos appcompat y design). La librería percent la veremos más adelante, y Butterknife se utiliza para simplificar el código

Diseñando el menú

El menú es un layout cualquiera, esto es, no tiene ninguna particularidad por el hecho de que vaya ser mostrado en la Bottom Sheet. Para el modo lista se usará simplemente un Linear Layout que apilará las entradas del menú (y no nos podemos olvidar de incluirlo en un ScrollView). Cada elemento del menú es un TextView que incluye dentro del mismo el icono correspondiente. Se va a definir íntegramente en XML, en un menú más grande o dinámico tendríamos que hacerlo programáticamente.

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

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/white"
        android:orientation="vertical">

        <TextView
            android:id="@+id/youtube"
            style="@style/AppTheme.BottomSheetListItem"
            android:drawableLeft="@drawable/youtube"
            android:text="@string/youtube" />

        <TextView
            android:id="@+id/lastfm"
            style="@style/AppTheme.BottomSheetListItem"
            android:drawableLeft="@drawable/lastfm"
            android:text="@string/title_lastfm" />

        <TextView
            android:id="@+id/google"
            style="@style/AppTheme.BottomSheetListItem"
            android:drawableLeft="@drawable/google"
            android:text="@string/google" />

        <TextView
            android:id="@+id/musicbrainz"
            style="@style/AppTheme.BottomSheetListItem"
            android:drawableLeft="@drawable/musicbrainz"
            android:text="@string/musicbrainz" />

        <TextView
            android:id="@+id/amazon"
            style="@style/AppTheme.BottomSheetListItem"
            android:drawableLeft="@drawable/amazon"
            android:text="@string/amazon" />

        <TextView
            android:id="@+id/play"
            style="@style/AppTheme.BottomSheetListItem"
            android:drawableLeft="@drawable/play"
            android:text="@string/google_play" />

    </LinearLayout>
    
</ScrollView>

Este es el estilo aplicado a cada TextView.

   <style name="AppTheme.BottomSheetListItem">
        <item name="android:clickable">true</item>
        <item name="android:background">@drawable/bottom_selector</item>
        <item name="android:paddingLeft">@dimen/activity_horizontal_margin</item>
        <item name="android:paddingRight">@dimen/activity_horizontal_margin</item>
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">@dimen/list_height</item>
        <item name="android:layout_gravity">center_vertical</item>
        <item name="android:drawablePadding">@dimen/activity_horizontal_margin_double</item>
        <item name="android:gravity">center_vertical</item>
        <item name="android:textSize">@dimen/bottom_font</item>
    </style>

Lo más destacable es el background, consistente en un drawable para indicar la pulsación del elemento. De forma genérica usaremos el siguiente.

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true">
        <shape android:shape="rectangle">
            <solid android:color="@color/bottomsheet_highlight" />
        </shape>
    </item>
    <!--default background color -->
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@android:color/white" />
        </shape>
    </item>
</selector>

Pero a partir de Lollipop se usará un efecto Ripple.

<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="@color/bottomsheet_highlight">

    <item android:drawable="@android:color/white" />

</ripple>

Para el menú en modo grid, también tenemos seis TextView equivalentes a los anteriores y que se van a repartir en dos filas de tres columnas de igual tamaño. El diseño puede hacerse de varias formas, en esta ocasión me he decantado por utilizar un PercentRelativeLayout y es tal como sigue.

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <android.support.percent.PercentRelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/white"
        android:paddingBottom="@dimen/grid_separation"
        android:paddingTop="@dimen/grid_separation">

        <TextView
            android:id="@+id/youtube"
            style="@style/AppTheme.BottomSheetGridItem"
            android:layout_marginBottom="@dimen/grid_separation"
            android:drawableTop="@drawable/youtube_big"
            android:text="@string/youtube" />

        <TextView
            android:id="@+id/lastfm"
            style="@style/AppTheme.BottomSheetGridItem"
            android:layout_marginBottom="@dimen/grid_separation"
            android:layout_toRightOf="@+id/youtube"
            android:drawableTop="@drawable/lastfm_big"
            android:text="@string/title_lastfm" />

        <TextView
            android:id="@+id/google"
            style="@style/AppTheme.BottomSheetGridItem"
            android:layout_marginBottom="@dimen/grid_separation"
            android:layout_toRightOf="@+id/lastfm"
            android:drawableTop="@drawable/google_big"
            android:text="@string/google" />

        <TextView
            android:id="@+id/musicbrainz"
            style="@style/AppTheme.BottomSheetGridItem"
            android:layout_below="@+id/youtube"
            android:drawableTop="@drawable/musicbrainz_big"
            android:text="@string/musicbrainz" />

        <TextView
            android:id="@+id/amazon"
            style="@style/AppTheme.BottomSheetGridItem"
            android:layout_below="@+id/lastfm"
            android:layout_toRightOf="@id/musicbrainz"
            android:drawableTop="@drawable/amazon_big"
            android:text="@string/amazon" />

        <TextView
            android:id="@+id/play"
            style="@style/AppTheme.BottomSheetGridItem"
            android:layout_below="@id/google"
            android:layout_toRightOf="@id/amazon"
            android:drawableTop="@drawable/play_big"
            android:text="@string/google_play" />

    </android.support.percent.PercentRelativeLayout>

</ScrollView>

Nota: para poder utilizar este layout es necesaria la dependencia com.android.support:percent.

Y este es su estilo.

 
   <style name="AppTheme.BottomSheetGridItem">
        <item name="android:clickable">true</item>
        <item name="layout_widthPercent">@fraction/third</item>
        <item name="android:background">@drawable/bottom_selector</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:drawablePadding">@dimen/padding_text_image_top</item>
        <item name="android:gravity">center_horizontal</item>
        <item name="android:textSize">@dimen/bottom_font</item>
    </style>
Crear y mostrar el fragment

La Bottom Sheet con el menú será gestionada por un fragment que herede de BottomSheetDialogFragment, que a su vez es un DialogFragment que muestra en su interior un BottomSheetDialog. El mismo fragment servirá para las dos versiones del menú y sólo cambiará el layout. Para cada «modo» de funcionamiento se proporciona un método estático que instancie el fragment, siguiendo el típico patrón newInstance, y que oculte al exterior cualquier detalle de implementación innecesario. El «inflado» y visualización del layout con el menú es muy sencillo y se hace en el método setupDialog. Aquí también es donde se inicia Butterknife para poder acceder a los Views del menú y que en esta ocasión sólo utilizaremos para asigna rle evento de pulsación a cada TextView que forma parte del menú.

package com.danielme.android.bottomsheetmenu;


import android.app.Dialog;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.design.widget.BottomSheetBehavior;
import android.support.design.widget.BottomSheetDialogFragment;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

import butterknife.ButterKnife;
import butterknife.OnClick;

public class BottomSheetMenuFragment extends BottomSheetDialogFragment {

    private static final String ARG_LAYOUT_ID = "ARG_LAYOUT_ID";
    private static final int LIST = R.layout.fragment_list_bottom_sheet;
    private static final int GRID = R.layout.fragment_grid_bottom_sheet;

    public static BottomSheetMenuFragment createInstanceList() {
        return getInstance(LIST);
    }

    public static BottomSheetMenuFragment createInstanceGrid() {
        return getInstance(GRID);
    }

    private static BottomSheetMenuFragment getInstance(int layoutId) {
        Bundle args = new Bundle();
        args.putInt(ARG_LAYOUT_ID, layoutId);
        BottomSheetMenuFragment frag = new BottomSheetMenuFragment();
        frag.setArguments(args);
        return frag;
    }

    @Override
    public void setupDialog(Dialog dialog, int style) {
        //noinspection RestrictedApi
        super.setupDialog(dialog, style);

        View contentView = View.inflate(getContext(), getArguments().getInt(ARG_LAYOUT_ID), null);
        ButterKnife.bind(this, contentView);
        dialog.setContentView(contentView);
    }

    @OnClick({R.id.youtube, R.id.lastfm, R.id.google, R.id.musicbrainz, R.id.amazon, R.id.play})
    public void onClickBottomSheet(View view) {
        Toast.makeText(getContext(), ((TextView) view).getText(), Toast.LENGTH_SHORT).show();
        dismiss();
    }

}

El fragment se mostrará desde MainActivity haciendo uso de dos botones.

package com.danielme.android.bottomsheetmenu;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.view.View;

import butterknife.ButterKnife;
import butterknife.OnClick;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ButterKnife.bind(this);
        setSupportActionBar((Toolbar) findViewById(R.id.toolbar));
    }

    @OnClick(R.id.buttonList)
    public void showBottomSheetList(View view) {
        BottomSheetMenuFragment frg = BottomSheetMenuFragment.createInstanceList();
        frg.show(getSupportFragmentManager(), BottomSheetMenuFragment.class.getSimpleName());
    }

    @OnClick(R.id.buttonGrid)
    public void showBottomSheetGrid(View view) {
        BottomSheetMenuFragment frg = BottomSheetMenuFragment.createInstanceGrid();
        frg.show(getSupportFragmentManager(), BottomSheetMenuFragment.class.getSimpleName());
    }

}

El siguiente video muestra el resultado final del proyecto de ejemplo en Android Oreo.

Algunas opciones

La altura hasta la que se extiende la Bottom Sheet al desplegarse se calcula automáticamente para ocupar sólo cierta parte de la pantalla, dejando libre la parte superior de la pantalla en un ratio al menos de 16:9, y mostrándose el resto de su contenido al deslizarla el usuario. Y si ocupa toda la pantalla y aún así quedan elementos ocultos, podemos hacer scroll dentro de la Bottom Sheet si nuestro layout es «scrollable».

Esta altura, denominada peek (cima) puede especificarse en píxeles con el método setPeekHeight del siguiente modo.

    @Override
    public void setupDialog(Dialog dialog, int style) {
        //noinspection RestrictedApi
        super.setupDialog(dialog, style);

        View contentView = View.inflate(getContext(), getArguments().getInt(ARG_LAYOUT_ID), null);
        ButterKnife.bind(this, contentView);
        dialog.setContentView(contentView);

        BottomSheetBehavior<View> mBottomSheetBehavior = BottomSheetBehavior.from(((View) contentView
                .getParent()));
        if (mBottomSheetBehavior != null) {
            mBottomSheetBehavior.setPeekHeight(1200);
        }
    }

Si hiciera falta obtener el tamaño en píxeles de la pantalla, podemos utilizar el siguiente código.

DisplayMetrics dm = new DisplayMetrics();
activity.getWindowManager().getDefaultDisplay().getMetrics(dm);
int pixels = dm.heightPixels;

Con el cambio anterior, en nuestro ejemplo el menú se mostrará completamente con el dispositivo en modo landscape. La siguiente imagen muestra el antes y el después.

Tal y como cabría esperar, la Bottom Sheet permite monitorizar sus eventos asociados aunque en este componente en lugar de implementar una interfaz tenemos que heredar y completar la clase abstracta BottomSheetBehavior.BottomSheetCallback. Disponemos de dos métodos:

  • onStateChanged: se invoca cada vez que la Bottom Sheet cambia de estado. Hay cinco estados definidos como constantes numéricas en BottomSheetBehavior.
    • STATE_DRAGGING: el usuario ha empezado a deslizar la Bottom Sheet sin importar el sentido (arriba o abajo).
    • STATE_SETTLING: el deslizamiento ha terminado y la Bottom Sheet se va a «acomodar» a su nueva posición.
    • STATE_COLLAPSED: la Bottom Sheet se muestra en la posición Peek Height.
    • STATE_EXPANDED: la Bottom Sheet se muestra con todo su height si este es superior al peek.
    • STATE_HIDDEN: tras haber sido mostrada, la Bottom Sheet ha sido ocultada.
  • onSlide: sigue el deslizamiento de la Bottom Sheet realizado por el usuario indicando su posición mediante un número entre -1 y 1, siendo el valor 0 la posición correspondiente al peek (STATE_COLLAPSED), -1 la posición HIDDEN y 1 la posición EXPANDED. Este comportamiento es debido a que, como hemos visto, si la Bottom Sheet no se muestra completamente en la posición peek puede ser deslizada hasta arriba para mostrarse entera (STATE_EXPANDED).

Ambos métodos no se ejecutan ni al mostrarse en pantalla el fragment ni al ocultarse pulsando back o bien fuera de la Bottom Sheet. El callback sólo captura la interacción directa del usuario con la Bottom Sheet.

Comprobemos el funcionamiento de la Bottom Sheet añadiendo un BottomSheetCallback al ejemplo.

    private final BottomSheetBehavior.BottomSheetCallback mBottomSheetBehaviorCallback = new
            BottomSheetBehavior.BottomSheetCallback() {

                @Override
                public void onStateChanged(@NonNull View bottomSheet, int newState) {
                    String state = null;

                    switch (newState) {
                        case BottomSheetBehavior.STATE_COLLAPSED:
                            state = "STATE_COLLAPSED";
                            break;
                        case BottomSheetBehavior.STATE_DRAGGING:
                            state = "STATE_DRAGGING";
                            break;
                        case BottomSheetBehavior.STATE_EXPANDED:
                            state = "STATE_EXPANDED";
                            break;
                        case BottomSheetBehavior.STATE_SETTLING:
                            state = "STATE_SETTLING";
                            break;
                        case BottomSheetBehavior.STATE_HIDDEN:
                            state = "STATE_HIDDEN";
                            //call ALWAYS dismiss to hide the modal background
                            dismiss();
                            break;
                    }

                    Log.d(BottomSheetMenuFragment.class.getSimpleName(), state);
                }

                @Override
                public void onSlide(@NonNull View bottomSheet, float slideOffset) {
                    Log.d(BottomSheetMenuFragment.class.getSimpleName(), String.valueOf(slideOffset));
                }
            };

    @Override
    public void setupDialog(Dialog dialog, int style) {
        //noinspection RestrictedApi
        super.setupDialog(dialog, style);

        View contentView = View.inflate(getContext(), getArguments().getInt(ARG_LAYOUT_ID), null);
        ButterKnife.bind(this, contentView);
        dialog.setContentView(contentView);

        BottomSheetBehavior<View> mBottomSheetBehavior = BottomSheetBehavior.from(((View) contentView
                .getParent()));
        if (mBottomSheetBehavior != null) {
            mBottomSheetBehavior.setBottomSheetCallback(mBottomSheetBehaviorCallback);
            mBottomSheetBehavior.setPeekHeight(1200);
        }
    }

El callback simplemente imprime en el log los valores de entrada de los métodos.

Prestemos especial atención al siguiente bloque de código

case BottomSheetBehavior.STATE_HIDDEN:
  state = "STATE_HIDDEN";
  //call ALWAYS dismiss to hide the modal background
  dismiss();
break;

Si implementamos el callback, es imprescindible detectar el estado HIDDEN y llamar al dismiss() del DialogFragment ya que de lo contrario al ocultarse la Bottom Sheet se seguirá mostrando el background propio de los dialogs que oculta de forma semitransparente toda la pantalla.

Código de ejemplo

El proyecto de ejemplo para Android Studio se encuentra disponible en GitHub. Para más información sobre cómo utilizar GitHub, consultar este artículo.

Deja un comentario

Este sitio utiliza Akismet para reducir el spam. Conoce cómo se procesan los datos de tus comentarios.