Reveal Transition

Android Lollipop introduced a really great new Animator, the circular reveal animator. To use it, we simply provide the center point of the circle as well as its start and end radii:

int w = view.getWidth();
int h = view.getHeight();
float maxRadius = (float) Math.sqrt(w * w / 4 + h * h / 4);
Animator reveal = ViewAnimationUtils.createCircularReveal(view,
    w / 2, h / 2, 0, maxRadius);
reveal.start();

Now you get a cool reveal in which the view is exposed from the center of the View to the edge.

That’s fun, but many of us are using Transitions to automate this kind of reveal. Normally, we use the Fade Transition, but perhaps we want some of our views to come in with a reveal animation instead. That doesn’t sound too hard! But there are a a couple of tricks to watch out for.

First, let’s create the basic RevealTransition as a subclass of Visibility. This gives us all the niceness that Visibility does for us, especially keeping a View in the Overlay when it has been removed from the layout:

public class RevealTransition extends Visibility { 
    public RevealTransition() {
    }

    public RevealTransition(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public Animator onAppear(ViewGroup sceneRoot, final View view,
            TransitionValues startValues,
            TransitionValues endValues){
        float radius = calculateMaxRadius(view);
        final float originalAlpha = view.getAlpha();
        view.setAlpha(0f);

        Animator reveal = createAnimator(view, 0, radius);
        reveal.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationStart(Animator animation) {
                view.setAlpha(originalAlpha);
            }
        });
        return reveal;
    }

    @Override
    public Animator onDisappear(ViewGroup sceneRoot, View view,
            TransitionValues startValues,
            TransitionValues endValues) {
        float radius = calculateMaxRadius(view);
        return createAnimator(view, radius, 0);
    }

    private Animator createAnimator(View view, float startRadius,
            float endRadius) {
        int centerX = view.getWidth() / 2;
        int centerY = view.getHeight() / 2;

        Animator reveal = ViewAnimationUtils.createCircularReveal(
            view, centerX, centerY, startRadius, endRadius);
        return reveal;
    }

    static float calculateMaxRadius(View view) {
        float widthSquared = view.getWidth() * view.getWidth();
        float heightSquared = view.getHeight() * view.getHeight();
        float radius = (float) Math.sqrt(widthSquared +
            heightSquared) / 2;
        return radius;
    }
}

This is a great start at a RevealTransition. You’ll notice a few tricks. I’ve added two constructors. The nullary constructor helps you create a RevealTransition in code. The constructor taking a Context and an AttributeSet allow you to load the transition from XML:

<transition class="my.sample.RevealTransition"
  android:duration="500"
  android:transitionVisibilityMode="mode_out"/>

The second trick is that onAppear hides the view at the start. If you have the RevealTransition follow another transition, its initial state should be invisible. It can use Visibility, but alpha has fewer side-effects, such as focus changes. onDisappear does it for you. Hmmm… should have probably had onAppear do it for you also, eh?

That works great, but if your RevealTransition is interrupted, the animation will always start at the beginning. Sadly, there is no way to capture the current state in the RevealAnimator. It runs on the render thread, so anything you do would be at best a guess. I expect this to be fixed in a future release, but for now, you get a jump cut when you interrupt the transition.

If you play with this and try the transition interruption, you’ll see an OperationNotSupportedException when the transition tries to pause the animator. Yikes! We can fix this by wrapping the Animator:

private static class NoPauseAnimator extends Animator {
    private final Animator mAnimator;
    private final ArrayMap<AnimatorListener, AnimatorListener>
        mListeners = new ArrayMap<AnimatorListener,
            AnimatorListener>();

    public NoPauseAnimator(Animator animator) {
       mAnimator = animator;
    }

    @Override
    public void addListener(AnimatorListener listener) {
        AnimatorListener wrapper = new AnimatorListenerWrapper(this,
            listener);
        if (!mListeners.containsKey(listener)) {
            mListeners.put(listener, wrapper);
            mAnimator.addListener(wrapper);
        }
    }

    @Override
    public void cancel() {
       mAnimator.cancel();
    }

    @Override
    public void end() {
        mAnimator.end();
    }

    @Override
    public long getDuration() {
        return mAnimator.getDuration();
    }

    @Override
    public TimeInterpolator getInterpolator() {
       return mAnimator.getInterpolator();
    }

    @Override
    public ArrayList<AnimatorListener> getListeners() {
        return new ArrayList<AnimatorListener>(mListeners.keySet());
    }

    @Override
    public long getStartDelay() {
        return mAnimator.getStartDelay();
    }

    @Override
    public boolean isPaused() {
        return mAnimator.isPaused();
    }

    @Override
    public boolean isRunning() {
        return mAnimator.isRunning();
    }

    @Override
    public boolean isStarted() {
        return mAnimator.isStarted();
    }

    @Override
    public void removeAllListeners() {
        super.removeAllListeners();
        mListeners.clear();
        mAnimator.removeAllListeners();
    }

    @Override
    public void removeListener(AnimatorListener listener) {
        AnimatorListener wrapper = mListeners.get(listener);
        if (wrapper != null) {
            mListeners.remove(listener);
            mAnimator.removeListener(wrapper);
        }
    }

    /* We don't want to override pause or resume methods
     * because we don't want them to affect mAnimator.
    public void pause();
    public void resume();
    public void addPauseListener(AnimatorPauseListener listener);
    public void removePauseListener(AnimatorPauseListener listener);
     */

    @Override
    public Animator setDuration(long durationMS) {
        mAnimator.setDuration(durationMS);
        return this;
    }

    @Override
    public void setInterpolator(TimeInterpolator timeInterpolator) {
        mAnimator.setInterpolator(timeInterpolator);
    }

    @Override
    public void setStartDelay(long delayMS) {
        mAnimator.setStartDelay(delayMS);
    }

    @Override
    public void setTarget(Object target) {
        mAnimator.setTarget(target);
    }

    @Override
    public void setupEndValues() {
        mAnimator.setupEndValues();
    }

    @Override
    public void setupStartValues() {
        mAnimator.setupStartValues();
    }

    @Override
    public void start() {
        mAnimator.start();
    }
 }

 private static class AnimatorListenerWrapper
        implements Animator.AnimatorListener {
    private final Animator mAnimator;
    private final Animator.AnimatorListener mListener;

    public AnimatorListenerWrapper(Animator animator,
            Animator.AnimatorListener listener) {
        mAnimator = animator;
        mListener = listener;
    }

    @Override
    public void onAnimationStart(Animator animator) {
        mListener.onAnimationStart(mAnimator);
    }

    @Override
    public void onAnimationEnd(Animator animator) {
        mListener.onAnimationEnd(mAnimator);
    }

    @Override
    public void onAnimationCancel(Animator animator) {
        mListener.onAnimationCancel(mAnimator);
    }

    @Override
    public void onAnimationRepeat(Animator animator) {
        mListener.onAnimationRepeat(mAnimator);
    }
}

Wow! That’s complicated. We not only need to keep the pause/resume from doing anything with the animator, but we also need to redirect the Animator parameters in the listener. Specifically, the TransitionManager has a list of currently-running animators and when the animator ends, it removes it from its internal list using the parameter in the onAnimationEnd(). Since the wrapped animator was the one that it added, the parameter that it receives must be the same as the one that it receives in the parameter.

That’s it! Enjoy your new RevealTransition and next time, I’ll talk about how to use it with shared elements in Activity Transitions.

Click here for the entire RevealTransition code