La especificación de Material Design incluye un tipo de menú de navegación que en los últimos tiempos está adquiriendo gran popularidad y que encontramos en apps tan conocidas como Spotify o Instagram, además de las aplicaciones propias de Google tales como YouTube o Google Play. Se trata de una barra de navegación situada en la parte inferior de la pantalla que muestra con iconos y, opcionalmente, textos, entre tres y cinco elementos de navegación. Asimismo, en muchas apps esté menú se complementa como un menú lateral.
Google proporcionó una implementación de este componente con el widget BottomNavigationView en el módulo design de la ya obsoleta librería de compatibilidad. En este tutorial vamos a utilizar la última versión de BottomNavigationView que es la disponible en Material Components For Android.
Proyecto de ejemplo
El proyecto de ejemplo, sencillo pero realista, consiste en una única Activity con una Toolbar y tendrá como target Android 11 (Api 30) siendo compatible hasta Android 4.4 (Api 19). En el build.properties del módulo app necesitamos la dependencia de Material Components.
apply plugin: 'com.android.application' android { compileSdkVersion 30 defaultConfig { applicationId "com.danielme.android.bottomnavigation" minSdkVersion 19 targetSdkVersion 30 versionCode 1 versionName "1.0" multiDexEnabled true } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'com.google.android.material:material:1.2.1' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1' }
Los elementos que aparecerán en la barra de navegación se configuran como cualquier menú estándar. Vamos a utilizar los siguientes cuatro.
<menu xmlns:android="http://schemas.android.com/apk/res/android"> <item android:id="@+id/page_home" android:icon="@drawable/baseline_home_black_24" android:title="@string/bottom_nav_home" /> <item android:id="@+id/page_fav" android:icon="@drawable/baseline_favorite_black_24" android:title="@string/bottom_nav_fav" /> <item android:id="@+id/page_search" android:icon="@drawable/baseline_search_black_24" android:title="@string/bottom_nav_search" /> <item android:id="@+id/page_settings" android:icon="@drawable/baseline_app_settings_alt_black_24" android:title="@string/bottom_nav_settings" /> </menu>
Los iconos los he descargado de la página oficial de Material Design.
Ahora vamos con la pantalla. La primera versión simplemente mostrará una Toolbar en la parte superior y el menú anterior en la parte inferior gracias al widget BottomNavigationView.
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".MainActivity"> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" style="@style/AppTheme.ToolbarMain" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/bottom_navigation" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:menu="@menu/bottom_nav_menu" /> </androidx.constraintlayout.widget.ConstraintLayout>
La barra de navegación tiene la configuración mínima necesaria y simplemente se ha indicado el menú. El resultado es el siguiente.
Podemos comprobar que por defecto las entradas del menú sólo muestran el icono y el título queda reservado para el elemento seleccionado. Al seleccionarse un elemento también se aplica el efecto correspondiente. Este comportamiento puede modificarse con la propiedad labelVisibilityMode
- app:labelVisibilityMode=”unlabeled” nunca se mostrará el texto del icono.
- app:labelVisibilityMode=”labeled” se muestra siempre el texto. Es el diseño más habitual.
En lo que respecta a los colores, la barra se muestra con el color definido por la variable ?attr/colorSurface y el elemento seleccionado se destaca utilizando el primary color de la app. Los estilos son muy configurables y las propiedades que podemos personalizar están bien descritas al final de la documentación oficial.
Implementando los eventos
Ahora vamos a proceder a implementar en la Activity que gestiona la pantalla las pulsaciones en las entradas del menú. Cada una mostrará un fragment con una imagen correspondiente al elemento pulsado. Siempre usaremos el mismo fragment y le pasaremos como un parámetro el identificador del icono (drawable a mostrar).
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:id="@+id/imageView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" tools:srcCompat="@drawable/baseline_app_settings_alt_black_48" /> </FrameLayout>
La creación la haremos con el típico “constructor estático” ya que un fragment debe crearse siempre utilizando su constructor vacío, a menos que trabajemos con FragmentFactory. El fragment va a “inflar” el layout y establecer el icono en el ImageView.
package com.danielme.android.bottomnavigation.fragments; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.DrawableRes; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import com.danielme.android.bottomnavigation.R; public class PageFragment extends Fragment { private static final String ARG_ICON = "ARG_ICON"; public static PageFragment newInstance(@DrawableRes int iconId) { PageFragment frg = new PageFragment(); Bundle args = new Bundle(); args.putInt(ARG_ICON, iconId); frg.setArguments(args); return frg; } @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View layout = inflater.inflate(R.layout.fragment_page, container, false); layout.findViewById(R.id.imageView).setBackgroundResource(getArguments().getInt(ARG_ICON)); return layout; } }
Y ahora añadimos un “contenedor” en la activity para insertar el fragment.
<com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" style="@style/AppTheme.ToolbarMain" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <FrameLayout android:id="@+id/container" android:layout_width="match_parent" android:layout_height="0dp" app:layout_constraintBottom_toTopOf="@+id/bottom_navigation" app:layout_constraintTop_toBottomOf="@id/toolbar" /> <com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/bottom_navigation"
Tenemos que asociar un listener de tipo OnNavigationItemSelectedListener a nuestro BottomMenu. La implementación es muy sencilla pues simplemente mostraremos en pantalla el fragment con el icono deseado haciendo la transición con una animación, pero hay “truco”: al cargarse la pantalla hay que mostrar el fragment correspondiente al elemento del menú seleccionado por defecto.
private void setupBottomMenu() { bottomNavigationView = findViewById(R.id.bottom_navigation); bottomNavigationView.setOnNavigationItemSelectedListener(item -> { switch (item.getItemId()) { case R.id.page_home: showFragment(PageFragment.newInstance(R.drawable.baseline_home_black_48)); break; case R.id.page_fav: showFragment(PageFragment.newInstance(R.drawable.baseline_favorite_black_48)); break; case R.id.page_search: showFragment(PageFragment.newInstance(R.drawable.baseline_search_black_48)); break; case R.id.page_settings: showFragment(PageFragment.newInstance(R.drawable.baseline_app_settings_alt_black_48)); break; default: throw new IllegalArgumentException("item not implemented : " + item.getItemId()); } return true; }); //setear aquí para que el listener muestre el fragment inicial al cargarse la pantalla bottomNavigationView.setSelectedItemId(R.id.page_home); } private void showFragment(Fragment frg) { getSupportFragmentManager() .beginTransaction() .setCustomAnimations(R.anim.bottom_nav_enter, R.anim.bottom_nav_exit) .replace(R.id.container, frg) .commit(); }
Otro detalle a tener en cuenta es que cuando rotamos la pantalla deberíamos conservar la selección para evitar que se muestre en pantalla el fragment inicial pero aparezca seleccionado el que había anteriormente.
@Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putInt(SELECTION, bottomNavigationView.getSelectedItemId()); } private void setupBottomMenu(Bundle savedInstanceState) { bottomNavigationView = findViewById(R.id.bottom_navigation); bottomNavigationView.setOnNavigationItemSelectedListener(item -> { ... } }); //setear aquí para que el listener muestre el fragment inicial al cargarse la pantalla if (savedInstanceState == null) { bottomNavigationView.setSelectedItemId(R.id.page_home); } else { bottomNavigationView.setSelectedItemId(savedInstanceState.getInt(SELECTION)); } }
Badges
Las entradas de menú pueden mostrar en su parte superior derecha un pequeño indicador para llamar la atención del usuario con cierta información. Por ejemplo, informar que en la sección de notificaciones tiene mensajes pendientes de leer.
Con el widget BadgeDrawable de las librerías de Material Componentes podemos aplicar fácilmente esta funcionalidad añadiendo a BottomNavigationView los BadgeDrawable que deseemos, asociándolos a la entrada de menú correspondiente.
bottomNavigationView.getOrCreateBadge(R.id.page_fav).setNumber(1000); bottomNavigationView.getOrCreateBadge(R.id.page_settings).setVisible(true);
Ocultar\mostrar con scroll
Aunque no lo he visto en muchas aplicaciones, Material Design recoge la posibilidad de ocultar\mostrar automáticamente la barra de navegación al realizarse scroll en el contenido principal de la pantalla. Este comportamiento lo proporciona la integración de BottomNavigationView con CoordinatorLayout y se configura de forma análoga a otros componentes gráficos como los botones FAB o las estructuras basadas en AppbarLayout (toolbar con pestañas, imágenes de fondo, etc) que también proporcionan de serie integración directa con CoordinatorLayout.
Vamos a ampliar la app de ejemplo con una nueva pantalla que muestre un listado con un RecyclerView. Puesto que tengo publicado un par de tutoriales sobre RecyclerView y su utilización no forma parte de los objetivos didácticos del presente tutorial, no voy a detenerme en los detalles de su implementación.
- Añadir la dependencia
dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'com.google.android.material:material:1.2.1' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation "androidx.recyclerview:recyclerview:1.1.0" coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1' }
- Creamos el layout para el Fragment que contendrá el RecyclerView
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="wrap_content" /> </FrameLayout>
- Cada fila del listado consiste en un TextView
- En el Adapter recibimos una lista de cadenas que mapeamos con el ViewHolder correspondiente al layout anterior
package com.danielme.android.bottomnavigation.fragments; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.recyclerview.widget.RecyclerView; import com.danielme.android.bottomnavigation.R; import java.util.List; class SimpleTextRecyclerViewAdapter extends RecyclerView.Adapter<TextViewHolder> { private final List<String> items; private final LayoutInflater inflater; SimpleTextRecyclerViewAdapter(Context context, List<String> items) { this.inflater = LayoutInflater.from(context); this.items = items; } @Override public TextViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = inflater.inflate(R.layout.row_recycler_view, parent, false); return new TextViewHolder(view); } @Override public int getItemCount() { return items.size(); } @Override public void onBindViewHolder(TextViewHolder holder, int position) { holder.bindText(items.get(position)); } }
package com.danielme.android.bottomnavigation.fragments; import android.view.View; import android.widget.TextView; import android.widget.Toast; import androidx.recyclerview.widget.RecyclerView; import com.danielme.android.bottomnavigation.R; class TextViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { private final TextView textView; TextViewHolder(View itemView) { super(itemView); textView = itemView.findViewById(R.id.textView); itemView.setOnClickListener(this); } void bindText(String text) { textView.setText(text); } @Override public void onClick(View view) { Toast.makeText(view.getContext(), textView.getText(), Toast.LENGTH_SHORT).show(); } }
- Y la implementación del fragment propiamente dicho que crea una lista de cadenas para el adapter y le pone un divider al RecyclerView.
- Para mostrarlo volvemos a lo visto al inicio del tutorial y añadimos un botón a la barra de navegación.
<item android:id="@+id/page_list" android:icon="@drawable/baseline_list_black_24" android:title="@string/bottom_nav_list" />
- Y finalizamos el desarrollo lanzando el fragment dentro del listener del NavigationBottomView.
bottomNavigationView.setOnNavigationItemSelectedListener(item -> { switch (item.getItemId()) { case R.id.page_home: showFragment(PageFragment.newInstance(R.drawable.baseline_home_black_48)); break; case R.id.page_list: showFragment(new RecyclerViewFragment()); break; ...
<FrameLayout 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" android:background="?android:attr/selectableItemBackground" android:gravity="center_vertical" android:minHeight="@dimen/list_height"> <TextView android:id="@+id/textView" style="@android:style/TextAppearance.Medium" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:paddingStart="@dimen/standard_margin" android:paddingEnd="@dimen/standard_margin" tools:text="item" /> </FrameLayout>
package com.danielme.android.bottomnavigation.fragments; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.danielme.android.bottomnavigation.R; import java.util.ArrayList; import java.util.List; public class RecyclerViewFragment extends Fragment { @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View layout = inflater.inflate(R.layout.fragment_recycler_view, container, false); RecyclerView recyclerView = layout.findViewById(R.id.recycler_view); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); List<String> items = new ArrayList<>(); for (int i = 0; i < 25; i++) { items.add("item " + i); } recyclerView.setAdapter(new SimpleTextRecyclerViewAdapter(getContext(), items)); DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(recyclerView.getContext(), DividerItemDecoration.VERTICAL); recyclerView.addItemDecoration(dividerItemDecoration); return layout; } }
Ya podemos ver el listado pero la barra de navegación siempre está visible. Para ocultarla automáticamente, recurrimos como dije anteriormente a la “magia” de CoordinatorLayout y tenemos que refactorizar el layout de la Activity de tal modo que nuestro BottomNavigationView sea hijo directo del mismo. La ocultación se aplicará a todos los fragments cuyo contenido sea scrollable (recyclerview, scrollview).
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/coordinatort" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" style="@style/AppTheme.ToolbarMain" /> <FrameLayout android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent"/> </LinearLayout> <com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/bottom_navigation" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" app:labelVisibilityMode="labeled" app:layout_behavior="@string/hide_bottom_view_on_scroll_behavior" app:menu="@menu/bottom_nav_menu" /> </androidx.coordinatorlayout.widget.CoordinatorLayout>
También se puede ocultar del mismo modo la Toolbar al hacer scroll utilizando un AppBarLayout como “padre” de la Toolbar.
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/coordinatort" android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.appbar.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" style="@style/AppTheme.ToolbarMain" app:layout_scrollFlags="scroll|enterAlways" /> </com.google.android.material.appbar.AppBarLayout> <FrameLayout android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> <com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/bottom_navigation" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" app:labelVisibilityMode="labeled" app:layout_behavior="@string/hide_bottom_view_on_scroll_behavior" app:menu="@menu/bottom_nav_menu" /> </androidx.coordinatorlayout.widget.CoordinatorLayout>
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.