Complete Working Sample of the Gmail Three-Fragment Animation Scenario?

TL;DR: I am looking for a complete working sample of what I'll refer to as "the Gmail three-fragment animation" scenario. Specifically, we want to start with two fragments, like this:

two fragments

Upon some UI event (e.g., tapping on something in Fragment B), we want:

  • Fragment A to slide off the screen to the left
  • Fragment B to slide to the left edge of the screen and shrink to take up the spot vacated by Fragment A
  • Fragment C to slide in from the right side of the screen and to take up the spot vacated by Fragment B

And, on a BACK button press, we want that set of operations to be reversed.

Now, I have seen lots of partial implementations; I'll review four of them below. Beyond being incomplete, they all have their issues.


@Reto Meier contributed this popular answer to the same basic question, indicating that you would use setCustomAnimations() with a FragmentTransaction. For a two-fragment scenario (e.g., you only see Fragment A initially, and want to replace it with a new Fragment B using animated effects), I am in complete agreement. However:

  • Since you can only specify one "in" and one "out" animation, I can't see how you would handle all the different animations required for the three-fragment scenario
  • The <objectAnimator> in his sample code uses hard-wired positions in pixels, and that would seem to be impractical given varying screen sizes, yet setCustomAnimations() requires animation resources, precluding the possibility of defining these things in Java
  • I am at a loss as to how the object animators for scale tie in with things like android:layout_weight in a LinearLayout for allocating space on a percentage basis
  • I am at a loss as to how Fragment C is handled at the outset (GONE? android:layout_weight of 0? pre-animated to a scale of 0? something else?)

@Roman Nurik points out that you can animate any property, including ones that you define yourself. That can help solve the issue of the hard-wired positions, at the cost of inventing your own custom layout manager subclass. That helps some, but I'm still baffled by the rest of Reto's solution.


The author of this pastebin entry shows some tantalizing pseudocode, basically saying that all three fragments would reside in the container initially, with Fragment C hidden at the outset via a hide() transaction operation. We then show() C and hide() A when the UI event occurs. However, I don't see how that handles the fact that B changes size. It also relies on the fact that you apparently can add multiple fragments to the same container, and I am not sure whether or not that is reliable behavior over the long term (not to mention it should break findFragmentById(), though I can live with that).


The author of this blog post indicates that Gmail is not using setCustomAnimations() at all, but instead directly uses object animators ("you just change left margin of the root view + change width of the right view"). However, this is still a two-fragment solution AFAICT, and the implementation shown once again hard-wires dimensions in pixels.


I will continue plugging away at this, so I may wind up answering this myself someday, but I am really hoping that somebody has worked out the three-fragment solution for this animation scenario and can post the code (or a link thereto). Animations in Android make me want to pull my hair out, and those of you who have seen me know that this is a largely fruitless endeavor.


Solution 1:

Uploaded my proposal at github (Is working with all android versions though view hardware acceleration is strongly recommended for this kind of animations. For non hardware accelerated devices a bitmap caching implementation should fit better)

Demo video with the animation is Here (Slow frame rate cause of the screen cast. Actual performance is very fast)


Usage:

layout = new ThreeLayout(this, 3);
layout.setAnimationDuration(1000);
setContentView(layout);
layout.getLeftView();   //<---inflate FragmentA here
layout.getMiddleView(); //<---inflate FragmentB here
layout.getRightView();  //<---inflate FragmentC here

//Left Animation set
layout.startLeftAnimation();

//Right Animation set
layout.startRightAnimation();

//You can even set interpolators

Explaination:

Created a new custom RelativeLayout(ThreeLayout) and 2 custom Animations(MyScalAnimation, MyTranslateAnimation)

ThreeLayout gets the weight of the left pane as param ,assuming the other visible view has weight=1.

So new ThreeLayout(context,3) creates a new view with 3 children and the left pane with have 1/3 of the total screen. The other view occupies the all available space.

It calculates width at runtime,a safer implementation is that the dimentions are be calculated first time in draw(). instead of in post()

Scale and Translate animations actually resize and move the view and not pseudo-[scale,move]. Notice that fillAfter(true) is not used anywhere.

View2 is right_of View1

and

View3 is right_of View2

Having set these rules RelativeLayout takes care of everything else. Animations alter the margins (on move) and [width,height] on scale

To access each child (so that you can inflate it with your Fragment you can call

public FrameLayout getLeftLayout() {}

public FrameLayout getMiddleLayout() {}

public FrameLayout getRightLayout() {}

Below are demonstrated the 2 animations


Stage1

---IN Screen----------!-----OUT----

[View1][_____View2_____][_____View3_____]

Stage2

--OUT-!--------IN Screen------

[View1][View2][_____View3_____]

Solution 2:

OK, here is my own solution, derived from the Email AOSP app, per @Christopher's suggestion in the question's comments.

https://github.com/commonsguy/cw-omnibus/tree/master/Animation/ThreePane

@weakwire's solution is reminiscent of mine, though he uses classic Animation rather than animators, and he uses RelativeLayout rules to enforce positioning. From the bounty standpoint, he will probably get the bounty, unless somebody else with a slicker solution yet posts an answer.


In a nutshell, the ThreePaneLayout in that project is a LinearLayout subclass, designed to work in landscape with three children. Those childrens' widths can be set in the layout XML, via whatever desired means -- I show using weights, but you could have specific widths set by dimension resources or whatever. The third child -- Fragment C in the question -- should have a width of zero.

package com.commonsware.android.anim.threepane;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;

public class ThreePaneLayout extends LinearLayout {
  private static final int ANIM_DURATION=500;
  private View left=null;
  private View middle=null;
  private View right=null;
  private int leftWidth=-1;
  private int middleWidthNormal=-1;

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

  void initSelf() {
    setOrientation(HORIZONTAL);
  }

  @Override
  public void onFinishInflate() {
    super.onFinishInflate();

    left=getChildAt(0);
    middle=getChildAt(1);
    right=getChildAt(2);
  }

  public View getLeftView() {
    return(left);
  }

  public View getMiddleView() {
    return(middle);
  }

  public View getRightView() {
    return(right);
  }

  public void hideLeft() {
    if (leftWidth == -1) {
      leftWidth=left.getWidth();
      middleWidthNormal=middle.getWidth();
      resetWidget(left, leftWidth);
      resetWidget(middle, middleWidthNormal);
      resetWidget(right, middleWidthNormal);
      requestLayout();
    }

    translateWidgets(-1 * leftWidth, left, middle, right);

    ObjectAnimator.ofInt(this, "middleWidth", middleWidthNormal,
                         leftWidth).setDuration(ANIM_DURATION).start();
  }

  public void showLeft() {
    translateWidgets(leftWidth, left, middle, right);

    ObjectAnimator.ofInt(this, "middleWidth", leftWidth,
                         middleWidthNormal).setDuration(ANIM_DURATION)
                  .start();
  }

  public void setMiddleWidth(int value) {
    middle.getLayoutParams().width=value;
    requestLayout();
  }

  private void translateWidgets(int deltaX, View... views) {
    for (final View v : views) {
      v.setLayerType(View.LAYER_TYPE_HARDWARE, null);

      v.animate().translationXBy(deltaX).setDuration(ANIM_DURATION)
       .setListener(new AnimatorListenerAdapter() {
         @Override
         public void onAnimationEnd(Animator animation) {
           v.setLayerType(View.LAYER_TYPE_NONE, null);
         }
       });
    }
  }

  private void resetWidget(View v, int width) {
    LinearLayout.LayoutParams p=
        (LinearLayout.LayoutParams)v.getLayoutParams();

    p.width=width;
    p.weight=0;
  }
}

However, at runtime, no matter how you originally set up the widths, width management is taken over by ThreePaneLayout the first time you use hideLeft() to switch from showing what the question referred to as Fragments A and B to Fragments B and C. In the terminology of ThreePaneLayout -- which has no specific ties to fragments -- the three pieces are left, middle, and right. At the time you call hideLeft(), we record the sizes of left and middle and zero out any weights that were used on any of the three, so we can completely control the sizes. At the point in time of hideLeft(), we set the size of right to be the original size of middle.

The animations are two-fold:

  • Use a ViewPropertyAnimator to perform a translation of the three widgets to the left by the width of left, using a hardware layer
  • Use an ObjectAnimator on a custom pseudo-property of middleWidth to change the middle width from whatever it started with to the original width of left

(it is possible that it is a better idea to use an AnimatorSet and ObjectAnimators for all of these, though this works for now)

(it is also possible that the middleWidth ObjectAnimator negates the value of the hardware layer, since that requires fairly continuous invalidation)

(it is definitely possible that I still have gaps in my animation comprehension, and that I like parenthetical statements)

The net effect is that left slides off the screen, middle slides to the original position and size of left, and right translates in right behind middle.

showLeft() simply reverses the process, with the same mix of animators, just with the directions reversed.

The activity uses a ThreePaneLayout to hold a pair of ListFragment widgets and a Button. Selecting something in the left fragment adds (or updates the contents of) the middle fragment. Selecting something in the middle fragment sets the caption of the Button, plus executes hideLeft() on the ThreePaneLayout. Pressing BACK, if we hid the left side, will execute showLeft(); otherwise, BACK exits the activity. Since this does not use FragmentTransactions for affecting the animations, we are stuck managing that "back stack" ourselves.

The project linked-to above uses native fragments and the native animator framework. I have another version of the same project that uses the Android Support fragments backport and NineOldAndroids for the animation:

https://github.com/commonsguy/cw-omnibus/tree/master/Animation/ThreePaneBC

The backport works fine on a 1st generation Kindle Fire, though the animation is a bit jerky given the lower hardware specs and lack of hardware acceleration support. Both implementations seem smooth on a Nexus 7 and other current-generation tablets.

I am certainly open for ideas of how to improve this solution, or other solutions that offer clear advantages over what I did here (or what @weakwire used).

Thanks again to everyone who has contributed!