From the blog

Android Activity Transitions, Part 1

Animations are crucial to the design of a modern Android app. A visual state change that happens in a single frame is jarring to the user, and appears incomplete. This applies especially to screen transitions. Thankfully, modern Android devices provide default screen transition animations:

This default transition animation looks good, and distracts from the time taken to load additional details on the second screen. However, Google’s Material Design principles call for animation that conveys meaning; the above animation does establish hierarchy (i.e., that we are moving to the “next” screen), but we can do better with just a few lines of code:

In this first post in a series on screen transitions, we will swap the default transition with a simple alternative. Throughout the rest of the series, we’ll make incremental improvements to arrive at a transition that feels beautiful and polished.

Note: Code samples will be given using the Kotlin language. Its concise syntax is beneficial when implementing and discussing animation code.

Requesting a Transition

The first step in creating our transition is to let Android know we want to persist a view from the launching activity to the launched activity. This view will be referred to as a “Shared Element”, and is meant to be the focal point of the animation. In the above example, an ImageView containing a photo is the Shared Element. As of API 21, the framework provides a helper method, ActivityOptions.makeSceneTransitionAnimation(), which packs relevant details about the Shared Element view into a Bundle for inclusion in your Activity’s launcher intent:

class DetailActivity() : AppCompatActivity() {
   ...

   // Methods inside this block are static
   companion object {
       fun launch(activity: Activity, sharedView: View) {
           val transitionName = activity.resources.getString(R.string.image_transition_name)
           val launcher = Intent(activity, DetailActivity::class.java)

           val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, sharedView, transitionName)
           activity.startActivity(launcher, options.toBundle())
       }
   }
}

Behind the scenes, Android uses the data packed into the options Bundle in order to identify the Shared Elements in the target activity, and set its initial position and size to match its counterpart in the list activity. The target activity’s view is then animated into place, and the result looks like we shared a single view across two Activities.

How does Android identify which view in DetailActivity corresponds to the sharedView we passed into the launcher method? It’s actually fairly simple: the transitionName string. We use a string resource so that we can also use it in DetailActivity’s layout XML:

<ImageView
   android:id="@+id/image_hero"
   android:layout_width="360dp"
   android:layout_height="360dp"
   android:transitionName="@string/image_transition_name"/>

Android looks for a view with the same transitionName as what was packed into the Bundle, and applies the animation to that view.

For many use cases, this is all that’s necessary to perform an activity transition animation.

Animating an Image

In many use cases, however, we’d like to animate an image. Those can require some additional work to get the transition right. For example, it is likely that your ListActivity does not actually show images at their full resolution. If you use Picasso to load images, your ViewHolder’s binding method might contain something that looks like this:

// Extension method - can be called directly on any ImageView
fun ImageView.loadImage(path: String) = Picasso.with(context)
           .load(path)
           .centerCrop()
           .fit()
           .into(this)

This simple method tells Picasso to fill in our ImageView with an image that will be reduced to the resolution of the ImageView, and cropped to its center. This optimization reduces memory usage when displaying the list, but it means we need to load an additional Bitmap in DetailActivity. For a large image, the time necessary to perform this load operation could easily be longer than the duration of the transition itself:

The solution is simple: we ask Android to delay the transition animation, and when the image load is finished, we ask Android to start it. First, we’ll upgrade our image loading method with some extra cleverness:

// callback, if omitted by the caller, is null
fun ImageView.loadImage(path: String, callback: Callback? = null) {
   val requestCreator = Picasso.with(context)
           .load(path)
           .centerCrop()
           .fit()

   // Executes if callback is NOT null
   callback?.let {
       requestCreator.into(this, callback)
   } ?: let { // Executes if callback IS null
       requestCreator.into(this)
   }
}

Now, our loader lets us optionally specify what to do when the image load completes or fails. We can now define this functionality in DetailActivity:

class DetailActivity() : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_detail)

       postponeEnterTransition()
       image_hero.loadImage(IMAGE_ASSET_PATH, getPicassoCallback())
   }

   fun getPicassoCallback() = object : Callback {
       override fun onSuccess() = startPostponedEnterTransition()

       override fun onError() = startPostponedEnterTransition()
   }
    
    ...

}

In this snippet, getPicassoCallback() is a one-line method that returns an anonymous object that implements the Picasso Callback interface, itself composed of two methods that are also one line. In onCreate(), we request that android postponeEnterTransition(), and within callback, we respond to either a success or failure by calling startPostponedEnterTransition(). It is crucial that this last step occurs; until it does, DetailActivity will remain in an unusable state. As a result, it’s probably not a good idea to postpone while waiting for an image to download from the network. It would be better to use a locally cached image and use the animation to fill time while the full-size image is downloaded.

With the above additions, the transition should now look the way we would expect:

Note that there is a slight delay after the list item is touched. This is the time it would’ve taken to load the image anyway, but we’re now doing it before the animation plays, rather than afterwards.

Limitations

Did you notice that the layout definition for image_hero had a hard-coded width and height? This was done to ensure the same 1:1 aspect ratio as the thumbnail view in the list. This is because with the techniques described above, transitioning between ImageViews with different aspect ratios will produce an unsightly glitch:

This glitch occurs because we asked our image-loading library to center, crop, and resize our image. In doing so, it will load as few pixels as possible into the resulting Bitmap. If the larger image has a different aspect ratio, there may be some data missing at the edges of the image, and the transition animation that Android provides for us does not know how to handle this situation. Fortunately, you can work around this issue with a custom Transition class, which we’ll describe in a later entry in this series.

For now, as long as you stick to the same aspect ratio in your thumbnail and hero ImageViews, this animation looks great, and is a dramatic improvement from using platform defaults. In the next post, we’ll discuss how to make this animation even better by customizing it.

Leave a Reply

Your email address will not be published.