曲线运动 - 2
在前一篇文章 中我们看到了把arcMotion添加到一个Scene transition来实现两点之间的曲线动画时多么简单的事情。但是很少有开发者有那么幸运可以使用minSdkVersion=“21"来使用这项技术。不过其实有一种巧妙的办法可以达到类似的效果,并且能向后兼容到 API 11 (Honeycomb)。同样也很简单。
需要指出的是这个技术并不能提供和ArcMotion同样级别的灵活程度,而且如果是我的话也会尽可能的优先使用后者。但是就如我一开始所说的-在我们能使用ArcMotion之前,还需要等一段时间。
要求minSdkVersion=“11”的原因是使用了ObjectAnimator以及View的getX() 和getY()方法,这些都是在Honeycomb时添加的。当然使用View animation来实现同样的效果也是完全可能的,不过那样的话要更复杂些,因为需要额外的工作。因为两者的区别仅仅是使用属性动画还是视图动画(而不是本文实际所要讲的技术),我选择使用更简单的方法,让示例代码尽可能的简洁。
好了,清理了所有思想上的阻碍之后,让我们把我们的项目从使用Scene transition转到使用属性动画吧:
MainActivity.java
public class MainActivity extends AppCompatActivity implements AdapterView.OnItemSelectedListener {
private SceneAnimator sceneAnimator = null;
private FrameLayout container;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
container = (FrameLayout) findViewById(R.id.container);
setupToolbar();
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
setLegacyAnimator();
} else {
setLollipopAnimator();
}
}
.
.
.
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private void setLollipopAnimator() {
sceneAnimator = LollipopSceneAnimator.newInstance(this, container, R.layout.scene1, R.layout.scene2, R.transition.arc1);
}
private void setLegacyAnimator() {
sceneAnimator = LegacySceneAnimator.newInstance(this, container, R.layout.scene1, R.id.view);
}
.
.
.
}
这已经够简单了 - 我们根据API 级别构造相应的animator 。这似乎解释了在第一篇文章中提到的问题:把Scene transition的逻辑封装到一个单独的类中,以方便转换成其他的实现方式。
那么让我们来看看LegacySceneAnimator 的代码:
LegacySceneAnimator.java
final class LegacySceneAnimator implements SceneAnimator, View.OnClickListener, ViewTreeObserver.OnPreDrawListener {
private static final String TRANSLATION_X = "translationX";
private static final String TRANSLATION_Y = "translationY";
private final FrameLayout parent;
private final View view;
private final int animationDuration;
private float currentX;
private float currentY;
public static LegacySceneAnimator newInstance(@NonNull Context context, @NonNull ViewGroup container,
@LayoutRes int layoutId, @IdRes int viewId) {
LayoutInflater layoutInflater = LayoutInflater.from(context);
FrameLayout root = (FrameLayout) layoutInflater.inflate(layoutId, container, false);
container.addView(root);
View view = root.findViewById(viewId);
int animationDuration = context.getResources().getInteger(android.R.integer.config_longAnimTime);
LegacySceneAnimator sceneAnimator = new LegacySceneAnimator(root, view, animationDuration);
view.setOnClickListener(sceneAnimator);
return sceneAnimator;
}
private LegacySceneAnimator(FrameLayout parent, View view, int animationDuration) {
this.parent = parent;
this.view = view;
this.animationDuration = animationDuration;
}
@Override
public void onClick(View v) {
ViewTreeObserver viewTreeObserver = parent.getViewTreeObserver();
viewTreeObserver.addOnPreDrawListener(this);
currentX = v.getX();
currentY = v.getY();
FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams();
if (isTopAligned(layoutParams)) {
layoutParams.gravity = Gravity.BOTTOM | Gravity.END;
} else {
layoutParams.gravity = Gravity.TOP | Gravity.START;
}
view.setLayoutParams(layoutParams);
}
private boolean isTopAligned(FrameLayout.LayoutParams layoutParams) {
return (layoutParams.gravity & Gravity.TOP) == Gravity.TOP;
}
@Override
public boolean onPreDraw() {
ViewTreeObserver viewTreeObserver = parent.getViewTreeObserver();
viewTreeObserver.removeOnPreDrawListener(this);
getAnimator().start();
return true;
}
private Animator getAnimator() {
AnimatorSet animatorSet = new AnimatorSet();
Animator xAnimator = getTranslationXAnimator();
Animator yAnimator = getTranslationYAnimator();
animatorSet.playTogether(xAnimator, yAnimator);
animatorSet.setDuration(animationDuration);
return animatorSet;
}
private Animator getTranslationXAnimator() {
float newX = view.getX();
Animator animator = ObjectAnimator.ofFloat(view, TRANSLATION_X, currentX - newX, 0);
return animator;
}
private Animator getTranslationYAnimator() {
float newY = view.getY();
Animator animator = ObjectAnimator.ofFloat(view, TRANSLATION_Y, currentY - newY, 0);
return animator;
}
}
这里面所有的东西都是Styling Android 以前讲过的。这里所做的就是在用户点击view的时候设置一个OnPreDrawListener,然后把view移到布局中。当布局完成之后(这时view已经在它的目的为止)绘制开始之前,onPreDraw()方法被调用,这让我们得以创建必要的属性动画让view从原来的位置动画过渡到新的位置。重要的是OnPreDrawListener被取消注册,让这个过程只发生一次。
这段代码能给我一个从初始位置到结束位置的直线动画。
创建曲线运动的技巧在于使用不同的Interpolators来改变X和Y移动的相对速度:
LegacySceneAnimator.java
private Animator getAnimator() {
AnimatorSet animatorSet = new AnimatorSet();
Animator xAnimator = getTranslationXAnimator(new DecelerateInterpolator(0.75f));
Animator yAnimator = getTranslationYAnimator(new AccelerateInterpolator(0.75f));
animatorSet.playTogether(xAnimator, yAnimator);
animatorSet.setDuration(animationDuration);
return animatorSet;
}
private Animator getTranslationXAnimator(Interpolator interpolator) {
float newX = view.getX();
Animator animator = ObjectAnimator.ofFloat(view, TRANSLATION_X, currentX - newX, 0);
animator.setInterpolator(interpolator);
return animator;
}
private Animator getTranslationYAnimator(Interpolator interpolator) {
float newY = view.getY();
Animator animator = ObjectAnimator.ofFloat(view, TRANSLATION_Y, currentY - newY, 0);
animator.setInterpolator(interpolator);
return animator;
}
}
我们在X的移动上采用了DecelerateInterpolator(),开始快然后减速;在Y的移动上采用了AccelerateInterpolator(),开始慢然后加速。这就意味着在开始X的变化会比Y快得多,而在动画结束的时候则相反。
因此在开始和结束点固定的情况下,在两个位移向量上分别使用不同的Interpolator(插值器,不过我觉得称为时间函数更准确些)会改变动画跟随的轨迹 - 在我们的情况中,我们得到的是一个曲线。
值得一提的是,我在Interpolator构造器里使用的属性值和ArcMotion中的非常接近。但是你可能需要使用不同的值甚至根据开始和结束点计算出合理的值以适用于所有场景。
如果要兼容到API 1,你需要把属性动画替换成视图动画(同时需要额外的计算)。
其实为啥不使用nineoldandroids库呢。。。-译者注。
以上就结束了这个关于曲线动画的简短系列。
这篇文章的源代码在这里。
Mark Allison。 保留所有权利,本文最先发布在Styling Android。