Animation / Interpolator / Material Design

Curved Motion – Part 2

The Material design guidelines advocate the use of authentic motion and the Play Store app has (at the time of writing) recently had an update to provide curved motion when transitioning from a list into a detail view. In this short series we’ll look at how to implement curved motion.

In the previous article we looked at how easy it is to add arcMotion to a Scene transition to get a nice curve when moving a View between two positions. But very few developers, at the time of writing this article, are lucky enough to be able to specify minSdkVersion="21" and so be able to use this technique. However there is a really neat way that we can get very similar behaviour back to API 11 (Honeycomb) which is almost as easy.

It should be mentioned that this technique will not give the same level of subtlety as that provided by ArcMotion and I would use that over this technique wherever possible. But as already stated – it may be a while before we can!

The reason for the minSdkVersion="11" requirement for this technique is that it uses ObjectAnimator and the getX() and getY() methods of View – which were all added in Honeycomb. It is entirely possible that the same technique could be applied using View animation but it would be a little more complex because of the additional work required to apply this. As the difference is down to the use of view animation versus property animation (and not the technique that we’re actually covering in this article) I have elected to use the simpler approach to keep the example code as uncluttered as possible.

OK, with all of that out of the way, let’s convert our project to use property Animators instead of Scene transitions pre-Lollipop:

public class MainActivity extends AppCompatActivity implements AdapterView.OnItemSelectedListener {
    private SceneAnimator sceneAnimator = null;
    private FrameLayout container;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        container = (FrameLayout) findViewById(R.id.container);

        setupToolbar();
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            setLegacyAnimator();
        } else {
            setLollipopAnimator();
        }
    }
    .
    .
    .
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private void setLollipopAnimator() {
        sceneAnimator = LollipopSceneAnimator.newInstance(this, container, R.layout.scene1, R.layout.scene2, R.transition.arc1);
    }

    private void setLegacyAnimator() {
        sceneAnimator = LegacySceneAnimator.newInstance(this, container, R.layout.scene1, R.id.view);
    }
    .
    .
    .
}

This is simple enough – we construct the appropriate animator depending on API level. This perhaps explains the decision in the first article to encapsulate all of the Scene transition logic in to a standalone class so we could switch in a different implementation.

So let’s have a look at the LegacySceneAnimator code:

final class LegacySceneAnimator implements SceneAnimator, View.OnClickListener, ViewTreeObserver.OnPreDrawListener {
    private static final String TRANSLATION_X = "translationX";
    private static final String TRANSLATION_Y = "translationY";

    private final FrameLayout parent;
    private final View view;
    private final int animationDuration;

    private float currentX;
    private float currentY;

    public static LegacySceneAnimator newInstance(@NonNull Context context, @NonNull ViewGroup container,
                                                  @LayoutRes int layoutId, @IdRes int viewId) {
        LayoutInflater layoutInflater = LayoutInflater.from(context);
        FrameLayout root = (FrameLayout) layoutInflater.inflate(layoutId, container, false);
        container.addView(root);
        View view = root.findViewById(viewId);
        int animationDuration = context.getResources().getInteger(android.R.integer.config_longAnimTime);
        LegacySceneAnimator sceneAnimator = new LegacySceneAnimator(root, view, animationDuration);
        view.setOnClickListener(sceneAnimator);
        return sceneAnimator;
    }

    private LegacySceneAnimator(FrameLayout parent, View view, int animationDuration) {
        this.parent = parent;
        this.view = view;
        this.animationDuration = animationDuration;
    }

    @Override
    public void onClick(View v) {
        ViewTreeObserver viewTreeObserver = parent.getViewTreeObserver();
        viewTreeObserver.addOnPreDrawListener(this);
        currentX = v.getX();
        currentY = v.getY();
        FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams();
        if (isTopAligned(layoutParams)) {
            layoutParams.gravity = Gravity.BOTTOM | Gravity.END;
        } else {
            layoutParams.gravity = Gravity.TOP | Gravity.START;
        }
        view.setLayoutParams(layoutParams);
    }

    private boolean isTopAligned(FrameLayout.LayoutParams layoutParams) {
        return (layoutParams.gravity & Gravity.TOP) == Gravity.TOP;
    }

    @Override
    public boolean onPreDraw() {
        ViewTreeObserver viewTreeObserver = parent.getViewTreeObserver();
        viewTreeObserver.removeOnPreDrawListener(this);
        getAnimator().start();
        return true;
    }

    private Animator getAnimator() {
        AnimatorSet animatorSet = new AnimatorSet();
        Animator xAnimator = getTranslationXAnimator();
        Animator yAnimator = getTranslationYAnimator();
        animatorSet.playTogether(xAnimator, yAnimator);
        animatorSet.setDuration(animationDuration);
        return animatorSet;
    }

    private Animator getTranslationXAnimator() {
        float newX = view.getX();
        Animator animator = ObjectAnimator.ofFloat(view, TRANSLATION_X, currentX - newX, 0);
        return animator;
    }

    private Animator getTranslationYAnimator() {
        float newY = view.getY();
        Animator animator = ObjectAnimator.ofFloat(view, TRANSLATION_Y, currentY - newY, 0);
        return animator;
    }
}

There’s nothing in here that we haven’t covered on Styling Android in the past. What we’re doing is when the attach an OnPreDrawListener when the user taps the View and then move the View within the layout. After the layout pass has been completed (so the View is in it’s destination position) but just before this is drawn, the onPreDraw() method is called and this enables us to create the necessary property animators to animate the View from its old position to its current one. It’s important that the OnPreDrawListener is unregistered again so this only happens once.

So this gives us a transition along a straight line from from the starting position to the end position.

The trick for creating a curved path is to use different Interpolators to change the relative speeds of the X and Y translations:

    private Animator getAnimator() {
        AnimatorSet animatorSet = new AnimatorSet();
        Animator xAnimator = getTranslationXAnimator(new DecelerateInterpolator(0.75f));
        Animator yAnimator = getTranslationYAnimator(new AccelerateInterpolator(0.75f));
        animatorSet.playTogether(xAnimator, yAnimator);
        animatorSet.setDuration(animationDuration);
        return animatorSet;
    }

    private Animator getTranslationXAnimator(Interpolator interpolator) {
        float newX = view.getX();
        Animator animator = ObjectAnimator.ofFloat(view, TRANSLATION_X, currentX - newX, 0);
        animator.setInterpolator(interpolator);
        return animator;
    }

    private Animator getTranslationYAnimator(Interpolator interpolator) {
        float newY = view.getY();
        Animator animator = ObjectAnimator.ofFloat(view, TRANSLATION_Y, currentY - newY, 0);
        animator.setInterpolator(interpolator);
        return animator;
    }
}

We apply a DecelerateInterpolator() to the X translation which means that it’s going to start fast and slow down as it progresses; and an AccelerateInterpolator() to the Y translation which means that it’s going to start slow and speed up as it progresses. So this means that at the beginning of the animation the X position will change much faster than the Y position with the reverse being true at the end of the animation. So while the start and end points are fixed the different Interpolators used for the two translation vectors will actually change the path that the animation follows – in this case we get a curve:

It’s worth pointing out that I played around with the factor value that is used in the Interpolator constructor to get a path that was fairly close to that of the ArcMotion. However, you may need to play with different values or even calculate appropriate values based upon the start and end points in order to get value which is going to work well in all cases.

In order to back-port this technique to API 1, the property Animators will need to be replaced with View animators (and extra calculation will be required to work out the start and end points). It will require two distinct View animators – one for the X translation and another for the Y translation. Then apply different Interpolators to each and run them in parallel.

That concludes this short series on adding a curve to a straight line animation.

The source code for this article is available here.

© 2015, Mark Allison. All rights reserved.

Copyright © 2015 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

1 Comment

  1. There is a typo in LegacySceneAnimator.java, “(” is missing.
    line 66: private Animator getTranslationXAnimator) {

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.