Animation / Animation Set / AnimationListener / Transition

Manual Layout Transitions – Part 1

Layout transitions are an important aspect of Material design as they help to indicate the user flow through an app, and help to tie visual components together as the user navigates. Two important tools for achieving this are Activity Transitions (which we’ll cover in the future) and Layout Transitions which we have covered on Styling Android before. However Layout Transitions are only supported in API 19 and later. In this series of articles we’ll look at how we can implement some nice transition animations even when we don’t have access to the transitions APIs.

Before we begin, it is worth pointing out that there is a back-port of the transitions API which offers compatibility back to API 14. However, I have decided to avoid this because I’ve never tried it; I prefer to stick with core Android APIs for blog articles; and the purpose of this series is to actually explore the techniques that the transitions API itself use to make it easy to roll your own.

In the previous series covering the Dirty Phrasebook app there were some simple animations when transitioning in and out of input mode:

I elected to implement these manually for reasons of backwards compatibility, so let’s begin with a look at how these animations work before progressing on to some more complex examples.

Let’s start with a quick look at the layout (this is a simplified version of the layout used in Dirty Phrasebook to make it easier to understand the various components):

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  xmlns:sa="http://schemas.android.com/apk/res-auto"
  android:id="@+id/layout_container"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical"
  tools:context=".MainActivity">

  <android.support.v7.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    android:background="?attr/colorPrimary"
    android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
    sa:popupTheme="@style/ThemeOverlay.AppCompat.Light">

    <Spinner
      android:id="@+id/language"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content" />

  </android.support.v7.widget.Toolbar>

  <android.support.v7.widget.CardView
    android:id="@+id/input_view"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1"
    android:clipChildren="false">

    <RelativeLayout
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:clipChildren="false"
      android:padding="@dimen/card_padding">

      <View
        android:id="@+id/focus_holder"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:focusableInTouchMode="true" />

      <EditText
        android:id="@+id/input"
        style="@style/Widget.TextView.Input"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:inputType="textMultiLine"
        android:imeOptions="flagNoFullscreen|actionDone"
        android:gravity="top"
        android:hint="@string/type_here" />

      <ImageView
        android:id="@+id/clear_input"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignTop="@id/input"
        android:layout_alignEnd="@id/input"
        android:layout_alignRight="@id/input"
        android:padding="8dp"
        android:src="@drawable/ic_clear"
        android:visibility="invisible"
        android:contentDescription="@string/clear_input" />

      <ImageView
        android:id="@+id/input_done"
        android:layout_width="32dip"
        android:layout_height="32dip"
        android:background="@drawable/done_background"
        android:src="@drawable/ic_arrow_forward"
        android:padding="2dp"
        android:layout_margin="8dp"
        tools:ignore="UnusedAttribute"
        android:elevation="4dp"
        android:visibility="invisible"
        android:layout_alignBottom="@id/input"
        android:layout_alignEnd="@id/input"
        android:layout_alignRight="@id/input"
        android:contentDescription="@string/done" />

    </RelativeLayout>

  </android.support.v7.widget.CardView>

  <FrameLayout
    android:id="@+id/translation_panel"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1"
    android:padding="@dimen/translation_outer_margin">

    <android.support.v7.widget.CardView
      android:layout_width="match_parent"
      android:layout_height="wrap_content">

      <FrameLayout
        android:id="@+id/translation_copy"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:foreground="@drawable/click_foreground">

        <LinearLayout
          android:layout_width="match_parent"
          android:layout_height="wrap_content"
          android:orientation="vertical"
          android:background="?attr/colorPrimary"
          tools:ignore="UselessParent">

          <FrameLayout
            android:id="@+id/translation_speak"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:foreground="@drawable/click_foreground"
            android:padding="@dimen/translation_inner_margin">

            <TextView
              android:id="@+id/translation_label"
              style="@style/Widget.TextView.Label"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:textAllCaps="true"
              android:drawableStart="@drawable/ic_tts"
              android:drawableLeft="@drawable/ic_tts"
              android:drawablePadding="4dip"
              android:text="@string/sample_language" />
          </FrameLayout>

          <TextView
            android:id="@+id/translation"
            style="@style/Widget.TextView.Translation"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginLeft="@dimen/translation_inner_margin"
            android:layout_marginStart="@dimen/translation_inner_margin"
            android:layout_marginRight="@dimen/translation_inner_margin"
            android:layout_marginEnd="@dimen/translation_inner_margin"
            android:layout_marginBottom="@dimen/translation_inner_margin"
            android:text="@string/sample_translation"/>
        </LinearLayout>
      </FrameLayout>

    </android.support.v7.widget.CardView>

  </FrameLayout>

</LinearLayout>

The key components that we need to be aware of in the animations are the Toolbar, The CardView with the ID input_view, the ImageView with the ID input_done, and the FrameLayout with the ID translation_panel. The only other view that we need worry about is focus_holder which is an invisible view used to hold the focus away from the only other focusable control in the layout. By toggling the focus between the EditText and focus_holder we toggle in and out of input mode, and it is this transition which starts the appropriate animations.

The animations are going to move input_view upwards to cover the Toolbar, fade in input_done, and fade out translation_panel. The reverse of this will be done as the user exits input mode. In the video you can see these happening.

Let’s now take a look at MainActivity:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        setTitle(R.string.sample_language);

        View input = findViewById(R.id.input);
        View inputDone = findViewById(R.id.input_done);
        final View focusHolder = findViewById(R.id.focus_holder);

        input.setOnFocusChangeListener(Part1TransitionController.newInstance(this));
        inputDone.setOnClickListener(
                new View.OnClickListener() {
                    @Override
                    public void onClick(@NonNull View v) {
                        focusHolder.requestFocus();
                    }
                });
    }
}

This is pretty straightforward: All it does is set up the Toolbar, and then set up the focus logic. The logic for creating the transitions is performed in Part1TransitionController and I’ve elected to abstract this logic out in to a separate class to enable us to easily swap implementations for future parts to this series. Part1TransitionController actually subclasses an abstract base class named TransitionController which contains some common logic:

public abstract class TransitionController implements View.OnFocusChangeListener {
    private final WeakReference<Activity> activityWeakReference;
    private final AnimatorBuilder animatorBuilder;

    protected TransitionController(WeakReference<Activity> activityWeakReference, @NonNull AnimatorBuilder animatorBuilder) {
        this.activityWeakReference = activityWeakReference;
        this.animatorBuilder = animatorBuilder;
    }

    @Override
    public void onFocusChange(View v, boolean hasFocus) {
        Activity mainActivity = activityWeakReference.get();
        if (mainActivity != null) {
            if (hasFocus) {
                enterInputMode(mainActivity);
            } else {
                exitInputMode(mainActivity);
            }
        }
    }

    protected AnimatorBuilder getAnimatorBuilder() {
        return animatorBuilder;
    }

    protected abstract void enterInputMode(Activity mainActivity);

    protected abstract void exitInputMode(Activity mainActivity);

    protected void closeIme(View view) {
        Activity mainActivity = activityWeakReference.get();
        if (mainActivity != null) {
            InputMethodManager imm = (InputMethodManager) mainActivity.getSystemService(
                    Context.INPUT_METHOD_SERVICE);
            imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
        }
    }

    protected class ImeCloseListener extends AnimatorListenerAdapter {
        private final View view;

        public ImeCloseListener(View view) {
            this.view = view;
        }

        @Override
        public void onAnimationEnd(@NonNull Animator animation) {
            super.onAnimationEnd(animation);
            closeIme(view);
        }

    }
}

This handles the onFocusChanged() event and calls an appropriate abstract method when we enter and exit input mode. It also contains an AnimatorListener class which we’ll use to ensure that the IME gets hidden when we exit input mode. The other thing is that there is an AnimatorBuilder instance which actually constructs some atomic property animators which we’ll use repeatedly, so let’s also take a look at that:

public class AnimatorBuilder {
    private static final String TRANSLATION_Y = "translationY";
    private static final String ALPHA = "alpha";

    private final int duration;

    public static AnimatorBuilder newInstance(Context context) {
        int duration = context.getResources().getInteger(android.R.integer.config_mediumAnimTime);
        return new AnimatorBuilder(duration);
    }

    AnimatorBuilder(int duration) {
        this.duration = duration;
    }

    public Animator buildTranslationYAnimator(View view, int startY, int endY) {
        Animator animator = ObjectAnimator.ofFloat(view, TRANSLATION_Y, startY, endY);
        animator.setDuration(duration);
        return animator;
    }

    public Animator buildShowAnimator(View view) {
        return buildAlphaAnimator(view, 0f, 1f);
    }

    public Animator buildHideAnimator(View view) {
        return buildAlphaAnimator(view, 1f, 0f);
    }

    public Animator buildAlphaAnimator(View view, float startAlpha, float endAlpha) {
        Animator animator = ObjectAnimator.ofFloat(view, ALPHA, startAlpha, endAlpha);
        animator.setDuration(duration);
        return animator;
    }
}

There are two basic animator that are constructed here: One is a translation animator which will effectively move a view up and down by modifying its translationY attribute, and the other will change the opacity of a view by modifying its alpha attribute. There are also a couple of utility function which wrap the alpha animator method and provide some convenience methods for transitioning from fully opaque to fully transparent, and vice versa.

All that remains is to look at Part1TransitionController to see how all of this is tied together:

public class Part1TransitionController extends TransitionController {

    public static TransitionController newInstance(Activity activity) {
        WeakReference<Activity> mainActivityWeakReference = new WeakReference<>(activity);
        AnimatorBuilder animatorBuilder = AnimatorBuilder.newInstance(activity);
        return new Part1TransitionController(mainActivityWeakReference, animatorBuilder);
    }

    Part1TransitionController(WeakReference<Activity> mainActivityWeakReference, AnimatorBuilder animatorBuilder) {
        super(mainActivityWeakReference, animatorBuilder);
    }

    @Override
    protected void enterInputMode(Activity activity) {
        View inputView = activity.findViewById(R.id.input_view);
        View inputDone = activity.findViewById(R.id.input_done);
        View translation = activity.findViewById(R.id.translation_panel);
        View toolbar = activity.findViewById(R.id.toolbar);

        inputDone.setVisibility(View.VISIBLE);

        AnimatorSet animatorSet = new AnimatorSet();
        AnimatorBuilder animatorBuilder = getAnimatorBuilder();
        Animator moveInputView = animatorBuilder.buildTranslationYAnimator(inputView, 0, -toolbar.getHeight());
        Animator showInputDone = animatorBuilder.buildShowAnimator(inputDone);
        Animator hideTranslation = animatorBuilder.buildHideAnimator(translation);
        animatorSet.playTogether(moveInputView, showInputDone, hideTranslation);
        animatorSet.start();
    }

    @Override
    protected void exitInputMode(Activity activity) {
        final View inputView = activity.findViewById(R.id.input_view);
        View inputDone = activity.findViewById(R.id.input_done);
        View translation = activity.findViewById(R.id.translation_panel);
        View toolbar = activity.findViewById(R.id.toolbar);

        AnimatorSet animatorSet = new AnimatorSet();
        AnimatorBuilder animatorBuilder = getAnimatorBuilder();
        Animator moveInputView = animatorBuilder.buildTranslationYAnimator(inputView, -toolbar.getHeight(), 0);
        Animator hideInputDone = animatorBuilder.buildHideAnimator(inputDone);
        Animator showTranslation = animatorBuilder.buildShowAnimator(translation);
        animatorSet.playTogether(moveInputView, hideInputDone, showTranslation);
        animatorSet.addListener(new ImeCloseListener(inputDone));
        animatorSet.start();
    }
}

Here we override the two abstract methods from the base TransitionController class. In both cases we find the appropriate views from the Activity. In enterInputMode() we build an AnimatorSet consisting of a property animator which will move inputView up by the height of the Toolbar, to transition inputDone from transparent to opaque, and to transition translation from opaque to transparent. In exitInputMode() we do the opposite of these, and also add an ImeCloseListener instance to hide the IME once the animation is complete.

That’s it. Hopefully by breaking this down in to some manageable pieces we can see how we can quite easily create some quite complex transitions by simply combining a few basic property animators.

However, we’re not going to stop there. This example is pretty straightforward, but the TransitionController instance implements the logic of which types of Animator get applied to which Views. Consequently is actually a long way short of the functionality that the transitions API offers us. In the next article we’ll change this a little to actually introspect the View states and construct the Animators dynamically rather than doing them manually as we have here.

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

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.