原文:Implementing Complex Animations in Android (Full Working Code)
Android对动画有着极好的支持,但有时你会看到这样的效果:
你可能会在此卡住不知从何开始。本文将带你一步一步尝试完整这个漂亮的动画。
第一次看到这个效果的时候可能会觉得很复杂,但是我们可以把它拆分为三个主要的动画。
1.用户点击卡片时的动画:
2.打开详情界面的动画:
3.向上滚动时头像收缩为圆点的动画:
我将实现第二和第三个动画,第一个很简单留给读者自己练习吧
记得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; }
这就可以使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; } });
现在,让我们回到缩小图标的部分。记住我们的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);
点击运行你将看到下面的效果:
资源
完整代码:
写作本文的动机来源于这个问题: https://stackoverflow.com/q/43542302/826606
如果你有更好的方式来实现这个动画,请留言或者 pull request。