Android、View视图与坐标系
View的滑动和属性动画
从源码解析View的事件分发机制
View的工作流程
Android自定义view
View的滑动是Android实现自定义控件的基础,同时在开发中我们也难免会遇到View的滑动处理。其实不管是哪种滑动方式,其基本思想都是类似的:当点击事件传到View时,系统记下触摸点的坐标,手指移动时系统记下移动后触摸的坐标并算出偏移量,并通过偏移量来修改View的坐标。实现View滑动有很多种方法,在这里主要讲解6种滑动方法,分别是layout()、offsetLeftAndRight()与offsetTopAndBottom()、LayoutParams、动画、scollTo 与 scollBy,以及Scroller。
View进行绘制的时候会调用onLayout()方法来设置显示的位置,因此我们同样也可以通过修改View的left、top、right、bottom这4种属性来控制View的坐标。首先我们要自定义一个View,在onTouchEvent()方法中获取触摸点的坐标,代码如下所示:
public boolean onTouchEvent(MotionEvent event) {
//获取手指触摸点的横坐标和纵坐标
int x =(int) event.getX();
int y =(int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_UP:
lastX = x;
lastY = y;
break;
case MotionEvent.ACTION_MOVE:
//计算移动的距离
int offsetX = x - lastX;
int offsetY = y - lastY;
//调用layout方法来重新放置它的位置
layout(getLeft() + offsetX, getTop() + offsetY,
getRight() + offsetX, getBottom() + offsetY);
break;
...
这两种方法和layout()方法的效果差不多,其使用方式也差不多。我们将ACTION_MOVE中的代码替换成如下代码:
case MotionEvent.ACTION_MOVE:
//计算移动的距离
int offsetX = x - lastX;
int offsetY = y - lastY;
//对 left 和 right 进行偏移
offsetLeftAndRight(offsetX);
//对 top 和 bottom 进行偏移
offsetTopAndBottom(offsetY);
break;
LayoutParams主要保存了一个View的布局参数,因此我们可以通过LayoutParams来改变View的布局参数从而达到改变View位置的效果。同样,我们将ACTION_MOVE中的代码替换成如下代码:
LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams)getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getBottom() + offsetY;
setLayoutParams(layoutParams);
因为父控件是 LinearLayout,所以我们用了LinearLayout.LayoutParams。如果父控件是RelativeLayout,则要使用RelativeLayout.LayoutParams。除了使用布局的LayoutParams外,我们还可以用ViewGroup.MarginLayoutParams来实现:
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams)getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getBottom() + offsetY;
setLayoutParams(layoutParams);
scrollTo(x,y)表示移动到一个具体的坐标点,而 scrollBy(dx,dy)则表示移动的增量为dx、dy。其中,scollBy最终也是要调用scollTo的。View.java的scollBy和scollTo的源码如下所示:
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
scollTo、scollBy移动的是View的内容,如果在ViewGroup中使用,则是移动其所有的子View。我们将ACTION_MOVE中的代码替换成如下代码:
((View) getParent()).scrollBy(-offsetX,-offsetY);
这里若要实现CustomView随手指移动的效果,就需要将偏移量设置为负值。为什么要设置为负值呢?下面具体讲解一下。假设我们正用放大镜来看报纸,放大镜用来显示字的内容。同样我们可以把放大镜看作我们的手机屏幕,它们都是负责显示内容的;而报纸则可以被看作屏幕下的画布,它们都是用来提供内容的。放大镜外的内容,也就是报纸的内容不会随着放大镜的移动而消失,它一直存在。同样,我们的手机屏幕看不到的视图并不代表其不存在,如图1所示。画布上有3个控件,即Button、EditText和SwichButton。只有Button在手机屏幕中显示,它的Android坐标为(60,60)。现在我们调用scrollBy(50,50),按照字面的意思,这个Button应该会在屏幕右下侧,可是事实并非如此。如果我们调用scrollBy(50,50),里面的参数都是正值,我们的手机屏幕向 X轴正方向,也就是向右边平移50,然后手机屏幕向Y轴正方向,也就是向下方平移 50,平移后的效果如图2所示。虽然我们设置的数值是正数并且在X轴和Y轴的正方向移动,但Button却向相反方向移动了,这是参考对象不同导致的差异。所以我们用scrollBy方法的时候要设置负数才会达到自己想要的效果。
我们在用scollTo/scollBy方法进行滑动时,这个过程是瞬间完成的,所以用户体验不大好。这里我们可以使用Scroller来实现有过渡效果的滑动,这个过程不是瞬间完成的,而是在一定的时间间隔内完成的。Scroller本身是不能实现View的滑动的,它需要与View的computeScroll()方法配合才能实现弹性滑动的效
果。在这里我们实现CustomView平滑地向右移动。首先我们要初始化Scroller,接下来重写computeScroll()方法,系统会在绘制View的时候在draw()方法中调用该方法。在这个方法中,我们调用父类的scrollTo()方法并通过Scroller来不断获取当前的滚动值,每滑动一小段距离我们就调用invalidate()方法不断地进行重绘,重绘就会调用computeScroll()方法,这样我们通过不断地移动一个小的距离并连贯起来就实现了平滑移动的效果。,代码如下所示:
private Scroller mScroller;
public CustomView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mScroller = new Scroller(context);
}
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) {
((View) getParent()).scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();
}
}
public void smoothScrollTo(int destX, int destY) {
int scrollX = getScrollX();
int delta = destX - scrollX;
mScroller.startScroll(scrollX, 0, delta, 0, 2000);
invalidate();
}
我们在CustomView中写一个smoothScrollTo方法,调用Scroller的startScroll()方法,在2000ms内沿X轴平移delta像素,最后调用CustomView的smoothScrollTo()方法。这里我们设定CustomView沿着X轴向右平移400像素。
mCustomView.smoothScrollTo(-400,0);
上面介绍了如何使用Scroller进行滑动,但是其使用流程和一般的类的使用方式稍有不同。为了更好地理解Scroller的使用流程,我们有必要学习一下Scroller的源码。要想使用Scroller,必须先调用new Scroller()。下面先来看看Scroller的构造方法,代码如下所示:
public Scroller(Context context) {
this(context, null);
}
public Scroller(Context context, Interpolator interpolator) {
this(context, interpolator,
context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.HONEYCOMB);
}
public Scroller(Context context, Interpolator interpolator, boolean flywheel) {
mFinished = true;
if (interpolator == null) {
mInterpolator = new ViscousFluidInterpolator();
} else {
mInterpolator = interpolator;
}
mPpi = context.getResources().getDisplayMetrics().density * 160.0f;
mDeceleration = computeDeceleration(ViewConfiguration.getScrollFriction());
mFlywheel = flywheel;
mPhysicalCoeff = computeDeceleration(0.84f); // look and feel tuning
}
从上面的代码我们得知,Scroller有三个构造方法,通常情况下我们都用第一个;第二个需要传进去一个插值器Interpolator,如果不传则采用默认的插值器ViscousFluidInterpolator。接下来看看Scroller的startScroll()方法,代码如下所示:
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
mMode = SCROLL_MODE;
mFinished = false;
mDuration = duration;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
mFinalX = startX + dx;
mFinalY = startY + dy;
mDeltaX = dx;
mDeltaY = dy;
mDurationReciprocal = 1.0f / (float) mDuration;
}
在startScroll()方法中并没有调用类似开启滑动的方法,而是保存了传进来的各种参数:startX和startY表示滑动开始的起点,dx和dy表示滑动的距离,duration则表示滑动持续的时间。所以startScroll()方法只是用来做前期准备的,并不能使View进行滑动。关键是我们在startScroll()方法后调用了invalidate()方法,这个方法会导致View的重绘,而View的重绘会调用View的draw()方法,draw()方法又会调用View的computeScroll()方法。我们重写computeScroll()方法如下:
public void smoothScrollTo(int destX, int destY) {
int scrollX = getScrollX();
int delta = destX - scrollX;
mScroller.startScroll(scrollX, 0, delta, 0, 2000);
invalidate();
}
我们在computeScroll()方法中通过Scroller来获取当前的ScrollX和ScrollY,然后调用scrollTo()方法进行View的滑动,接着调用invalidate方法来让View进行重绘,重绘就会调用computeScroll()方法来实现View的滑动。这样通过不断地移动一个小的距离并连贯起来就实现了平滑移动的效果。但是在Scroller中如何获取当前位置的ScrollX和ScrollY呢?我们忘了一点,那就是在调用scrollTo()方法前会调用Scroller的computeScrollOffset()方法。接下来看看computeScrollOffset()方法:
public boolean computeScrollOffset() {
if (mFinished) {
return false;
}
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
case FLING_MODE:
final float t = (float) timePassed / mDuration;
final int index = (int) (NB_SAMPLES * t);
float distanceCoef = 1.f;
float velocityCoef = 0.f;
if (index < NB_SAMPLES) {
final float t_inf = (float) index / NB_SAMPLES;
final float t_sup = (float) (index + 1) / NB_SAMPLES;
final float d_inf = SPLINE_POSITION[index];
final float d_sup = SPLINE_POSITION[index + 1];
velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
distanceCoef = d_inf + (t - t_inf) * velocityCoef;
}
mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;
mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
// Pin to mMinX <= mCurrX <= mMaxX
mCurrX = Math.min(mCurrX, mMaxX);
mCurrX = Math.max(mCurrX, mMinX);
mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
// Pin to mMinY <= mCurrY <= mMaxY
mCurrY = Math.min(mCurrY, mMaxY);
mCurrY = Math.max(mCurrY, mMinY);
if (mCurrX == mFinalX && mCurrY == mFinalY) {
mFinished = true;
}
break;
}
}
else {
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}
首先会计算动画持续的时间timePassed。如果动画持续时间小于我们设置的滑动持续时间mDuration,则执行Switch语句。因为在startScroll()方法中的mMode值为SCROLL_MODE,所以执行分支语句SCROLL_MODE,然后根据插值器Interpolator来计算出在该时间段内移动的距离,赋值给mCurrX和mCurrY,这样我们就能通过Scroller来获取当前的ScrollX和ScrollY了。另外,computeScrollOffset()的返回值如果为true则表示滑动未结束,为false则表示滑动结束。所以,如果滑动未结束,我们就得持续调用
scrollTo()方法和invalidate()方法来进行View的滑动。讲到这里总结一下Scroller的原理:Scroller并不能直接实现View的滑动,它需要配合View的computeScroll()方法。在computeScroll()中不断让View进行重绘,每次重绘都会计算滑动持续的时间,根据这个持续时间就能算出这次View滑动的位置,我们根据每次滑动的位置调用scrollTo()方法进行滑动,这样不断地重复上述过程就形成了弹性滑动。
View 动画提供了AlphaAnimation、RotateAnimation、TranslateAnimation、ScaleAnimation这4种动画方式,并提供了AnimationSet动画集合来混合使用多种动画。随着Android3.0属性动画的推出,View动画不再风光。相比属性动画,View动画一个非常大的缺陷突显,其不具有交互性。当某个元素发生View动画后,其响应事件的位置依然在动画进行前的地方,所以View动画只能做普通的动画效果,要避免涉及交互操作。但是它的优点也非常明显:效率比较高,使用也方便。由于 Android 3.0之前已有的动画框架Animation存在一些局限性,也就是动画改变的只是显示,但View的位置没有发生变化,View移动后并不能响应事件。在Animator框架中使用最多的就是AnimatorSet和ObjectAnimator配合:使用 ObjectAnimator进行更精细化的控制,控制一个对象和一个属性值,而使用多个ObjectAnimator组合到AnimatorSet形成一个动画。属性动画通过调用属性get、set方法来真实地控制一个View的属性值,因此,强大的属性动画框架基本可以实现所有的动画效果。
ObjectAnimator 是属性动画最重要的类,创建一个 ObjectAnimator 只需通过其静态工厂类直接返还一个ObjectAnimator对象。参数包括一个对象和对象的属性名字,但这个属性必须有get和set方法,其内部会通过Java反射机制来调用set方法修改对象的属性值。下面看看平移动画是如何实现的,代码如下所示:
ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, "translationX", 200);
objectAnimator.setDuration(300);
objectAnimator.start();
通过ObjectAnimator的静态方法,创建一个ObjectAnimator对象,查看ObjectAnimator.java的静态方法ofFloat(),代码如下所示:
public static ObjectAnimator ofFloat(Object target, String propertyName, float... values) {
ObjectAnimator anim = new ObjectAnimator(target, propertyName);
anim.setFloatValues(values);
return anim;
}
从源码可以看出第一个参数是要操作的Object;第二个参数是要操作的属性;最后一个参数是一个可变的float类型数组,需要传进去该属性变化的取值过程,这里设置了一个参数,变化到200。与View动画一样,也可以给属性动画设置显示时长、插值器等属性。下面就是一些常用的可以直接使用的属性动画的属性值。
需要注意的是,在使用ObjectAnimator的时候,要操作的属性必须要有get和set方法,不然ObjectAnimator就无法生效。如果一个属性没有get、set方法,也可以通过自定义一个属性类或包装类来间接地给这个属性增加get和set方法。现在来看看如何通过包装类的方法给一个属性增加get和set方法,代码如下所示:
private static class MyView {
private View mTarget;
public MyView(View mTarget) {
this.mTarget = mTarget;
}
public int getWidth(){
return mTarget.getLayoutParams().width;
}
public void setWidth(int width){
mTarget.getLayoutParams().width = width;
mTarget.requestLayout();
}
}
使用时只需要操作包类就可以调用get、set方法了:
MyView myView = new MyVIew(mButton);
ObjectAnimator.ofFloat(myView, "width", 500).setDuration(500).start();
ValueAnimator不提供任何动画效果,它更像一个数值发生器,用来产生有一定规律的数字,从而让调用者控制动画的实现过程。通常情况下,在ValueAnimator的AnimatorUpdateListener中监听数值的变化,从而完成动画的变换,代码如下所示:
ValueAnimator valueAnimator = ValueAnimator.ofFloat(0,100);
valueAnimator.setTarget(view);
valueAnimator.setDuration(1000).start();
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
Float f = (Float) animation.getAnimatedValue();
}
});
完整的动画具有start、Repeat、End、Cancel这4个过程,代码如下所示:
ObjectAnimator animator = ObjectAnimator.ofFloat(view,"alpha",1.5f);
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationCancel(Animator animation) {
super.onAnimationCancel(animation);
}
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
}
@Override
public void onAnimationRepeat(Animator animation) {
super.onAnimationRepeat(animation);
}
@Override
public void onAnimationStart(Animator animation) {
super.onAnimationStart(animation);
}
});
大部分时候我们只关心 onAnimationEnd 事件,Android 也提供了 AnimatorListenterAdaper来让我们选择必要的事件进行监听。
AnimatorSet 类提供了一个 play()方法,如果我们向这个方法中传入一个 Animator 对象(ValueAnimator或ObjectAnimator),将会返回一个AnimatorSet.Builder的实例。AnimatorSet的play()方法源码如下所示:
public Builder play(Animator anim) {
if (anim != null) {
return new Builder(anim);
}
return null;
}
很明显,在play()方法中创建了一个AnimatorSet.Builder类,这个Builder类是AnimatorSet的内部类。
我们来看看这个Builder类中有什么,代码如下所示:
public class Builder {
private Node mCurrentNode;
Builder(Animator anim) {
mDependencyDirty = true;
mCurrentNode = getNodeForAnimation(anim);
}
public Builder with(Animator anim) {
Node node = getNodeForAnimation(anim);
mCurrentNode.addSibling(node);
return this;
}
public Builder before(Animator anim) {
Node node = getNodeForAnimation(anim);
mCurrentNode.addChild(node);
return this;
}
public Builder after(Animator anim) {
Node node = getNodeForAnimation(anim);
mCurrentNode.addParent(node);
return this;
}
public Builder after(long delay) {
// setup dummy ValueAnimator just to run the clock
ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f);
anim.setDuration(delay);
after(anim);
return this;
}
}
从源码中可以看出,Builder类采用了建造者模式,每次调用方法时都返回 Builder 自身用于继续构建。AnimatorSet.Builder中包括以下4个方法。
AnimatorSet正是通过这几种方法来控制动画播放顺序的。很多读者可能还是一头雾水,这里再举一个例子,代码如下所示:
ObjectAnimator animator1 = ObjectAnimator.ofFloat(view,"translationX",0,200f,0);
ObjectAnimator animator2 = ObjectAnimator.ofFloat(view,"scaleX",1f,2f);
ObjectAnimator animator3 = ObjectAnimator.ofFloat(view,"rotationX",0,90f,0);
AnimatorSet set = new AnimatorSet();
set.setDuration(1000);
set.play(animator1).with(animator2).after(animator3);
首先我们创建3个ObjectAnimator,分别是animator1、animator2和animator3,然后创建AnimatorSet。在
这里先执行 animator3,然后同时执行 animator1 和 animator2(也可以调用set.playTogether(animator1,
animator2);来使这两种动画同时执行)。
以上内容摘自《Android进阶之光》