在Android中实现复杂动画(附完整代码)

原文:Implementing Complex Animations in Android (Full Working Code) 

Android对动画有着极好的支持,但有时你会看到这样的效果:

Untitled.gif

你可能会在此卡住不知从何开始。本文将带你一步一步尝试完整这个漂亮的动画。

第一次看到这个效果的时候可能会觉得很复杂,但是我们可以把它拆分为三个主要的动画。

1.用户点击卡片时的动画:

1-OVMHo8MTEal3dBvZpc4kKA (1).gif

2.打开详情界面的动画:

1-t_oijMCzE6TQsc39dSgEXQ (1).gif

3.向上滚动时头像收缩为圆点的动画:

1-GqAI58V9pWwC4o-JUWkcuw.gif

我将实现第二和第三个动画,第一个很简单留给读者自己练习吧

记得Android 5.0 (API level 21)添加的r Shared Element Transition 吗?你只需告诉OS当前界面与下一界面共享的view,OS就会处理好view从旧状态到新状态的过渡,包括 translation, rotation, scale 以及 visibility等。它甚至还可以在ImageView上做矩阵动画。

第一个动画中我们将利用 Shared Element Transition。我们有一个显示圆形image的RecyclerView。我们想点击任意一个image时,所有的item都过渡动画到下一屏的恰当位置。为此我们需要从LayoutManager那里得到可见item的position:

int firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition();
int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition();

一旦有了这些position,可以得到与之关联的itemView,然后把这些view作为共享元素启动下一个activity:

List<Pair<View, String>> pairs = new ArrayList<Pair<View, String>>();
for (int i = firstVisibleItemPosition; i <= lastVisibleItemPosition; i++) {
	ViewHolder holderForAdapterPosition = (ViewHolder) list.findViewHolderForAdapterPosition(i);
	View itemView = holderForAdapterPosition.image;
	pairs.add(Pair.create(itemView, "unique_key_" + i));
}
Bundle bundle = ActivityOptions.makeSceneTransitionAnimation(CurrentActivity.this, pairs.toArray(new Pair\[\]{})).toBundle();
startActivity(intent, bundle);

在下一个activity中,你只需要把unique_key_x设置到一些view上,系统就会处理好动画了。而下一个activity中的相应图片我们是用ViewPager的IconPageIndicator来显示的。因此需要在IconPageIndicator的同一position设置与上一个activity相同的transition name。

在IconPageIndicator类的notifyDataSetChanged方法中:

  public void notifyDataSetChanged() {
  ...
        IconPagerAdapter iconAdapter = (IconPagerAdapter) mViewPager.getAdapter();
        int count = iconAdapter.getCount();
        LayoutInflater inflater = LayoutInflater.from(getContext());
        for (int i = 0; i < count; i++) {
            final View parent = inflater.inflate(R.layout.indicator, mIconsLayout, false);
            final ImageView view = (ImageView) parent.findViewById(R.id.icon);
            //// TODO: 25/04/2017 Use ViewCompat to support pre-lollipop
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                view.setTransitionName("tab_" + i);
            }
  
        }
    ...
    }

瞧,第一个动画我们就完成了。

第二个动画就非常复杂了。列表滚动的时候伴随着太多的事情。scroll up的时候图标缩小成点,scroll down的时候点慢慢扩展成图标。另一个有趣的事情就是indicator始终在Toolbar中垂直居中。

首先,我们希望在Toolbar折叠或者展开的时候IconPageIndicator是居中的(显然我们用的是CoordinatorLayout+CollapsingToolbarLayout)。如官网所说,CoordinatorLayout是一个超级强大的FrameLayout。CoordinatorLayout中的每个child都可以通过CoordinatorLayout.Behavior监听其它child发生的事件,并且做出响应。我们将利用这个来实现在CollapsingToolbarLayout中垂直居中。

首先我们让Behavior知道我们对AppBarLayout感兴趣:

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, IconPageIndicator child, View dependency) {
    return dependency instanceof AppBarLayout;
}

每当onDependentViewChanged() 被调用的时候,我们做一些处理,同时考虑android:fitsSystemWindows="true":

@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, IconPageIndicator child, View dependency) {
    //keep child centered inside dependency respecting android:fitsSystemWindows="true"
    int systemWindowInsetTop = 0;
    if (lastInsets != null) {
        systemWindowInsetTop = lastInsets.getSystemWindowInsetTop();
    }
    int bottom = dependency.getBottom();
    float center = (bottom - systemWindowInsetTop) / 2F;
    float halfChild = child.getHeight() / 2F;
    setTopAndBottomOffset((int)(center + systemWindowInsetTop - halfChild));
    return true;
}

1-CGPC5niiuV_e2tiLQllHFw.png

这就可以使indicator居中了。现在我们需要让用户滚动的时候icon缩小为点。但如果你仔细点的话,就会发现这里还有一个细节;点是横向居中的,但是头像图标indicator是从中间开始的。也就是说当收缩到点的时候要让indicator居中。

我们各个击破。这里我们将为indicator添加startpadding和endpadding ,从而让它从中间开始显示。我们在OnPreDrawListener中做这个事情,因为必须在view测量完成之后才可以做这个事情。

indicator.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
    @Override
    public boolean onPreDraw() {
        indicator.getViewTreeObserver().removeOnPreDrawListener(this);
        int parentWidth = getWidth();
        int indicatorWidth = indicator.getWidth();
        int leftRightPadding = (parentWidth - indicatorWidth) / 2;
        //just touch left and right padding
        setPadding(leftRightPadding, getPaddingTop(), leftRightPadding, getPaddingBottom());
        return true;
    }
});

1-ZW3xdRlvz7dOT6xcdjG8fw.png

现在,让我们回到缩小图标的部分。记住我们的Behavior中重写了onDependentViewChanged()方法。每当CollapsingToolbarLayout发生变化的时候这个方法都会被调用。我们可以获得滚动的总距离和当前的滚动位置,这是我们把动画和滚动绑定所需要的仅有两个东西:

child.collapse(-appBar.getTop(), appBar.getTotalScrollRange());

而在collapse()内部我们可以把icon缩小为点。注意别太小,对我来说除以 1.2就可以了。

public void collapse(float current, float total) {
    //do not scale to 0
    float newTop = current / 1.2F;
    float scale = (total - newTop) / (float) total;
    ViewCompat.setScaleX(this, scale);
    ViewCompat.setScaleY(this, scale);
}

我们还希望上滚的时候icon变成灰色的indicator,因此:

public void collapse(float top, float total) {
    ...
    //alpha can be zero
    percentExpanded = (total - top) / (float) total;
    float alpha = 1 - percentExpanded;
    for (int i = 0; i < tabCount; i++) {
        View parent = mIconsLayout.getChildAt(i);
        //start showing our gray foreground when scrolling
        View child = parent.findViewById(R.id.foreground);
        ViewCompat.setAlpha(child, alpha);
    }
    updateScroll();
}

到此我们的app几乎具有了gif图中看到的所有效果。不过仍然有改进空间!我们用p代表当前的收缩比例,c代表indicator的中点,s代表当前选中的icon position,sx代表横向滚动的距离,那么就可以写出下面的公式:

sx = (p x c) + ((1 - p) x s

这个公司代表的意思就是,当p从 0 到 1 时,我们要么让c完全居中,要么让当前选中的position s居中。代码看起来更丑:

int center = iconsLayout.getWidth() / 2;
int scrollTo = (int)((center * (1 - p)) + (p * iconsLayout.getChildAt(selectedIndex).getLeft()));
smoothScrollTo(scrollTo, 0);

点击运行你将看到下面的效果:

1-BbgeLHkKiRoOIoDeDQO6Wg.gif

资源

完整代码:

屏幕快照 2017-06-24 00.52.34.png 

写作本文的动机来源于这个问题: https://stackoverflow.com/q/43542302/826606 

如果你有更好的方式来实现这个动画,请留言或者 pull request。

来自:UI实验室