Exploring Conductor (Part 3)— TransitionChangeHandler
The first part of this series provides a demo of how to use Conductor in Android apps. The second one goes into details on how to create a custom AnimatorChangeHandler to animate Controller view changes when one is replaced by another. In this last part of the series, I’m going to share with you an example of using TransitionChangeHandler for animations.
If you haven’t gone through the previous parts of this series, I strongly encouraged you to do so since the discussion in this post is based on the previous ones.
TransitionChangeHandler
TransitionChangeHandler
offers a way to perform view change animations using the Transition
framework. Because of that, it is only available to use when you app is running on device with API 21+. Relying on the built-in Transitions
(e.g., ChangeTransform, ChangeBounds), TransitionChangeHandler
allows you to create animations which would be complicated to build with AnimatorChangeHandler
such as shared element transition. Below is what we are going to build in this post.
As you can see, a part from the shared element animation on the flag ImageView
, the other 2 animations (sliding up the country details and scaling up the favourite FAB
) look the same as what we had in part 2 with AnimatorChangeHandler
. However, this time we will implement them in a completely different way which relies entirely on the Transition
framework.
The Push Change Handler
We start with creating a custom change handler for pushing the Country Details View in. To do this, we subclass TransitionChangeHandler
. An implementation of the getTransition
function is required to create a Transition
instance to perform the animation.
@Override
protected Transition getTransition(@NonNull ViewGroup container,
@Nullable View from,
@Nullable View to,
boolean isPush)
Given we have 3 animations to perform with 3 different view elements (flag, country details group, and FAB) as described, let’s see how we can implement each of them.
Flag ImageView’s shared element transition
To perform a shared view element animation from one screen to another, we need to find a way to “mark” a view element so that we know it is an element which is shared across the screens. Fortunately, we have a view attribute called transitionName
(which was introduced since API level 21) for that purpose. This means if each country flag in the Country Grid View has a transition name, we can assign that name to the corresponding flag view in the Country Detail View. We also need to ensure no two country flags in the Country Grid View has the same transition name, otherwise our desired shared element animation would not be possible. However, the questions here are (1) how do we assign unique names to country flags in the country grid view?, and (2) how and when do we assign the same transition name to the corresponding country flag view in the country details view?
To address (1), we take advantage of the fact that country names must be unique (yes we’re lucky here!). So, within the bindData
method of CountryViewHolder (see the source code for the complete method implementation), we assign the corresponding country name as the flag view’s transition name:
void bindData(@NonNull Country country) {
...
// Set transition name for flag view to enable transition
animation.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
flagView.setTransitionName(country.getName());
}
}
Since the TransitionChangeHander.getTransition
function has to
View (which is exactly the country details View in this case) as one of its parameters, it is the nice place to assign a transition name to the flag ImageView in the Country Details View (2). But how do we get the country name from? It needs to be passed into our custom TransitionChangeHandler
via its constructor. The code would look as simple as this:
DetailPushTransChangeHandler.java...
private String flagViewTransitionName;
public DetailPushTransChangeHandler(String flagViewTransitionName) {
this.flagViewTransitionName = flagViewTransitionName;
}
...
However, every subclass of ControllerChangeHandler
(include TransitionChangeHandler
and AnimatorChangeHandler
) has to have a public default constructor (a constructor without parameter). That is to allow change handlers to be recreated whenever its associated controller is restored after the app’s process is terminated by Android system. To enable the restoration, ControllerChangeHandler
has a pair of methods: saveToBundle(Bundle)
to save the change handler’s state into a bundle (which will then be part of the associated controller’s state bundle) when the controller is destroyed by Android system, and restoreFromBundle(Bundle)
to restore its state when the controller is recreated. However, at least in our case, such restoration should only be needed for the pop handler which handles the change from the Country Details View back to the Country Grid View. The push handler does not need to be restored since a new instance of it is created every time a change from Country Grid View to Country Details View happens. As a result, we only need to add the public default constructor for the push
Handler:
...
public DetailPushTransChangeHandler() {}
...
We can then start the getTransition
function implementation as follows.
protected Transition getTransition(@NonNull ViewGroup container,
@Nullable View from,
@Nullable View to,
boolean isPush) {
if (to == null || !(to instanceof CountryDetailView)) {
throw new IllegalArgumentException("The to view must be a
CountryDetailView");
}
CountryDetailView detailView = (CountryDetailView) to;
detailView.flagView.setTransitionName(flagViewTransitionName);
...
}
To perform the shared element animation which involves changing size of and moving the flag ImageView
, we use the combination of 4 transitions: ChangeBounds
, ChangeClipBounds
, ChangeTransform
, and ChangeImageTransform
. In out Country Details View, the favourite FAB
is positioned between the FAB
and the country details (as shown in the above figure). However, by default a ChangeTransform
draws its transitioned view in the current window’s view overlay. This makes the flag ImageView
drawn over the FAB when the transition is completed — which is obviously not what we want. We therefore set the ChangeTransform
transition not to draw on the overlay and thus the flag’s transition is implemented as follows.
...
ChangeTransform changeTransform = new ChangeTransform();
changeTransform.setReparentWithOverlay(false);Transition flagTransition = new TransitionSet()
.addTransition(new ChangeBounds())
.addTransition(new ChangeClipBounds())
.addTransition(changeTransform)
.addTransition(new ChangeImageTransform())
.setDuration(300);
...
Note #1: Setting the ChangeTransform
transition not to draw on the overlay works nicely in this specific case (and only in the portrait mode). It however might not be a good solution for other cases and thus need to always be used with care. We’ll come back to this point at a later point in this post.
Note #2: By default, transitions added to a TransitionSet is executed together concurrently, so we do not need to explicitly call TransitionSet.setOrdering(TransitionSet.ORDERING_TOGETHER)
in the above code.
Country details group’s transition
As discussed, we want the country details group to begin sliding up after the shared element transition on the flag ImageView starts but before it stops. We therefore play the transition for country details together with the one for the flag but with some delay before it actually starts. Thanks to the pre-made Slide
transition, the code looks as simple as below.
...
Transition detailsTransition =
new Slide().addTarget(detailView.detailGroup).setStartDelay(150);
...
Favourite FAB’s transition
We want to play a scale up animation on the favourite FAB as soon as the flag transition completes. Unfortunately, the Transition
framework does not provide a pre-made Scale transition. We therefore need to create one, which is not complicated:
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public class Scale extends Visibility {
public Scale() {}
public Scale(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public Animator onAppear(ViewGroup sceneRoot,
View view,
TransitionValues startValues,
TransitionValues endValues) {
return getAnimator(view, 0, 1);
}
@Override
public Animator onDisappear(ViewGroup sceneRoot,
View view,
TransitionValues startValues,
TransitionValues endValues) {
return getAnimator(view, 1, 0);
}
private Animator getAnimator(View view,
float startValue,
float endValue) {
view.setScaleX(startValue);
view.setScaleY(startValue);
PropertyValuesHolder scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, startValue, endValue);
PropertyValuesHolder scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, startValue, endValue);
return ObjectAnimator.ofPropertyValuesHolder(view, scaleX, scaleY);
}
}
Since the Scale
transition extends Visibility
, it inherits the ability of detecting when a view is appearing or disappearing. Therefore, what this Scale
transition does is as simple as creating an Animator
to scale up the view (which is the favourite FAB in our specific case) from zero to 1 when it appears and scale it down from 1 back to zero when it disappears.
Since we want the FAB transition to start as soon as the flag transition completes, setting its startDelay
value again is the way to go (we alternatively can use TransitionSet.ORDERING_SEQUENCE
to play the transitions linearly. However that would make the code ugly here).
...
Transition fabTransition =
new Scale().addTarget(detailView.favouriteFab).setStartDelay(300);
...
We finally can combine all three transitions and return the composite one as the result of the getTransition
function. The FastOutSlowInInterpolator
is used to make the whole animation looks nicer IMO.
...
return new TransitionSet()
.addTransition(flagTransition)
.addTransition(detailsTransition)
.addTransition(fabTransition)
.setInterpolator(new FastOutSlowInInterpolator());
That’s it. We’ve now have a nice (hopefully) push change handler.
The Pop Change Handler
Using the same strategy, the pop
change handler can be implemented as follows. This time we need to handle the save and restore of the transition name saved to and restored from the Bundle
.
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public class DetailPopTransChangeHandler extends
TransitionChangeHandler {
private static final String KEY_FLAG_TRANSITION_NAME =
"key_flag_transition_name";
private String flagViewTransitionName;
public DetailPopTransChangeHandler() {}
public DetailPopTransChangeHandler(String transitionName) {
this.flagViewTransitionName = transitionName;
}
@Override
public void saveToBundle(@NonNull Bundle bundle) {
bundle.putString(KEY_FLAG_TRANSITION_NAME,
flagViewTransitionName);
}
@Override
public void restoreFromBundle(@NonNull Bundle bundle) {
flagViewTransitionName =
bundle.getString(KEY_FLAG_TRANSITION_NAME);
}
@NonNull
@Override
protected Transition getTransition(@NonNull ViewGroup container,
@Nullable View from,
@Nullable View to,
boolean isPush) {
if (from == null || !(from instanceof CountryDetailView)) {
throw new IllegalArgumentException("The from view must
be a CountryDetailView");
} CountryDetailView detailView = (CountryDetailView) from;
detailView.flagView.setTransitionName(flagViewTransitionName);
return new TransitionSet()
.addTransition(new TransitionSet()
.addTransition(new ChangeBounds())
.addTransition(new ChangeClipBounds())
.addTransition(new ChangeTransform())
.addTransition(new ChangeImageTransform()))
.addTransition(new Slide()
.addTarget(detailView.detailGroup))
.addTransition(newScale()
.addTarget(detailView.favouriteFab));
}
}
Discussions
- For demo purposes, I split the push and pop change handlers into 2 separate classes to make things clear. You may choose to combine them into just one class and use the
isPush
parameter passed in thegetTransition
function to decide whichTransition
to return. - The demo in this post is simple in a sense that the flag ImageViews are all laid out and have transition names set before the transitions start. In case the positions and transition names of views depends on some certain asynchronously loaded data, you would need to postpone the transition until everything is ready.
TransitionChangeHandler
offers a methodprepareForTransition
that could be very helpful. It is executed before the transition occurs and thus is a perfect place to create listeners to detect when the relevant views are ready and when transition names can be set. It is also a place to add code to defer the transition until certain conditions are satisfied. A good example of creating a delayed transition can be found here. - Landscape mode: I’ve used the same change handlers for both portrait and landscape mode. However, with the current design of the Country Details View in landscape, the transitions do not work nicely. Since I’ve set the ChangeTransform transition (on the flag ImageView) to not draw views on the Window’s overlay, there’s chance that the flag is moving “underneath” the country details group if their movements are crossing each other. I would leave this problem to interested readers to address. It might involve redesigning the transitions for landscape view. It might also involve redesigning the Country Details View in landscape mode as well (which I don’t think nice enough since I just quickly created the views for demo purposes).
This post completes my series on Conductor. For anyone who do not want to deal with Fragments and want to try a view-based approach (like me), I believe Conductor is a nice choice for the following reasons:
- Simple and easy to understand lifecycle
- States survive orientation changes
- Easy and flexible to add change handlers (
AnimatorChangeHandler
andTransitionChangeHandler
) - Fully compatible with whatever design patterns you use (e.g., MVP, MVVM,…)
I hope you enjoy playing with Conductor’s approach of building Android application. Please click to share the series if you like it. All questions/feedback/discussions are appreciated and welcome :)
This post is part of a blog series on Conductor, the table of content as follows: