Diseño Android: Transiciones entre activities con Shared Element

android

Material Design hace especial hincapié en el concepto de motion (movimiento) para «describir las relaciones espaciales, la funcionalidad y la intención con belleza y fluidez». Dentro de los patrones de diseño propuestos podemos encontrar ejemplos y consejos para mantener la continuidad al navegar entre las distintas pantallas de forma coherente, natural y elegante, siendo uno de los comportamientos más llamativos el desplazamiento rápido y suave entre pantallas de elementos comunes entre ellas (shared elements). Podemos encontrar un buen ejemplo de ello en Google Play.


Para ayudarnos en la implementación en nuestras apps de estos diseños Google intodujo en Lollipop una nueva API de transiciones entre Activites y fragments, no disponible en versiones anteriores de Android, basada en la Transitions API previamente introducida en KitKat. En este tutorial la usaremos para hacer transiciones entre dos activities de forma que ambas compartan una imagen de forma similar a lo que podemos ver en la animación anterior de Google Play.

Proyecto de ejemplo

Cheesquare es una sencilla demo de varios elementos visuales de Material Design proporcionados por Google, algunos de los cuales han sido tratados en este blog como por ejemplo Floating Action Button (FAB) y Snackbar. La pantalla principal muestra un listado de quesos con un recycler view y pulsando en cada fila se accede a una Activity que muestra el detalle del mismo.

cheesequare

En este tutorial vamos a animar la transición entre las dos pantallas anteriores desplazando la imagen entre ambas dando una agradable sensación de continuidad en la navegación de la aplicación.

Nota: he cambiado Glide por Picasso ya que el primero no era capaz de redimensionar siempre de forma adecuada la imagen compartida. Asimismo, el proyecto se ha actualizado para poder utilizar la última versión de Android en el momento de escribirse el tutorial (Android O – Api 26).

Aplicando la animación

Para configurar la animación seguimos los siguientes pasos:

  1. Emparejar los elementos compartidos entre las dos activities mediante un mismo código único con el atributo android:transitionName
    
        <de.hdodenhof.circleimageview.CircleImageView
            android:id="@+id/avatar"
            android:layout_width="@dimen/list_item_avatar_size"
            android:layout_height="@dimen/list_item_avatar_size"
            android:layout_marginRight="16dp"
            android:transitionName="avatar"/>
       
    
       <ImageView
                    android:id="@+id/backdrop"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:scaleType="centerCrop"
                    android:fitsSystemWindows="true"
                    app:layout_collapseMode="parallax"
                    android:transitionName="avatar"/>
       
    
  2. Al iniciar la Activity con el detalle indicamos los elementos compartidos. En nuestro ejemplo esto se realiza en la clase CheeseListFragment dentro de la implementación del adapter del recyclerview.
        @Override
            public void onBindViewHolder(final ViewHolder holder, int position) {
                holder.mBoundString = mValues.get(position);
                holder.mTextView.setText(mValues.get(position));
    
                final int drawableId = Cheeses.getRandomCheeseDrawable();
    
                holder.mView.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        Context context = v.getContext();
                        Intent intent = new Intent(context, CheeseDetailActivity.class);
                        intent.putExtra(CheeseDetailActivity.EXTRA_NAME, holder.mBoundString);
                        intent.putExtra(CheeseDetailActivity.EXTRA_IMAGE, drawableId);
    
                        ActivityOptionsCompat options = ActivityOptionsCompat.
                                makeSceneTransitionAnimation(CheeseListFragment.this.getActivity(),
                                        holder.mImageView, "avatar");
                        startActivity(intent, options.toBundle());                        
                    }
                });
    
               Picasso.with(holder.mImageView.getContext())
                        .load(drawableId)
                        .fit()
                        .into(holder.mImageView);
            }
    

    Si tuviésemos más de un elemento compartido lo haríamos así:

      Pair<View, String> p1 = Pair.create((View)holder.mImageView, "avatar");
      Pair<View, String> p2 = Pair.create(view2, "view2");
      ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(CheeseListFragment.this.getActivity(), p1, p2);
      startActivity(intent, options.toBundle());
    
  3. Con estos cambios tan simples ya tenemos la transición pero el resultado no queda demasiado estético ya que el desplazamiento de la imagen «colisiona» tanto con el FAB como con el título y la flecha de navegación de la toolbar.

    Vamos a mejorar la transición haciendo que el titulo, la toolbar y el FAB no se muestren hasta que haya terminado la animación de la imagen. Para ello atendemos con una implementación del listener TransitionListener el evento de finalización de entrada en la Activity. Asimismo, tenemos que asegurar que el código relativo a las transiciones sólo se ejecute en Lollipop y superior ya que estamos usando métodos que sólo están disponibles a partir de esta versión.

    package com.support.android.designlibdemo;
    
    import android.content.Intent;
    import android.os.Build;
    import android.os.Bundle;
    import android.support.annotation.RequiresApi;
    import android.support.design.widget.AppBarLayout;
    import android.support.design.widget.CollapsingToolbarLayout;
    import android.support.design.widget.FloatingActionButton;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.transition.Transition;
    import android.view.Menu;
    import android.view.MotionEvent;
    import android.view.View;
    import android.view.ViewTreeObserver;
    import android.widget.ImageView;
    
    import com.squareup.picasso.Picasso;
    
    public class CheeseDetailActivity extends AppCompatActivity {
    
        public static final String EXTRA_NAME = "cheese_name";
        public static final String EXTRA_IMAGE = "cheese_image";
    
        private String cheeseName;
        private CollapsingToolbarLayout collapsingToolbar;
        private Toolbar toolbar;
        private FloatingActionButton fab;
    
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_detail);
    
            Intent intent = getIntent();
            cheeseName = intent.getStringExtra(EXTRA_NAME);
    
            toolbar = findViewById(R.id.toolbar);
            setSupportActionBar(toolbar);    
            getSupportActionBar().setDisplayHomeAsUpEnabled(true);
    
    
            collapsingToolbar = findViewById(R.id.collapsing_toolbar);   
    
            fab = findViewById(R.id.fab);
    
            loadBackdrop(intent.getIntExtra(EXTRA_IMAGE, -1));
    
             if (savedInstanceState == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                getWindow().getEnterTransition().addListener(new TransitionAdapter());
            } else {
                collapsingToolbar.setTitleEnabled(true);
                collapsingToolbar.setTitle(cheeseName);
            }
        }
    
        @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
        private class TransitionAdapter implements Transition.TransitionListener {
            @Override
            public void onTransitionStart(Transition transition) {
                collapsingToolbar.setTitleEnabled(false);
                toolbar.setVisibility(View.GONE);
                fab.setVisibility(View.INVISIBLE);
            }
           
            @Override
            public void onTransitionEnd(Transition transition) {
                fab.setVisibility(View.VISIBLE);
                collapsingToolbar.setTitleEnabled(true);
                collapsingToolbar.setTitle(cheeseName);
                toolbar.setVisibility(View.VISIBLE);
                getWindow().getEnterTransition().removeListener(this);
            }
    
            @Override
            public void onTransitionCancel(Transition transition) {
    
            }
    
            @Override
            public void onTransitionPause(Transition transition) {
    
            }
    
            @Override
            public void onTransitionResume(Transition transition) {
    
            }
        }
    
        private void loadBackdrop(int drawable) {
            final ImageView imageView = (ImageView) findViewById(R.id.backdrop);
            Picasso.with(getApplicationContext())
                    .load(drawable)
                    .into(imageView);
        }
    
        @Override
        public boolean onCreateOptionsMenu(Menu menu) {
            getMenuInflater().inflate(R.menu.sample_actions, menu);
            return true;
        }
    
    }
    
    

    Con este cambio la transición finalmente queda asi:

    Desincronizaciones en las animaciones

    En ocasiones vamos a encontrarnos con un comportamiento anómalo al aplicar estas animaciones, como por ejemplo que la nueva Activity se visualize completamente y posteriormente se muestre el elemento compartido, con o sin animación. Este fenónemo suele producirse cuando el elemento compartido en la nueva Activity que estamos abriendo se encuentra dentro de un fragment ya que la transacción no se ejecuta inmediatamente al realizarse el commit de la misma y ese retraso puede arruinar la correcta visualización de la animación. También tendremos este tipo de problemas si el elemento compartido se procesa de forma asíncrona y este procesamiento no finaliza antes de que la Activity se muestre en pantalla.

    Cursos aplicaciones móviles

    Para lidiar con este problema podemos aplicar la técnica expuesta en el este artículo consistente en hacer esperar a la animación de entrada en la Activity hasta que hayamos procesado el elemento compartido. Esta espera se inicia llamando a supportPostponeEnterTransition, y se finaliza con supportStartPostponedEnterTransition.

    En nuestro ejemplo utilizamos un listener para saber cuándo Picasso ha terminado la carga de la imagen y podemos realizar la animación de transición:

        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
    
            supportPostponeEnterTransition();
    
    ...
    
           private void loadBackdrop(final int drawable) {
            final ImageView imageView = (ImageView) findViewById(R.id.backdrop);
    
            Picasso.with(getApplicationContext())
                    .load(drawable)
                    .into(imageView, new Callback() {
                        @Override
                        public void onSuccess() {
                            CheeseDetailActivity.this.supportStartPostponedEnterTransition();
                        }
    
                        @Override
                        public void onError() {
                        }
                    });
        }
    

    Hasta que no se ejecute supportStartPostponedEnterTransition se mostrará en pantalla la Activity de la que venimos y la app quedará bloqueada, por tanto no debemos aplicar estas transiciones a elementos cuya carga pueda demorarse por ejemplo más de unos 200-300 ms.

    Transición de retorno

    Los desplazamientos de elementos compartidos al navegar hacia una Activity como el que hemos implementado pueden «revertirse» al volverse desde esa Activity de forma automática. Para ello al salir de la Activity hay que invocar al método supportFinishAfterTransition o bien llamar a onBackPressed. La animación de retorno no se realizará si se invoca directamente a finish(), lo que implica que tendremos que sobreescribir el método onBackPressed para evitar que la animación se aplique con el botón back del dispositivo.

    Así pues, la animación de retorno ya se está aplicando en nuestro ejemplo al pulsarse back por lo que nos faltaría animar el retorno cuando se pulse en la toolbar el icono de navegación hacia atrás en cuyo caso lo que haremos será llamar a onBackPressed.

     toolbar.setNavigationOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    onBackPressed();
                }
            });
    

    Lastimosamente si queremos que nuestro ejemplo quede realmente bien hay que tener en cuenta que la imagen se muestra dentro de una Collapsing Toolbar por lo que en el momento de salir de la Activity puede que no esté totalmente visible (o incluso totalmente oculta). En estos casos la imagen aparece «de repente» y se realiza la transición.

    La estrategia que voy a seguir para conseguir una transición más elegante es la siguiente:

    1. Si la imagen no está visible, no se aplicará animación alguna y salimos de la Activity haciendo un finish(). Se puede conocer esta situación con la condición «collapsingToolbar.getContentScrim().getAlpha() == 255«
    2. Si la imagen está visible, se expandirá la toolbar para que se muestre completamente y una vez que haya finalizado esta expansión salimos.

    Para implementar el segundo caso es necesario asegurar que la secuencia de acciones se ejecute en el orden adecuado y que no se aplique la transición mientras todavía se está expandiendo la toolbar. Lo que haremos será solicitar la expansión del AppBar Layout con el método setExpanded e implementar un AppBarLayout.OnOffsetChangedListener para detectar la finalización de la expansión. En ese momento se llama a onBackPressed si es que su ejecución estaba a la espera de la expansión. Esto último lo sabremos porque usaremos un flag (el atributo back) para indicarlo.

    Para asegurar que todo este proceso para volver de la Activity se ejecuta completamente se va a «bloquear» la pantalla para evitar que el usuario pueda realizar cualquier acción. Esto se hará sobreescribiendo el método dispatchTouchEvent.

    Aplicamos estos cambios:

    package com.support.android.designlibdemo;
    
    import android.annotation.SuppressLint;
    import android.content.Intent;
    import android.os.Build;
    import android.os.Bundle;
    import android.support.annotation.RequiresApi;
    import android.support.design.widget.AppBarLayout;
    import android.support.design.widget.CollapsingToolbarLayout;
    import android.support.design.widget.FloatingActionButton;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.transition.Transition;
    import android.view.Menu;
    import android.view.MotionEvent;
    import android.view.View;
    import android.view.ViewTreeObserver;
    import android.widget.ImageView;
    
    import com.squareup.picasso.Picasso;
    
    public class CheeseDetailActivity extends AppCompatActivity {
    
        public static final String EXTRA_NAME = "cheese_name";
        public static final String EXTRA_IMAGE = "cheese_image";
    
        private String cheeseName;
        private CollapsingToolbarLayout collapsingToolbar;
        private Toolbar toolbar;
        private FloatingActionButton fab;
        private AppBarLayout appBarLayout;
        private boolean back = false;
    
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
    
            setContentView(R.layout.activity_detail);
    
            Intent intent = getIntent();
            cheeseName = intent.getStringExtra(EXTRA_NAME);
    
            toolbar = (Toolbar) findViewById(R.id.toolbar);
            setSupportActionBar(toolbar);
            getSupportActionBar().setDisplayHomeAsUpEnabled(true);
    
            toolbar.setNavigationOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    onBackPressed();
                }
            });
    
            collapsingToolbar = (CollapsingToolbarLayout) findViewById(R.id.collapsing_toolbar);
    
            fab = (FloatingActionButton) findViewById(R.id.fab);
            appBarLayout = ((AppBarLayout) findViewById(R.id.appbar));
    
            loadBackdrop(intent.getIntExtra(EXTRA_IMAGE, -1));
    
            if (savedInstanceState == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                getWindow().getEnterTransition().addListener(new TransitionAdapter());
            } else {
                collapsingToolbar.setTitleEnabled(true);
                collapsingToolbar.setTitle(cheeseName);
            }
    
            appBarLayout.addOnOffsetChangedListener(new AppBarLayout.OnOffsetChangedListener() {
                @Override
                public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
    
                    if (verticalOffset == 0 && back) {
                        CheeseDetailActivity.super.onBackPressed();
                    }
                }
            });
        }
    
        @Override
        public void onBackPressed() {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                if (collapsingToolbar.getContentScrim().getAlpha() == 255) {
                    finish();
                } else {
                    back = true;
                    fab.setVisibility(View.INVISIBLE);
                    appBarLayout.setExpanded(true);
    
                }
            } else {
                super.onBackPressed();
            }
        }
    
        @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            return back || super.dispatchTouchEvent(ev);
        }
    
    ...
    
    

    Este es el resultado final del tutorial.

    Proyecto de ejemplo

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

    Otros tutoriales sobre animaciones en Android

    Diseño Android: Transiciones entre Activities

    Tip Android #12: animar mostrar/ocultar layout con desplazamientos

Deja una respuesta

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 )

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.