«

»

feb 24 2015

Un ‘stack’ productivo para el desarrollador android #2, UI

Esta es la segunda parte en las serie: ‘Un entorno productivo en android‘, en la primera parte revisamos la arquitectura general del proyecto, esta vez se centrará en la interfaz gráfica y en algunos aspectos generales del diseño de la aplicación.

No me gustaría hablar de cómo materializar una aplicación android con Material Design, creo que hay muy buenos posts por internet como por ejemplo éste de David Gonzalez.

En el momento que escribo este artículo la aplicación es realmente sencilla respecto al diseño, una lista de películas, una vista de detalle y un navigation drawer.

El proyecto se encuentra disponible en GitHub

 Librerias

app/build.gradle

// Google libraries
compile 'com.android.support:appcompat-v7:21.0.3'
compile 'com.android.support:recyclerview-v7:21.0.3'
compile 'com.android.support:palette-v7:21.0.0'
// Square libraries
compile 'com.squareup.picasso:picasso:2.4.0'
compile 'com.jakewharton:butterknife:6.0.0'

AppCompat

Qué decir sobre la nueva librería de compatibilidad de Google, el uso que le he dado en el proyecto ha sido mayoritariamente para sacarle provecho al elemento Toolbar.

El elemento Toolbar es una generalización del antiguo: Action Bar, es un ViewGroup, por lo que podemos agrupar vistas dentro de él, en mi caso he incluido un TextView personalizado con una fuente determinada. Además, aprovechando la flexibilidad de poder tener la vista en el layout, cada vez que el usuario hace scroll hacia abajo, se oculta para dar una mayor visibilidad.

activity_main.xml

<com.hackvg.android.views.custom_views.LobsterTextView 
     android:layout_width="wrap_content" 
     android:layout_height="wrap_content" 
     android:text="@string/app_name" 
     android:textSize="22sp" 
     android:textColor="#FFF" />

Únicamente activando la animación cuando se detecta que el usuario ha hecho scroll hacia abajo o hacia arriba se puede lograr éste efecto.

bar

MoviesActivity.java

private RecyclerView.OnScrollListener recyclerScrollListener =
      new RecyclerView.OnScrollListener() {

      public boolean flag;

     @Override
     public void onScrolled(RecyclerView recyclerView, 
          int dx, int dy) {

          super.onScrolled(recyclerView, dx, dy);

          // Is scrolling up
          if (dy > 10) {

                if (!flag) {
                    showToolbar();
                    flag = true;
               }

          // Is scrolling down
          } else if (dy < -10) {

               if (flag) {
                    hideToolbar();
                    flag = false;
               }
          }
     }
};

private void showToolbar() {

     toolbar.startAnimation(AnimationUtils.loadAnimation(
          this, R.anim.translate_up_off));
}

private void hideToolbar() {

     toolbar.startAnimation(AnimationUtils.loadAnimation(
          this, R.anim.translate_up_on));
}

translate_up_off.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:interpolator/fast_out_linear_in"
    android:fillAfter="true">

    <translate
        android:duration="@integer/anim_trans_duration_millis"
        android:startOffset="0"
        android:fromXDelta="0"
        android:fromYDelta="0"
        android:toXDelta="0"
        android:toYDelta="-100%"
        />
</set>

ButterKnife

ButterKnife como su creador Jake Wharton indica, es una librería de inyección de vistas para android la cuál se basa en el procesado de anotaciones.

Así se evita tener que escribir sentencias del tipo: findViewById, setOnClickListener(new OnClick…) repetidas veces, de ésta forma, el código queda más legible además de escribir menos.

MovieDetailActivity.java

@InjectViews({
     R.id.activity_movie_detail_title,
     R.id.activity_movie_detail_content,
     R.id.activity_detail_homepage_value,
     R.id.activity_detail_company_value,
     R.id.activity_detail_tagline_value,
     R.id.activity_movie_detail_confirmation_text,
}) List movieInfoTextViews;

@InjectViews({
     R.id.activity_detail_header_tagline,
     R.id.activity_detail_movie_header_description
}) List headers;

@InjectView(R.id.activity_detail_book_info)
View overviewContainer;
@InjectView(R.id.activity_movie_detail_fab)
ImageView fabButton;
@InjectView(R.id.activity_movie_detail_cover_wtf)
ImageView coverImageView;
@InjectView(R.id.activity_movide_detail_confirmation_image)
ImageView confirmationView;
@InjectView(R.id.activity_movie_detai_confirmation_container)
FrameLayout confirmationContainer;
@InjectView(R.id.activity_movie_detail_scroll)
ObservableScrollView observableScrollView;

Un aspecto interesante de ésta librería es la anotación: @InjectViews, que permite inyectar varias vistas en una lista, con esto es posible utilizar otras utilidades de esta librería, como son los Setters y Actions para por ejemplo, aplicar una determinada propiedad a toda la lista de vistas.

GUIUtils.java

public static final ButterKnife.Setter<TextView, Integer> setter =
      new ButterKnife.Setter<TextView, Integer>() {

      @Override
      public void set(TextView view, Integer value, int index) {
            view.setTextColor(value);
      }
};

En mi caso a todos los TextView que muestran información sobre la película seleccionada tienen un color determinado para el texto.

MoviesActivity.java

ButterKnife.apply(movieInfoTextViews, 
     GUIUtils.setter, lightSwatch.getTitleTextColor());

Es posible también manejar eventos de las vistas con ButterKnife

@OnClick(R.id.activity_movie_detail_fab)

public void onClick() {
      showConfirmationView();
}

Palette

Con android L Google presentó una nueva librería llamada Palette, ésta promete extraer los colores predominantes en una imagen.

Estos colores se agrupan en un contenedor llamado Swatch, que contiene entre otras propiedades un color de fondo y un color para el texto legible en conjunto con el color de fondo.

palette

Con Palette se pueden obtener los siguientes conjuntos de colores:

  • MutedSwatch
  • VibrantSwatch
  • DarkVibrantSwatch
  • DarkMutedSwatch
  • LightMutedSwatch
  • LightVibrantSwatch

Para esta aplicación únicamente he usado: VibrantSwatch, DarkVibrantSwatch y LightVibrantSwatch

palette2

Hay que tener en cuenta que no siempre se pueden extraer determinados colores en una imagen, por lo que es más que recomendable comprobar que Palette no devuelve conjuntos nulos.

Otro aspecto a tener en cuenta es que la tarea de determinar los colores es una tarea compleja, por lo que Palette provee una generación asíncrona para obtener los colores.

MoviesActivity.java

Palette.generateAsync(bookCoverBitmap, this);
public class MovieDetailActivity extends Activity 
      implements MVPDetailView, Palette.PaletteAsyncListener {

      ...
      @Override
      public void onGenerated(Palette palette) {

            if (palette != null) {

                  Palette.Swatch vibrantSwatch = palette
                        .getVibrantSwatch();

                 Palette.Swatch darkVibrantSwatch = palette
                        .getDarkVibrantSwatch();

                  Palette.Swatch lightSwatch = palette
                        .getLightVibrantSwatch();

                  if (lightSwatch != null) {
                        // awesome palette code
                  }
            }
      }
}

Me ha parecido muy interesante un concepto extraído del Dialer en android 5.0, resulta que en la vista de detalle de un contacto, los iconos se tintan en función del color del tinte que se le aplica a la imagen del contacto:

dialer_colors

Este efecto se puede conseguir dinámicamente aplicando un ColorFilter al CompoundDrawable del TextView

GUIUtils.java

public static void tintAndSetCompoundDrawable (
      Context context, @DrawableRes int drawableRes, int color,
      TextView textview) {

            Resources res = context.getResources();
            int padding = (int) res.getDimension(
                  R.dimen.activity_horizontal_margin);

            Drawable drawable = res.getDrawable(drawableRes);
            drawable.setColorFilter(color,
                  PorterDuff.Mode.MULTIPLY);

            textview.setCompoundDrawablesRelativeWithIntrinsicBounds(
            drawable, null, null, null);

            textview.setCompoundDrawablePadding(padding);
}

Resultado:

compund

Transiciones

La transición entre la actividad con la lista de películas (MoviesActivity) y la actividad de detalle (MovieDetailActivity), hace uso de un elemento compartido que es la carátula de la película.

En el adaptador del RecyclerView se especifica el transitionName de la vista que será compartida, (transitionName será el identificador de la vista respecto a la transición entre dos actividades).

@Override
public void onBindViewHolder(MovieViewHolder holder,
      int position) {

      TvMovie selectedMovie = movieList.get(position);
      holder.titleTextView.setText(selectedMovie.getTitle());
      holder.coverImageView.setTransitionName("cover" + position);

      String posterURL = Constants.POSTER_PREFIX
            + selectedMovie.getPoster_path();

      Picasso.with(context)
            .load(posterURL)
            .into(holder.coverImageView);
}

Antes de pasar a la actividad de detalle se especifica qué elemento será el compartido, esto se hace mediate ActivityOptions.

@Override
public void onClick(View v, int position) {

      Intent i = new Intent (MoviesActivity.this, MovieDetailActivity.class);

      String movieID = moviesAdapter.getMovieList()
            .get(position).getId();

      i.putExtra("movie_id", movieID);
      i.putExtra("movie_position", position);

      ImageView coverImage = (ImageView) v.findViewById(
            R.id.item_movie_cover);

      photoCache.put(0, coverImage
            .getDrawingCache());

      // Setup the transition to the detail activity
      ActivityOptions options = ActivityOptions
            .makeSceneTransitionAnimation(this,
            new Pair<View, String>(v, "cover" + position));

      startActivity(i, options.toBundle());
}

Finalmente en la actividad de detalle se recoge la vista que es compartida y se muestra en el layout

@Override
public void onCreate(Bundle savedInstanceState) {

      ...
      int moviePosition = getIntent()
      .getIntExtra("movie_position", 0);
      coverImageView.setTransitionName(
            "cover" + moviePosition);

      ...
}

Hasta aquí todo lo normal, estos podrían ser los requerimientos de cualquier aplicación con una lista y una vista de detalle, pero, ¿Y si tenemos un estado intermedio entre el detalle y la vuelta a la vista?

Cuando un usuario presiona el Floating Action Button para marcar una película como favorita, se presenta fugazmente una vista para informar al usuario que se ha marcado satisfactoriamente, en ese momento no interesa hacer uso de de la sharedElementReturnTransition para volver a la actividad principal, lo que me interesa es mostrar una animación que cambie la experiencia del usuario.

transitions_groupEsto se puede conseguir fácilmente sobreescribiendo la transición de retorno de la actividad: getWindow().setReturnTransition(new Slide());

VectorDrawable

Una característica muy poderosa que ha venido con Lollipop son los nuevos VectorDrawable, estos abren un mundo a los vectores gráficos a partir de paths, escalado de imágenes etc… Además, Lollipop incluye poderosas herramientas para poder animar estos nuevos gráficos.

Los VectorDrawable no es lo mismo que la especificación SVG, sin embargo, los VectorDrawable implementan parte de la misma.

Este es ele ejemplo de una simple estrella en formato SVG:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1"  xmlns="http://www.w3.org/2000/svg" 
    width="300px" 
    height="300px" >

    <g id="star_group">
        <path fill="#000000" d="M 200.30535,69.729172
        C 205.21044,69.729172 236.50709,141.52218 240.4754,144.40532
        C 244.4437,147.28846 322.39411,154.86809 323.90987,159.53312
        C 325.42562,164.19814 266.81761,216.14828 265.30186,220.81331
        C 263.7861,225.47833 280.66544,301.9558 276.69714,304.83894
        C 272.72883,307.72209 205.21044,268.03603 200.30534,268.03603
        C 195.40025,268.03603 127.88185,307.72208 123.91355,304.83894
        C 119.94524,301.9558 136.82459,225.47832 135.30883,220.8133
        C 133.79307,216.14828 75.185066,164.19813 76.700824,159.53311
        C 78.216581,154.86809 156.16699,147.28846 160.13529,144.40532
        C 164.1036,141.52218 195.40025,69.729172 200.30535,69.729172 z"/>
    </g>
</svg>

Y esta, es su implementación con un VectorDrawable

<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:viewportWidth="400"
    android:viewportHeight="400"
    android:width="300px"
    android:height="300px">

    <group android:name="star_group"
        android:pivotX="200"
        android:pivotY="200"
        android:scaleX="0.0"
        android:scaleY="0.0">

        <path
            android:name="star"
            android:fillColor="#FFFFFF"
            android:pathData="@string/star_data"/>
    </group>
</vector>
<string name="star_data">
    M 200.30535,69.729172
    C 205.21044,69.729172 236.50709,141.52218 240.4754,144.40532
    C 244.4437,147.28846 322.39411,154.86809 323.90987,159.53312
    C 325.42562,164.19814 266.81761,216.14828 265.30186,220.81331
    C 263.7861,225.47833 280.66544,301.9558 276.69714,304.83894
    C 272.72883,307.72209 205.21044,268.03603 200.30534,268.03603
    C 195.40025,268.03603 127.88185,307.72208 123.91355,304.83894
    C 119.94524,301.9558 136.82459,225.47832 135.30883,220.8133
    C 133.79307,216.14828 75.185066,164.19813 76.700824,159.53311
    C 78.216581,154.86809 156.16699,147.28846 160.13529,144.40532
    C 164.1036,141.52218 195.40025,69.729172 200.30535,69.729172 z
</string>

La diferencia es muy pequeña, existen grupos (group), trazas (path) etc…, android:viewport{Width | Height} especifica el tamaño del lienzo, mientras que android:width y android:height especifican el tamaño de la imagen.

Los “ permiten animar partes de estos <vector-drawable, es posible animar grupos de “ o hasta cambiar la forma de los mismos.

En mi caso, muestro una estrella, con una animación de escalado y cuando ésta termina, ejecuta una animación que rota la estrella, a la vez, esta cambia de forma a un caramelo, y posteriormente vuelve a su forma original.

Es importante mencionar que para que sea posible mostrar una animación de cambio de forma, los datos `svg` tiene que contenter **los mismos comandos**, si no, se disparará una excepción.

`avd_star.xml`

<?xml version="1.0" encoding="utf-8"?>
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:drawable="@drawable/vd_star">

    <target
        android:name="star_group"
        android:animation="@anim/appear_rotate" />

    <target
        android:name="star"
        android:animation="@anim/star_morph" />

</animated-vector>

En este que está asociado al drawable star.xml se especifican dos objetivos (target) a animar

El primero se centrara en el grupo start_group definido en el vector: star.xml, este ejecutará una animación de escalado y de rotación

appear_rotate.xml

<set
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:ordering="sequentially"
    android:interpolator="@android:anim/decelerate_interpolator"
    >

    <set
        android:ordering="together"
        >

        <objectAnimator
            android:duration="300"
            android:propertyName="scaleX"
            android:valueFrom="0.0"
            android:valueTo="1.0"/>

        <objectAnimator
            android:duration="300"
            android:propertyName="scaleY"
            android:valueFrom="0.0"
            android:valueTo="1.0"/>
    </set>

    <objectAnimator
        android:propertyName="rotation"
        android:duration="500"
        android:valueFrom="0"
        android:valueTo="360"
        android:valueType="floatType"/>
</set>

Para el segundo objetivo se aplica la animación de cambio de forma, esta es sencillamente otro que cambiara de unos datos a otros, vuelvo a subrayar que para que la mutación se produzca los comandos han de ser similares, pero con diferentes valores.

En este <set> se cambia primero de estrella a caramelo y de caramelo a estrella.

star_morph.xml

<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:ordering="sequentially"
    android:fillAfter="true">

    <objectAnimator
        android:duration="500"
        android:propertyName="pathData"
        android:valueFrom="@string/star_data"
        android:valueTo="@string/star_lollipop"
        android:valueType="pathType"
        android:interpolator="@android:anim/accelerate_interpolator"/>

    <objectAnimator
        android:duration="500"
        android:propertyName="pathData"
        android:valueFrom="@string/star_lollipop"
        android:valueTo="@string/star_data"
        android:valueType="pathType"
        android:interpolator="@android:anim/accelerate_interpolator"/>
</set>

Resultado:

animated

Cabeceras fijas

Otro aspecto que me llamo la atención en la aplicación del Dialer de Google, es que cuando haces scroll en la información de un contacto, la imagen, se hace más pequeña hasta un punto que se queda fija.

dialer

Intentando replicar este efecto he encontrado un post perdido de Roman Nurik, en donde utiliza la propiedad View.setTranslationY(float translationY) en el listener de un `ScrollView` para lograr este efecto, el parámetro translationY para que el título de la película se quede fija a partir de una determinada cantidad (el tamaño de la carátula) sumo la constante del tamaño de la carátula al desplazamiento del ScrollView

MovieDetailActivity.xml

@Override
public void onScrollChanged(ScrollView scrollView, 
      int x, int y, int oldx, int oldy) {

      if (y > coverImageView.getHeight()) {

            movieInfoTextViews.get(TITLE)
                  .setTranslationY(y - coverImageView.getHeight());

            if (!isTranslucent) {

                  GUIUtils.setTheStatusbarNotTranslucent(this);

                  getWindow().setStatusBarColor(
                        mBrightSwatch.getRgb());

                  isTranslucent = true;
            }
      }

      if (y < coverImageView.getHeight() && isTranslucent) {

            GUIUtils.makeTheStatusbarTranslucent(this);
            isTranslucent = false;
      }
}

rings

Acerca del autor

Saúl Molinero Malvido

Android enthusiast. GDG Vigo & GDG Santiago, en twitter @_saulmm