AndroidPub
Published in

AndroidPub

Exploring Conductor (Part 2)— AnimatorChangeHandler

In the first part of this series, I’ve shown you an example of using Conductor in an Android app. We’ve seen how Router, Controller and RouterTransaction are created and work together. We also know that ControllerChangeHandler is used to provide animation whenever a Controller is popped out of a back stack and another Controller is pushed in. However, it was not covered in the first part.

In this post I’m going to share with you an example of how to create custom ControllerChangeHandlers to provide your desired animation.

Below is what we should have when we’re done.

Prerequisites

This post continues a discussion from the previous post in this series. So I assume that you have read it. I also assume that you have some background of animation in Android.

AnimatorChangeHandler vs. TransitionChangeHandler

Conductor offers 2 abstract subclasses of ControllerChangeHandler which provide different ways to implement change animation. AnimatorChangeHandler relies on android.animation.Animator to animate view changes. TransitionChangeHandler, on the other hand, facilitates the use of android.transition.Transition (similar to Activity/Fragment transitions). While AnimatorChangeHandler is available for all API levels that Conductor supports (16+), TransitionChangeHandler can only be used when your app is running on a Lollipop+ (21+) device. Therefore, a fallback change handler needs to be declared if your app targets APIs lower than 21.

This post explores the use of AnimatorChangeHandler.

Create a custom AnimatorChangeHandler

You only need to subclass the abstract class AnimatorChangeHandler. There are 2 compulsory functions that any of its subclass has to implement:

protected Animator getAnimator(@NonNull ViewGroup container, 
@Nullable View from,
@Nullable View to,
boolean isPush,
boolean toAddedToContainer)

which creates an Animator object that is used to animate the view change, and:

protected void resetFromView(@NonNull View from)

which is to reset the from View (the View being replaced) back to its pre-animation state (if any of its properties was changed during the animation) after the animation is completed. This function is useful for cases when the View of a Controller is retained after the change (i.e., by calling Controller.setRetainViewMode(RetainViewMode.RETAIN_DETACH) on your Controller). Implementing this function is not necessary (i.e., leave it blank) if your View is torn down after the Controller change.

Create the Animators

The push animation we want in this case looks like this: when a country is clicked in the grid View, the entire grid View is faded out, and the country flag slides from the top while the country details slide from the bottom. As soon as those 2 components meet each other, the favourite FAB is scaled up. So, we have to be able to access these 3 components from the View and animate each of them. The pop animation looks nearly the reversed version of the push one except that all 3 components are all animated at the same time.

Now we look at what are available from the parameters of the getAnimator function which we need to provide a concrete implementation for:

  • container is the ViewGroup that we push Controller’s View to (or pop Controller’s View from)
  • from is the View being removed from the container
  • to is the View being pushed into the container
  • isPush is an indicator that the Animator is for a push (true) or pop (false) animation
  • toAddedToContainer is an indicator that the to view is going to be added to the container as a part of this Controller change (true) or it already exists in the hierarchy (false).

With regards to the animations we are going to build, what’s important to us are the from, to, and isPush parameters. isPush is extremely useful if we were to create a single custom AnimatorChangeHandler to use for both push and pop animations since it helps us determine the direction of the controller change to create an appropriate Animator. In this example, to make things as clear as possible, we’ll create 2 custom AnimatorChangeHandlers, one for push and one for pop (call them DetailPushAnimChangeHandler and DetailPopAnimChangeHandler respectively). Therefore, it is only from and to Views that we should make use of.

DetailPushAnimChangeHandler

Since the animation we are building is very specific to the country detail screen, we start off the implementation with a check to ensure the from View is a CountryDetailView:

protected Animator getAnimator(@NonNull ViewGroup container,
@Nullable View from,
@Nullable View to,
boolean isPush,
boolean toAddedToContainer) {

// Make sure the to view is a CountryDetailView
if (to == null || !(to instanceof CountryDetailView))
throw new IllegalArgumentException("The to view must be a
CountryDetailView");
...
}

A Controller change starts with the to View inflated and added into the container before the Animator is applied. Therefore, we have to hide the favourite FAB before the animation begins because the FAB should only be visible after the flag and details’ animations are completed. Since we later will use scale animations for showing the FAB, we can set its scaleX and scaleY properties to zero at the start.

...
CountryDetailView detailView = (CountryDetailView)to;

// Set the button scale to 0 to make it invisible at the beginning.
detailView.favouriteFab.setScaleX(0);
detailView.favouriteFab.setScaleY(0);
...

There should be two sets of Animators running sequentially. The first set deals with fading out the grid View and sliding the flag and country details Views. The second one is for scaling up the FAB button. We create the first set of Animator as follows:

...
AnimatorSet flagAndDetailAnim = new AnimatorSet();

// Hide the old view
Animator hideFromViewAnim = ObjectAnimator.ofFloat(from, View.ALPHA,
1, 0);

// Slide down the flag
Animator flagAnim =
ObjectAnimator.ofFloat(detailView.flagView,
View.TRANSLATION_Y,
-detailView.flagView.getHeight(),
0);

// Slide up the details
Animator detailAnim =
ObjectAnimator.ofFloat(detailView.detailGroup,
View.TRANSLATION_Y,
detailView.detailGroup.getHeight(),
0);
// Set to play the above animators at the same time
flagAndDetailAnim.playTogether(hideFromViewAnim,
flagAnim,
detailAnim);
flagAndDetailAnim.setDuration(300);
flagAndDetailAnim.setInterpolator(new FastOutSlowInInterpolator());
...

We now need an Animator to scale up the FAB:

...
// Scale up the favourite fab
PropertyValuesHolder fabScaleX =
PropertyValuesHolder.ofFloat(View.SCALE_X, 0, 1);
PropertyValuesHolder fabScaleY =
PropertyValuesHolder.ofFloat(View.SCALE_Y, 0, 1);
Animator favouriteAnim =
ObjectAnimator.ofPropertyValuesHolder(detailView.favouriteFab,
fabScaleX,
fabScaleY)
.setDuration(200);
...

The last step is to tie all the Animators together in a single AnimatorSet:

...
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playSequentially(flagAndDetailAnim, favouriteAnim);

animatorSet.start();

return animatorSet;

The resetFromView function, although not important to implement in our case, can be written as follows. The alpha property of the from View is set back to 1 since it was animated to 0 during our change animation.

@Override
protected void resetFromView(@NonNull View from) {
from.setAlpha(1);
}

DetailPopAnimChangeHandler

The pop Animator needs to do these 4 things together concurrently: (1) scale down the favourite FAB, (2) slide up the flag, (3) slide down the country details, and (4) fade in the grid View. Follow the same style that we used for the push Animator, the pop Animator implementation could be as follows.

protected Animator getAnimator(@NonNull ViewGroup container,
@Nullable View from,
@Nullable View to,
boolean isPush,
boolean toAddedToContainer) {

// Make sure the from view is a CountryDetailView
if (from == null || !(from instanceof CountryDetailView))
throw new IllegalArgumentException("The from view must be a
CountryDetailView");

if (to == null)
throw new IllegalArgumentException("The to view must not be
null");

final CountryDetailView detailView = (CountryDetailView)from;

AnimatorSet animatorSet = new AnimatorSet();

// Set the to View's alpha to 0 to hide it at the beginning.
to.setAlpha(0);

// Scale down to hide the fab button
PropertyValuesHolder fabScaleX =
PropertyValuesHolder.ofFloat(View.SCALE_X, 0);

PropertyValuesHolder fabScaleY =
PropertyValuesHolder.ofFloat(View.SCALE_Y, 0);
Animator hideFabButtonAnimator =
ObjectAnimator.ofPropertyValuesHolder(detailView.favouriteFab,
fabScaleX,
fabScaleY);

// Slide up the flag
Animator flagAnimator =
ObjectAnimator.ofFloat(detailView.flagView,
View.TRANSLATION_Y,
0,
-detailView.flagView.getHeight());

// Slide down the details
Animator detailAnimator =
ObjectAnimator.ofFloat(detailView.detailGroup,
View.TRANSLATION_Y,
0,
detailView.detailGroup.getHeight());

// Show the new view
Animator showToViewAnimator =
ObjectAnimator.ofFloat(to, View.ALPHA, 0, 1);

animatorSet.playTogether(hideFabButtonAnimator,
flagAnimator,
detailAnimator,
showToViewAnimator);

animatorSet.setDuration(300);
animatorSet.setInterpolator(new FastOutLinearInInterpolator());

animatorSet.start();

return animatorSet;
}

We reset the from View as below.

protected void resetFromView(@NonNull View from) {
CountryDetailView detailView = (CountryDetailView) from;
detailView.favouriteFab.setScaleX(1);
detailView.favouriteFab.setScaleY(1);
detailView.flagView.setTranslationY(0);
detailView.detailGroup.setTranslationY(0);
}

Connect the custom Change Handlers to RouterTransaction

Our final step is to make use of the custom AnimatorChangeHandlers that we’ve created. We change the CountryGridController.onCountryClicked function to the following.

@Override
public void onCountryClicked(@NonNull Country country) {
getRouter().pushController(
RouterTransaction.with(new CountryDetailController(country))
.pushChangeHandler(new DetailPushAnimChangeHandler())
.popChangeHandler(new DetailPopAnimChangeHandler()));
}

That’s it. We’ve now had the controller change handlers up and running.

Landscape mode

I’ve made the layout of country details View in landscape mode a bit different from the portrait mode layout to avoid the flag covering the entire screen. I’m not quite happy with this design, but anyway this is just for demo purposes. The issue is, although the controller change handlers we created obviously work in landscape view, the animations are not quite meaningful here (i.e., the movement of the flag, the details, and FAB are not relevant to each other). So if you are keen, I encourage you to redesign the animations for landscape mode and create a landscape-specific custom ControllerChangeHandlers (and even better to redesign the layout).

Final words

In this part of the series, I’ve shared with you an example of how to create a custom AnimatorChangeHandler in Conductor. The source code of this example can be found here.

Thanks for reading and having fun with Conductor!

This post is part of a blog series on Conductor, the table of content as follows:

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store