Animation / Scene / Transition / Translate

Curved Motion – Part 1

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.

The Material design guidelines suggest:

Not all objects move the same way. Lighter or smaller objects may move faster because they require less force, and larger or heavier objects may need more time to speed up.

Use curved motion and avoid linear spatial paths. Identify the qualities of motion best suited to your object, and represent their motion accordingly. Curves represent that change over time, for a particular value range. Find a curve that fits that character of motion you are describing.

If we look at the current version of the Play Store app we can see that this is applied when the images follow a curved path as we transition to and from the detail view rather than simply following a straight path:

For anyone lucky enough to be able to specify minSdkVersion="21" this is really easy because the Transitions framework does a lot of the hard work for you. We’ve covered Transitions before on Styling Android so we won’t do a deep dive on how these work – we’ll focus on how to use curved motion within these transitions.

Let’s take a look at this by first declaring a couple of layouts to represent our two scenes for our transitions:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <View
    android:id="@+id/view"
    android:layout_width="@dimen/view_size"
    android:layout_height="@dimen/view_size"
    android:layout_gravity="top|start"
    android:background="@color/sa_accent" />

</FrameLayout>
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <View
    android:id="@+id/view"
    android:layout_width="@dimen/view_size"
    android:layout_height="@dimen/view_size"
    android:layout_gravity="bottom|end"
    android:background="@color/sa_accent" />

</FrameLayout>

These are identical with the exception that the inner View is positions differently within the parent. This will simulate the different positions of the View so that we can create translation animations using the Transitions framework.

Next we’ll take a look at our Activity:

public class MainActivity extends AppCompatActivity {
    private FrameLayout container;

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

        setupToolbar();
        setLollipopAnimator();
    }

    private void setupToolbar() {
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        ActionBar actionBar = getSupportActionBar();
        if (actionBar != null) {
            actionBar.setTitle(R.string.app_name);
        }
    }

    private void setLollipopAnimator() {
        LollipopSceneAnimator.newInstance(this, container, R.layout.scene1, R.layout.scene2, R.transition.arc1);
    }
}

There’s nothing special going on here. I’ve elected to encapsulate all of the Transitions logic in to an external class to make it easier to swap in separate implementations later on. We initialise this by providing a Context, the container for the scenes, the layout ids of both scenes, and a Transition id which is actually the key bit – we’ll cover this once we’ve got the basic behaviour wired up.

final class LollipopSceneAnimator implements SceneAnimator {
    private final TransitionManager transitionManager;
    private Scene scene1;
    private Scene scene2;

    public static LollipopSceneAnimator newInstance(@NonNull Context context, @NonNull ViewGroup container,
                                            @LayoutRes int layout1Id, @LayoutRes int layout2Id, @TransitionRes int transitionId) {
        TransitionManager transitionManager = new TransitionManager();
        LollipopSceneAnimator sceneAnimator = new LollipopSceneAnimator(transitionManager);
        Scene scene1 = createScene(sceneAnimator, context, container, layout1Id);
        Scene scene2 = createScene(sceneAnimator, context, container, layout2Id);
        Transition transition = TransitionInflater.from(context).inflateTransition(transitionId);
        transitionManager.setTransition(scene1, scene2, transition);
        transitionManager.setTransition(scene2, scene1, transition);
        transitionManager.transitionTo(scene1);
        sceneAnimator.scene1 = scene1;
        sceneAnimator.scene2 = scene2;
        return sceneAnimator;
    }

    private static Scene createScene(@NonNull LollipopSceneAnimator sceneAnimator, @NonNull Context context,
                                     @NonNull ViewGroup container, @LayoutRes int layoutId) {
        Scene scene = Scene.getSceneForLayout(container, layoutId, context);
        scene.setEnterAction(new EnterAction(sceneAnimator, scene));
        return scene;
    }

    private LollipopSceneAnimator(TransitionManager transitionManager) {
        this.transitionManager = transitionManager;
    }

    private void sceneTransition(Scene from) {
        if (from == scene1) {
            transitionManager.transitionTo(scene2);
        } else {
            transitionManager.transitionTo(scene1);
        }
    }

    private static final class EnterAction implements Runnable, View.OnClickListener {
        private final LollipopSceneAnimator sceneAnimator;
        private final Scene scene;

        private EnterAction(@NonNull LollipopSceneAnimator sceneAnimator, @NonNull Scene scene) {
            this.sceneAnimator = sceneAnimator;
            this.scene = scene;
        }

        @Override
        public void run() {
            ViewGroup sceneRoot = scene.getSceneRoot();
            View view = sceneRoot.findViewById(R.id.view);
            view.setOnClickListener(this);
        }

        @Override
        public void onClick(View v) {
            sceneAnimator.sceneTransition(scene);
        }
    }
}

Once again, there’s nothing special going on here. In newInstance() we inflate our two scenes from the supplied layout ids, and wire them up to a TransitionManager. EnterAction hooks up an OnClickListener when each scene is entered to add the necessary click behaviour to toggle between the two scenes.

If we were to use this with a standard ChangeBounds Transition is would cause the cause the View to move between the two positions represented by the two scenes in a straight line. However, if we run this we can see that we actually get a curved path:

The reason for this is because of the transition id that we passed in to newInstance() which was inflated in to a Transition object. So let’s take a look at it:

<?xml version="1.0" encoding="utf-8"?>
<changeBounds xmlns:android="http://schemas.android.com/apk/res/android"
  android:duration="500">

  <arcMotion
    android:maximumAngle="90"
    android:minimumHorizontalAngle="15"
    android:minimumVerticalAngle="0" />

</changeBounds>

So this is a ChangeBounds transition, but it has an ArcMotion child which is what does all the hard work for us. It will calculate an appropriate arc between the start and end points but the attributes will case the arc to flatten to a straight line if the line between the start and end points is too close to either a horizontal or vertical line.

So, where you’re already using Transitions it is incredibly easy to change you transitions from straight lines to curves just by customising your Transition definitions slightly.

However, not many of us are able to use this, so in the next article we’ll look it to a simple technique for applying this to standard property Animators which will work back to API 11.

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.

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.