6.
Element Transitions
Written by Alex Sullivan
You’ve learned about a lot of different types of screen transitions so far, but the coolest is still to come! Motion is the name of the game when building animations, and one of the coolest pieces of motion you can introduce in your apps is the shared element transition. A common place for shared element transition is transitioning from a list item to detail view. The user’s eye can be drawn to certain shared elements in the fragments, instead of transitioning the entire view heirarchy with an enter or exit transition.
In this chapter, you’ll learn:
- What a shared element transition is.
- How to use a shared element transition when changing fragments.
- How to use custom transitions to make your shared element transition beautiful.
- What a circular reveal animation is and how to use it to reveal tab content.
Now, it’s time to jump right in!
Getting started
Using Android Studio Arctic Fox or newer, open the starter project within 06-element-transitions in the aat-materials repository. Once the project syncs, build and run. You’ll see the login screen.
By the end of this chapter, you’ll have an app full of beautiful, meaningful screen animations!
Introduction to shared element transitions
Shared element transitions are a handy way to share a View between two different Fragments or Activitys. They make it seem as if a View is moving from one screen to another. These shared elements add a sense of continuity between screens.
They also help a user figure out where content went or how a new screen relates to the previous screen.
Even though shared element transitions make it seem like two screens are sharing a View, it’s important to note that each screen still has its independent copy of the “shared” View. The shared element animation is just UI trickery; under the hood, you’re still creating two normal layouts, each of which defines its Views internally.
Now that you understand what a shared element transition is, it’s time to learn about their components.
Anatomy of a shared element transition
Shared element transitions consist of two key components:
- The
Viewto be “shared”. If you’re navigating fromFragmentA to B, this will be aViewinFragmentA. - The transition name of the shared
View. The transition name is how you tie together the sharedViews. TheViewinFragmentA needs to have the same transition name as theViewinFragmentB for the shared element transition to work.
When building shared element transitions with Fragments, you use addSharedElement, a method on FragmentTransaction, to tell the framework to create the shared element transition. You typically call addSharedElement when building up your FragmentTransaction to swap out the currently displayed Fragment.
addSharedElement takes two parameters that map to the two key components outlined above:
-
sharedElement: The
Viewthat the two screens share. -
name: The transition name you define for the shared
View, both in the currentFragmentand theFragmentit will navigate to.
Now that you understand the basics of a shared element transition, it’s time to get your hands dirty and start sharing some views!
Sharing the logo TextView
In the previous chapter, you created a snazzy animation using the Transition framework that showed the Cinematic logo sliding up on the AuthFragment and then back down on the SignupFragment and LoginFragment.
That transition looks pretty good — but it would look even better if the logo used a shared element transition.
You’ll build a shared element transition to share the Cinematic logo TextView between AuthFragment and SignupFragment/LoginFragment. Here’s how the desired animation should look:
The next step in your shared element journey is to add a transition name to the logo TextView.
Defining a transition name
As a recap, the transition name is a property on a View that the system uses to match up your shared Views. It tells the framework that the two Views are linked and should run a shared element transition between them.
You’ll define a transition name on the three logo TextViews across the AuthFragment, LoginFragment and SignupFragment screens.
Start by opening fragment_auth.xml. Find the logo TextView and add the following tag to its body:
android:transitionName="logo_transition_name"
Here, you set a transition name of logo_transition_name on the TextView.
Now, you need to do the same for both the fragment_signup and fragment_login files, to give them all the same transition name.
Open fragment_signup and add the transition name to logo TextView:
android:transitionName="logo_transition_name"
Next, open fragment_login and add the same, again to the logo TextView:
android:transitionName="logo_transition_name"
Be sure to set the transition name on the TextView with the logo ID in each of the files. Otherwise, your shared element transition won’t run!
Note: Setting up a transition name is pretty easy when you’re dealing with static layouts like this. However, sometimes you want to run a shared element transition with a more dynamic
View— for example, on an item in aRecyclerView. Transition names need to be unique between the elements of the sharedViews. That means, when you’re using something like aRecyclerView, you can’t just set the transition name in XML because each item in theRecyclerViewwould have the same transition name; the framework wouldn’t know whichViews are being shared. In that scenario, you can programmatically set thetransitionNameto give each item in theRecyclerViewhas a unique transition name.
Now that you’ve defined your transition name, it’s time to put the shared element transition to work!
Triggering the shared element transition
In Cinematic, AuthActivity manages the Fragments in the authorization flow. All the Fragments in the authorization flow share the AuthViewModel with AuthActivity. When the user taps a button that should cause AuthActivity to change the current Fragment, the Fragment that’s currently displaying will call a method on AuthViewModel. Calling that method will trigger one of the LiveData objects to either change the Fragment or navigate to the main screen.
Open AuthActivity.kt and look at the bottom of onCreate. AuthActivity observes two LiveData objects to figure out when to transition between different fragments:
viewModel.showLogin.observe(this) {
showLogin()
}
viewModel.showSignUp.observe(this) {
showSignup()
}
showLogin and showSignup do the actual Fragment manipulation. That’s where you’ll need to add the call to addSharedElement to trigger the shared element transition.
Add the following code in the body of showLogin, before the call to addToBackStack:
// 1
val sharedView = findViewById<View>(R.id.logo)
// 2
addSharedElement(sharedView, sharedView.transitionName)
The code above does two things:
- It gets a reference to the shared logo
View. Since theFragments are housed inside theActivity, you can usefindViewByIdto find the view in theFragments layout. - It then triggers the actual call to
addSharedElement, using the shared view and its transition name (which you defined earlier).
Now, add that same code in showSignup so the shared element transition runs when the user navigates to both the login and sign-up screens:
val sharedView = findViewById<View>(R.id.logo)
addSharedElement(sharedView, sharedView.transitionName)
Finally, build and run the app. You’ll see… just about nothing. Maybe a flickering Cinematic logo, if you’re lucky.
What gives? It turns out you need to give the framework a few more hints about how to animate your logos.
Types of shared element transitions
You’ve set up your shared element transition perfectly, but you still need to tell the framework how exactly you want the animation to run. For example, should the size of the logo View on the auth Fragment screen grow to be the same size as the logo view on the sign-up Fragment screen? Should the logo View rotate as it moves to its final position? There are lots of questions to answer here!
To specify a Transition to run for the shared element transition, you’ll use sharedElementEnterTransition on Fragment. Here, you can use all the Transitions you learned about in the previous chapter — but they won’t do much.
Normally, these transitions work off of changes to a View‘s visibility, but shared element transitions are a bit strange; one View isn’t losing visibility as another gains visibility. The shared View is always visible, meaning you need a different set of Transitions to affect your shared element transition.
Fortunately, the Jetpack Transition library comes with several built-in Transitions that work specifically with shared element transitions:
-
ChangeBounds: Animates between the layout boundaries of the two
Views. It’s so handy that you’ll almost always want to reach for it because the size and/or position usually change between the two sharedViews. -
ChangeClipBounds: Animates between different clip boundaries for your
View. This is particularly helpful when you’re doing a shared element transition with images. -
ChangeImageTransform: Animates between different
ImageViewmatrices. It’s critical to use this transition when doing a shared element transition with images. -
ChangeScroll: Animates between different scroll positions for scrollable
Views. -
ChangeTransform: Animates between different rotations and scales for your
Views. It will also do some magic around reparenting aViewto make the animation smoother.
Now that you know the different types of Transitions you can use with your shared element transition, it’s time to jump in and start customizing your transition.
Customizing the shared element transition
For now, you’ll focus on SignupFragment. Later, you’ll port the work you do to the LoginFragment to cover all your bases.
To start, you need to figure out which Transitions you want to use. The size of the logo TextView changes, so you’ll need to use ChangeBounds. You’ll add that transition now.
Open SignupFragment.kt. Add the following below the call that defines returnTransition in onCreate.:
sharedElementEnterTransition = ChangeBounds()
Build and run. You’ll see an animation that looks something like this:
That’s… interesting. It looks like the logo TextView fades in and out while sliding in from the side.
A couple of things are going wrong here:
- You’ve included the logo
TextViewin theMaterialSharedAxistransition the rest of the page is using, causing it to fade in and out. - While the size and bounds of the logo
TextVieware animating, the actual text size isn’t, making the resulting animation look wrong.
To fix the first issue, you’ll use ChangeTransform. ChangeTransform’s magic reparenting logic will stop the MaterialSharedAxis transition from including the logo TextView.
Don’t worry if you’re confused — shared element transitions are brittle, and the Transitions that operate on them, like ChangeTransform, do many different things. No one has ever accused Google of following the single responsibility principle!
Next, you’ll learn how to use ChangeTransform to fix the logo fading in and out.
Fixing the fading issue with ChangeTransform
For ChangeTransform to work, you need to use a TransitionSet on your sharedElementEnterTransition. Replace the existing sharedElementEnterTransition with the following:
// 1
val set = TransitionSet()
// 2
val changeBounds = ChangeBounds()
set.addTransition(changeBounds)
val changeTransform = ChangeTransform()
set.addTransition(changeTransform)
// 3
sharedElementEnterTransition = set
Here’s a breakdown of the code:
- You define a new
TransitionSetto hold your transitions. - You then define a new instance of
ChangeBoundsandChangeTransformand add them to the newTransitionSet. - Last but not least, you assign
setas yoursharedElementEnterTransition.
Adding ChangeTransform to the mix should pull your logo TextView out of the MaterialSharedAxisTransition animation and resolve the fading issue.
Build and run. You’ll see… the same animation.
What gives? Why isn’t ChangeTransform performing its magic to stop the MaterialSharedAxisTransition enter animation from including the logo TextView?
It turns out that ChangeTransform will only perform its reparenting magic if it detects that there’s a rotation or scale change between the shared Views. Ideally, you’d have a Transition dedicated to reparenting shared Views. But we’re not building ideal apps; we’re building Android apps! And as always when building Android apps, there’s a… quirky workaround you can employ to fix the issue.
Adding an invisible scale to the shared View
To fix the problem, you’ll set a scale on one of the shared Views so there is a scale or rotation change, forcing ChangeTransform to perform the reparenting magic.
This quirky API is no match for a little creativity!
Open fragment_auth.xml, then add the following to the logo TextView:
android:scaleY="0.99"
Here, you set a scaleY value of 0.99. That should be small enough that no user will notice it, while still triggering the reparenting logic.
Build and run. You’ll see the following animation:
All right, you stopped the fading! Good job.
The only thing left is to have the text size animate nicely. You’ll then have a full-featured shared element transition!
Creating a custom text size transition
There’s good news and bad news. The bad news is that Android doesn’t come with a built-in Transition to animate text size. The good news is — you can just make one yourself!
At its core, a Transition is quite simple. The abstract Transition class exposes a core API that you tap into to create your animations. It allows you to capture start and end values for the View you’re animating via the captureStartValues and captureEndValues. You then create an Animator to animate the changes between those values in the createAnimator method. Pretty easy, right?
The starter project comes with a mostly empty shell where you’ll create a fancy custom TextSizeTransition object. Open TextSizeTransition.kt and look around. There are empty stubs for captureStartValues, captureEndValues, and createAnimator. There’s also a companion object with a constant textSizeProp. You’ll use that in a moment.
You’ll start by filling in captureStartValues.
captureStartValues takes one parameter: a TransitionValues object. TransitionValues is just a container for a View and a HashMap of properties. You’ll store the details you care about in this object.
Speaking of details you care about, the only thing that’s important here is the textSize of the logo TextView. You’ll capture that now.
Add the following to the body of captureStartValues:
(transitionValues.view as? TextView)?.let { textView ->
transitionValues.values[textSizeProp] = textView.textSize
}
The code above is pretty simple. It assumes you’re operating on a TextView because using a TextSizeTransition on a different type of View doesn’t make sense. It then accesses the internal Map of transitionValues and saves the Views textSize in that map, using the constant mentioned earlier as the key.
Good job, you’re now saving the start values of the logo TextView. Next, you need to save the final values of the TextView in SignupFragment.
It turns out the captureEndValues code looks exactly like the captureStartValues code. To be a good programming citizen, you’ll create a reusable method that you can use in both captureStartValues and captureEndValues. Add the following method below captureEndValues:
private fun captureTextSize(transitionValues: TransitionValues) {
(transitionValues.view as? TextView)?.let { textView ->
transitionValues.values[TextSizeTransition.textSizeProp] = textView.textSize
}
}
Now, replace the existing body of captureStartValues with the following:
captureTextSize(transitionValues)
And finish by adding the same to the body of captureEndValues:
captureTextSize(transitionValues)
Now you’re calling the newly created method in both places to avoid duplicate code.
All that’s left is to write the actual animator code!
Building a text size Animator
Now that you’ve populated the start and end transition values, writing the actual Animator will be a piece of cake. All you need to do is use a ValueAnimator and animate between the two values!
Start by replacing the body of createAnimator with the following:
if (startValues == null || endValues == null) {
return null
}
createAnimator provides the start and end values as parameters. If either is null, there’s no animation to run, so simply return.
Next, you need to get the start and end values from TransitionValues to prepare for the actual Animator code. Add the following :
val startSize = startValues.values[textSizeProp] as Float
val endSize = endValues.values[textSizeProp] as Float
val view = endValues.view as TextView
Here, you pull out the start and end text sizes and declare your View. Since you know that this Transition only works with TextViews, you can just cast the View held in endValues to a TextView.
Now, it’s time to return the actual animator! You’ll use a ValueAnimator to animate between the start and end text sizes. Add the following below the declarations you just added:
return ValueAnimator.ofFloat(startSize, endSize).apply {
addUpdateListener {
view.setTextSize(TypedValue.COMPLEX_UNIT_PX, it.animatedValue as Float)
}
}
Here, you use a basic Float ValueAnimator. In the updateListener, you set the text sizes of your TextView.
And you’re done! You only have a few more steps to wrap up your spiffy new animation.
Wrapping up the logo shared element transition
Now that you’ve created a full-fledged text size transition, it’s time to see it in action.
Back in SignupFragment.kt, add TextSizeTransition to the TransitionSet right below the ChangeTransform addition:
val textSize = TextSizeTransition()
set.addTransition(textSize)
Build and run. You’ll now see a beautiful shared element transition, where the root authorization screen and the sign-up screen share the logo.
Next, you’ll learn about circular reveal animations and how to use them to reveal different screens after clicking a tab!
Revealing a tab with a circular reveal
One of the coolest animations you can trigger in Android is a circular reveal animation. It’s an easy way to show or hide a View with a circular clipping motion, adding some pizzazz to your app.
In this section, you’ll learn how to use a circular reveal animation to reveal content on the main screen.
Once you’re finished, tapping the Popular tab will reveal PopularMoviesFragment with a circular reveal animation that starts from the bottom-left corner of the screen, closest to the popular tab icon. Tapping the Favorites icon will also show a circular reveal animation starting from the bottom-right corner of the screen.
Now, it’s time to learn how to construct a circular reveal animation.
Anatomy of a circular reveal
Android exposes a super convenient method, ViewAnimationUtils.createCircularReveal, to create the Animator that does the heavy lifting.
createCircularReveal can seem a little complicated at first, but once you break it down, it’s pretty simple. It takes the following parameters:
-
view: The view to show or hide.
-
centerX: The center of the clipping circle’s X coordinate — in other words, the X portion of the position that the circle should expand or contract from.
-
centerY: The center of the clipping circle’s Y coordinate.
-
startRadius: The beginning radius of the clipping circle. If you try to reveal a view, the start radius would be zero because the circle is emanating out from it. When hiding a view, the start radius should be the full radius of the circle that holds the view.
-
finalRadius: The ending radius of the clipping circle. If you’re revealing a view, that would be the radius of the circle that holds the view. If you’re hiding a view, this would be zero.
Don’t worry if it seems a bit mathy. The math involved isn’t too intense.
Now that you know the theory behind circular reveal animations, it’s time to begin building the tab animation! But before you get your hands dirty, take a moment to understand when the animations should run.
Determining when tab animations should run
As mentioned earlier, you want to build a circular reveal that reveals PopularMoviesFragment and FavoriteMoviesFragment. In contrast to earlier chapters, you won’t use the transition framework or even Fragment or Activity animations. Instead, you’ll trigger the circular reveal from within the Fragment at the right time.
That time is when the user taps the favorite or popular tab icons. You determined that moment in MainActivity. If you look in MainActivity.kt, you’ll see some logic in the body of navController.addOnDestinationChangedListener that triggers when the animation should appear:
val shouldTriggerFavoriteAnimation = lastBackstackEntry == R.id.popularMoviesFragment &&
destination.id == R.id.favoriteMoviesFragment
val shouldTriggerPopularAnimation = lastBackstackEntry == R.id.favoriteMoviesFragment &&
destination.id == R.id.popularMoviesFragment
viewModel.animateFavoriteEntranceLiveData.value = shouldTriggerFavoriteAnimation
viewModel.animatePopularEntranceLiveData.value = shouldTriggerPopularAnimation
This code inspects the last-seen destination against the new destination to figure out if an animation should trigger. Moving from the popular movies screen to the favorite movies screen should trigger the favorite movies circular reveal. The opposite holds for the popular movies screen.
The code then sets a value on MutableLiveData in AnimationViewModel. The PopularMoviesFragment and FavoriteMoviesFragment screens then observe that ViewModel.
Open PopularMoviesFragment.kt. In attachObservers, there’s a block dedicated to handling the enter animation:
animationViewModel.animatePopularEntranceLiveData.observe(viewLifecycleOwner) { shouldAnimate ->
if (shouldAnimate) {
animateContentIn()
}
}
This block checks the Boolean value of the LiveData object and triggers animateContentIn when it should animate. You’ll find the same structure in FavoriteMoviesFragment. Right now, animateContentIn is empty, but you’ll change that shortly.
Now it’s time to execute the animation!
Executing the circular reveal
Navigate to animateContentIn in PopularMoviesFragment. This is where you’ll add the actual circular reveal code. You’ll start by adding a doOnPreDraw block.
Replace the content of the method with the following:
binding.root.doOnPreDraw {
}
doOnPreDraw will execute an action exactly once, right before drawing the View. It’s a handy way to ensure that the View is ready to be drawn before you execute an animation. You call it on the root of the layout binding because this animation should run on the whole layout.
Next, you’ll declare some variables to use in the circular reveal. Add the following in the body of the doOnPreDraw block:
// 1
val view = binding.root
// 2
val centerX = 0
// 3
val centerY = view.height
In the code above, you:
- Get a shorter reference to the
Viewyou’re going to animate, which is the rootViewof the layout. - Declare the X coordinate of the center point of the clipping circle. This is the popular movies screen, so you want the circle to start from the left side of the screen, close to the popular icon. Therefore, you set the value to
0. - Declare the Y coordinate of the center point of the clipping circle. The circle should emanate out from the bottom-left of the screen, so the Y coordinate should be the full height of the
View— that is, at the bottom of the screen.
Next, you need to figure out what the final radius of the clipping circle should be. Add the following code after the previous declarations:
val finalRadius = hypot(view.width.toDouble(), view.height.toDouble())
Math alert! So the clipping circle should expand to fully reveal the entire View of the Fragment. You need to figure out what the final radius of that circle should be.
The circle will expand from the bottom-left until it fills the screen. That means the actual circle would theoretically expand to the left and bottom, as well as to the top and right. If you imagine that full circle, the radius of it once it expands fully is the horizontal line from the bottom-left to the top-right of the screen. That line is the hypotenuse of the triangle forming the right and bottom edges of the screen, and that’s where the above code comes from!
Here’s a diagram to help outline the concept:
Now that all the variables are set up, it’s time to create the Animator. Add the following after the finalRadius declaration:
val anim = ViewAnimationUtils.createCircularReveal(view, centerX, centerY, 0f, finalRadius.toFloat())
Here, you use the values you defined earlier, as well as a starting radius of 0, to create the circular reveal Animator. Nice!
The default speed of the reveal is pretty quick, but since it’s an Animator, you can set the duration yourself. Add the following:
anim.duration = 600
600 milliseconds looks pretty good.
Last but not least, you need to start the animation! Add one last call:
anim.start()
Build and run. You’ll see a beautiful circular reveal when you tap from the favorites tab to the popular tab.
Creating the circular reveal for the favorite movies screen
All that’s left now is to add very similar code to FavoriteMoviesFragment. Open FavoriteMoviesFragment.kt and replace the body of animateContentIn with the following:
binding.root.doOnPreDraw {
val view = binding.root
val centerX = view.width
val centerY = view.height
val finalRadius = hypot(view.width.toDouble(), view.height.toDouble())
val anim = ViewAnimationUtils.createCircularReveal(view, centerX, centerY, 0f, finalRadius.toFloat())
anim.duration = 600
anim.start()
}
The only difference between the code above and the code you wrote previously is that you set the centerX value to the width of the View so the circular animation emanates out from the bottom right of the view instead of the bottom left.
After making sure you’ve marked a few movies as favorites, build and run. You’ll see beautiful circular reveals for both tabs now:
Congratulations, you’ve made it to the end of the screen animation section. Well done! Hopefully, you found this section enjoyable and full of great information. Along the way, you built something truly special — the Cinematic app looks beautiful and elegant, with screen animations that are not only good-looking, but also unique.
Key points
- Shared element transitions are a wonderful way of transitioning between screens. They improve continuity and add meaningful motion.
- To use a shared element transition, you need to define the same transition name for the
Views in bothFragments. - Use setSharedElementTransition to set your shared element transition when changing out
Fragments. - Use sharedElementEnterTransition to customize your shared element transition’s actual animation.
- Use ChangeTransform to fix issues where a shared element is caught in another transition.
- Set a fake scale value if
ChangeTransformisn’t executing its reparenting magic. - Define custom transitions to do things like animate text size.
- Create a circular reveal using
ViewAnimationUtils.createCircularReveal. - Use doOnPreDraw to execute animation code as soon as the
Viewis ready to be drawn. - Don’t be afraid of using math to figure out the properties of your animations.
In the next section, you’ll learn how to use list- and gesture-based animations to make your lists look equally fancy!