Diseño Android: Tarjetas con Material Components y contenido expandible

Última actualización: 17/12/2020

android

Las tarjetas son un componente gráfico popularizado en Android por Google Now hace ya bastantes años y que forman parte de las guías de estilos y componentes gráficos Material Design creadas por Google. El concepto de tarjeta es el de una ficha de papel o portada de una carpeta que proporciona sólo el resumen de cierto contenido. El resumen puede estar compuesto por un conjunto de elementos heterogéneos que incluye imágenes, títulos, textos, íconos, menús contextuales, acciones…

Contamos con una implementación oficial para Android del componente tarjeta en el widget CardView empaquetado en un módulo propio de AndroidX (la evolución de la ya obsoleta biblioteca de compatibilidad). Asimismo, en la novedosa librería Material Components se incluye una especialización de este widget llamada MaterialCardView que añade algunas mejoras para soportar las últimas versiones de Material Design y que a día es el widget más recomendable para utilizar tarjetas en nuestras aplicaciones.

En este tutorial implementaremos una tarjeta más o menos típica utilizando Material Components, tanto la propia card como la toolbar (MaterialToolbar). Lo haremos en un proyecto de ejemplo compatible con Android Studio 4.1 y que se compilará para Android Api 30, siendo compatible hasta Android 4.4 KitKat.

Nota Las primeras versiones de este pequeño artículo utilizaban el componente CardView. Se puede consultar el proyecto de ejemplo correspondiente en esta rama.

En primer lugar, debemos incluir en nuestro proyecto las librerías que proporcionan los widgets de Material Components. Sólo hay que añadir una dependencia.

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.google.android.material:material:1.2.1'
}

MaterialCardView es una subclase de FrameLayout por lo que podemos considerar este widget como un Layout más cuyos hijos se pueden superponer. Vamos a crear una tarjeta que simplemente muestre una imagen del olinguito. El layout contiene además una Toolbar a modo de ActionBar y la tarjeta ha sido incluida en un ScrollView.

<LinearLayout 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"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <com.google.android.material.appbar.MaterialToolbar
        android:id="@+id/toolbar"
        style="@style/AppTheme.ToolbarMain" />

    <ScrollView
        android:id="@+id/scrollView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp"
        android:scrollbars="none">

        <com.google.android.material.card.MaterialCardView
            android:id="@+id/card"
            style="@style/AppTheme.CardView">

            <ImageView
                android:id="@+id/imageView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_margin="8dp"
                android:adjustViewBounds="true"
                android:contentDescription="@string/olinguito"
                android:src="@drawable/olinguito" />

        </com.google.android.material.card.MaterialCardView>

    </ScrollView>

</LinearLayout>

Para facilitar la reutilización de los componentes, tanto la toolbar como la tarjeta han sido configuradas mediante estilos. Como tema base de la aplicación, tenemos el DayNight de Material Components que proporciona automáticamente soporte para tema claro y oscuro.

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">

  <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <item name="colorPrimary">@color/primary</item>
        <item name="colorPrimaryDark">@color/primaryDark</item>
        <item name="colorOnPrimary">@android:color/white</item>
    </style>

     <!--colorPrimary en modo claro, colorSurface en modo oscuro -->
    <style name="AppTheme.ToolbarMain" parent="Widget.MaterialComponents.Toolbar.PrimarySurface">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">?attr/actionBarSize</item>
        <item name="android:elevation" tools:targetApi="lollipop">4dp</item>
    </style>

    <style name="AppTheme.CardView">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <!-- por defecto se aplica el colorSurface del tema-->
        <!--<item name="cardBackgroundColor">@android:color/white</item>-->
        <item name="cardCornerRadius">4dp</item>
        <item name="cardElevation">4dp</item>
        <item name="cardUseCompatPadding">true</item>
    </style>

</resources>

El resultado que tenemos es simplemente el ImageView dentro de la tarjeta aunque obsérvese los bordes redondeados y la sombra (elevación) pertenecientes a la CardView, elementos configurados en los estilos. El color de fondo de la card se corresponde con el colorSurface del tema, que no hemos personalizada y que por defecto proporciona un color adecuado tanto para modo claro. Podemos cambiarlo explícitamente sólo para la card con la propiedad cardBackgroundColor.

Nota Se puede cambiar fácilmente entre tema claro y oscuro activando en Android 10+ el modo ahorro de batería o bien el tema oscuro en las preferencias. Recomiendo consultar Android: Selección de tema claro y oscuro, pantalla de ajustes.

MaterialCardView tema claro y oscuro

La línea del borde es personalizable gracias a las propiedades strokeWidth y strokeColor.

 <style name="AppTheme.CardView">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="cardCornerRadius">4dp</item>
        <item name="cardElevation">4dp</item>
        <item name="cardUseCompatPadding">true</item>
        <item name="strokeWidth">3dp</item>
        <item name="strokeColor">@android:color/black</item>
    </style>

MaterialCardView borde

Toolbar

Vamos a añadir una cabecera a la tarjeta que muestre un título, un subtítulo y un menú desplegable con varias acciones utilizando para ello una Toolbar. La tarjeta ahora contendrá un LinearLayout con la Toolbar y el ImageView.

<LinearLayout 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"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <com.google.android.material.appbar.MaterialToolbar
        android:id="@+id/toolbar"
        style="@style/AppTheme.ToolbarMain" />

    <ScrollView
        android:id="@+id/scrollView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp"
        android:scrollbars="none">

        <com.google.android.material.card.MaterialCardView
            android:id="@+id/card"
            style="@style/AppTheme.CardView">

            <LinearLayout
                android:id="@+id/linearLayoutCardContent"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

                <com.google.android.material.appbar.MaterialToolbar
                    android:id="@+id/toolbarCard"
                    style="@style/ToolbarCard"/>

                <!--By Mark Gurney [CC BY 3.0 (http://creativecommons.org/licenses/by/3.0)], via Wikimedia Commons -->
                <ImageView
                    android:id="@+id/imageView"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_margin="8dp"
                    android:adjustViewBounds="true"
                    android:contentDescription="@string/olinguito"
                    android:src="@drawable/olinguito" />

            </LinearLayout>

        </com.google.android.material.card.MaterialCardView>

    </ScrollView>


</LinearLayout>

Cursos aplicaciones móviles

Para personalizar la Toolbar nuevamente se ha recurrido a la creación de un estilo, y queremos que su color de fondo sea el mismo que el de la card.

    <style name="ToolbarCard" parent="Widget.MaterialComponents.Toolbar">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <!-- estilo del menú adecuado para modo claro y modo oscuro -->
        <item name="popupTheme">@style/Theme.MaterialComponents.DayNight</item>
    </style>

El menú se define como es habitual en el xml correspondiente. En nuestro caso queremos que las opciones se muestren siempre en un menú contextual (showAsAction tendrá el valor never), pero también se podrían mostrar la acciones del mismo modo que en cualquier Toolbar, por ejemplo con iconos.

<?xml version="1.0" encoding="utf-8"?>
<menu 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"
    tools:context=".MainActivity">

    <item
        android:id="@+id/action_option1"
        android:title="@string/option1"
        app:showAsAction="never" />

    <item
        android:id="@+id/action_option2"
        android:title="@string/option2"
        app:showAsAction="never" />

    <item
        android:id="@+id/action_option3"
        android:title="@string/option3"
        app:showAsAction="never" />
</menu>

El título, subtítulo y menú de la Toolbar de la tarjeta también se pueden definir directamente en el layout.

 <com.google.android.material.appbar.MaterialToolbar
                    android:id="@+id/toolbarCard"
                    style="@style/ToolbarCard"
                    app:title="@string/olinguito"
                    app:subtitle="@string/subtitle"
                    app:menu="@menu/menu_card"/>

Pero normalmente la card será dinámica y estos valores tendrán que ser configurados mediante código. También tendremos que asignar la Toolbar un listener de tipo Toolbar.OnMenuItemClickListener para atender las pulsaciones de las acciones. Simplemente se mostrará un Toast con la opción seleccionada.

package com.danielme.android.cardview;

import android.os.Bundle;
import android.view.MenuItem;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;

public class MainActivity extends AppCompatActivity {

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    setupToolbar();
    setupCard();
  }

  private void setupToolbar() {    
    Toolbar toolbar = findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);
  }

  private void setupCard() {
    Toolbar toolbarCard = findViewById(R.id.toolbarCard);
    toolbarCard.setTitle(R.string.olinguito);
    toolbarCard.setSubtitle(R.string.subtitle);
    toolbarCard.inflateMenu(R.menu.menu_card);
    toolbarCard.setOnMenuItemClickListener(new Toolbar.OnMenuItemClickListener() {
      @Override
      public boolean onMenuItemClick(MenuItem item) {
        switch (item.getItemId()) {
          case R.id.action_option1:
            Toast.makeText(MainActivity.this, R.string.option1, Toast.LENGTH_SHORT).show();
            break;
          case R.id.action_option2:
            Toast.makeText(MainActivity.this, R.string.option2, Toast.LENGTH_SHORT).show();
            break;
          case R.id.action_option3:
            Toast.makeText(MainActivity.this, R.string.option3, Toast.LENGTH_SHORT).show();
            break;
        }
        return true;
      }
    });
  }

}

Secciones expandibles

Veamos cómo añadir una sección expandible tipo acordeón que haga uso de animaciones para mostrar y ocultar el contenido.

Añadimos dos nuevos layout a la tarjeta: la cabecera de la sección expandible (título y botón) y la información que se mostrará al expandirse la sección.

android contenido expandible

<LinearLayout 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"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <com.google.android.material.appbar.MaterialToolbar
        android:id="@+id/toolbar"
        style="@style/AppTheme.ToolbarMain" />

    <ScrollView
        android:id="@+id/scrollView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp"
        android:scrollbars="none">

        <com.google.android.material.card.MaterialCardView
            android:id="@+id/card"
            style="@style/AppTheme.CardView">

            <LinearLayout
                android:id="@+id/linearLayoutCardContent"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

                <com.google.android.material.appbar.MaterialToolbar
                    android:id="@+id/toolbarCard"
                    style="@style/ToolbarCard" />

                <!--app:title="@string/olinguito"
                    app:subtitle="@string/subtitle"
                    app:menu="@menu/menu_card" -->

                <!--By Mark Gurney [CC BY 3.0 (http://creativecommons.org/licenses/by/3.0)], via Wikimedia Commons -->
                <ImageView
                    android:id="@+id/imageView"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_margin="8dp"
                    android:adjustViewBounds="true"
                    android:contentDescription="@string/olinguito"
                    android:src="@drawable/olinguito" />

                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:orientation="horizontal"
                    android:padding="8dp">

                    <TextView
                        android:layout_width="0dp"
                        android:layout_height="wrap_content"
                        android:layout_gravity="center_vertical"
                        android:layout_weight="1"
                        android:gravity="center_vertical"
                        android:text="@string/details"
                        android:textAppearance="?attr/textAppearanceHeadline6" />

                    <ImageButton
                        android:id="@+id/imageViewExpand"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:contentDescription="@string/details"
                        android:background="?attr/selectableItemBackground"
                        android:src="@mipmap/more"
                        android:tint="?attr/colorOnSurface"
                        android:onClick="toggleDetails"/>
                </LinearLayout>


                <LinearLayout
                    android:id="@+id/linearLayoutDetails"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:orientation="vertical"
                    android:paddingLeft="8dp"
                    android:paddingRight="8dp"
                    android:paddingBottom="16dp"
                    android:visibility="gone">

                    <TextView
                        android:id="@+id/textViewInfo"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@string/info"
                        android:textAppearance="?attr/textAppearanceSubtitle1"
                        />
                </LinearLayout>


            </LinearLayout>

        </com.google.android.material.card.MaterialCardView>

    </ScrollView>

</LinearLayout>

La pulsación del botón está asociada al método toggleDetails cuyo cometido es mostrar u ocultar el linearLayoutDetails según su estado actual. Esta acción se realizará cambiando la visibilidad del layout de GONE a VISIBLE y viceversa, pero para que el resultado final sea refinado y elegante vamos a utilizar animaciones:

  • Si el linearLayoutDetails está oculto, lo mostraremos deslizándolo hacia abajo empezando en la cabecera. También se cambiará el icono con la flecha rotándolo sobre sí mismo 180º
  • En caso contrario, las animaciones se realizarán de forma inversa de tal modo que el linearLayoutDetails se deslice hacia arriba hasta desaparecer debajo de la tarjeta y el icono con la flecha “vuelva” a su posición anterior

La animación del icono flecha es sencilla y para el deslizado del layout se podría simplemente aplicar la api transition, disponible en androidx.transition, del siguiente modo

Transition transition = new AutoTransition();
transition.setDuration(DURATION);
TransitionManager.beginDelayedTransition(cardView, transition);
linearLayoutDetails.setVisibility(linearLayoutDetails.getVisibility() == View.GONE ? View.VISIBLE : View.GONE);

Sin embargo el efecto resultante no me convence. Implementar esta animación manualmente resulta más complejo de lo que puede aparecer a simple vista ya que es necesario calcular la altura que debe ocupar el layout a medida que se va deslizando y además hay que tener en cuenta que su contenido tiene una altura variable (en el ejemplo se utiliza un TextView que puede tener múltiples líneas). Para esta animación de deslizado voy a utilizar este código que he refactorizado con algunos pequeños cambios en la clase ExpandAndCollapseViewUtil.

El nuevo código necesario en la MainActivity es el siguiente (no olvidar obtener las vistas en el onCreate)

  private ViewGroup linearLayoutDetails;
  private ImageView imageViewExpand;

  private static final int DURATION = 250;

  public void toggleDetails(View view) {
    if (linearLayoutDetails.getVisibility() == View.GONE) {
      ExpandAndCollapseViewUtil.expand(linearLayoutDetails, DURATION);
      imageViewExpand.setImageResource(R.mipmap.more);
      rotate(-180.0f);
    } else {
      ExpandAndCollapseViewUtil.collapse(linearLayoutDetails, DURATION);
      imageViewExpand.setImageResource(R.mipmap.less);
      rotate(180.0f);
    }
  }

  private void rotate(float angle) {
    Animation animation = new RotateAnimation(0.0f, angle, Animation.RELATIVE_TO_SELF, 0.5f,
            Animation.RELATIVE_TO_SELF, 0.5f);
    animation.setFillAfter(true);
    animation.setDuration(DURATION);
    imageViewExpand.startAnimation(animation);
  }

El siguiente video muestra el resultado final.

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.

Master Pyhton, Java, Scala or Ruby

4 comentarios sobre “Diseño Android: Tarjetas con Material Components y contenido expandible

  1. Buenas, a la hora de incluir las dependencias me da error, y no se como solucionarlo, si me pudieras ayudar lo agradecería mucho.
    Muchas gracias, un saludo.

  2. hola excelente tutorial.

    pero tengo un error al crear la ultima linea de código la aplicación se destruye o podria explicar como se obtiene la vistas en el onCreate ,

    muchas gracias

  3. Hola! una consulta, en “linearLayoutDetails” al expandir la vista, si el contenido es largo y sobrepasa el alto de la pantalla, ¿cómo hacés para ir al final del linearLayoutDetails? gracias, saludos!

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Google photo

Estás comentando usando tu cuenta de Google. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

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