View的事件体系(2)
掌握滑动的方法是实现绚丽的自定义控件的基础。通过三种方式可以实现View
的滑动:
1 是通过
View
本身提供的scrollerTo/scrollerBy
方法来实现滑动;
2 是通过动画给View
施加平移效果来实现滑动;
3 是通过改变View
的LayoutParams
使得View
重新布局从而实现滑动。
3.2.1 scrollTo/scrollBy
为了实现View
的滑动,View
提供了专门的方法来实现这个功能,那就是scrollTo
和scrollBy
,我们先来看看这两个方法的实现。
/**
* Set the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the x position to scroll to
* @param y the y position to scroll to
*/
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();
}
}
}
/**
* Move the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the amount of pixels to scroll by horizontally
* @param y the amount of pixels to scroll by vertically
*/
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
从上面的源码可以看出,scrollBy
实际上也是调用了scrollTo
方法,它实现了基于当前位置的相对滑动,而scrollTo
则实现了基于所传递参数的绝对滑动,这个不难理解。利用scrollTo
和scrollBy
来实现View
的滑动,这不是一件困难的事,但是我们要明白滑动过程中View
内部的两个属性mScrollX
和mScrollY
的改变规则,这两个属性可以通过getScrollX
和getScrollY
方法分别得到。这里先简要概况一下:在滑动过程中,mScrollX
的值总是等于View
左边缘和View内容左边缘在水平方向的距离,而mScrollY
的值总是等于View
上边缘和View
内容上边缘在竖直方向的距离。View
边缘是指View
的位置,由4个顶点组成,而View
内容边缘是指View
中内容的边缘,scrollTo
和scrollBy
只能改变View
内容的位置而不能改变View
在布局中的位置。mScrollX
和mScrollY
的单位为像素,并且当View
左边缘在View
内容左边缘的右边时,mScrollX
为正值,反之为负值;当View
上边缘在View
内容上边缘的下边时,mScrollY
为正值,反之为负值。换句话说,如果从左向右滑动,mScrollX
为负值,反之为正值;如果从上往下滑动,那么mScrollY
为负值,反之为正值。
为了更好地理解这个问题,下面举个例子,在图中假设水平和竖直方向的滑动距离都为100像素,针对图中各种滑动情况,都给出了对应的mScrollX
和mScrollY
的值。根据上面的分析可以知道,使用scrollTo
和scrollBy
来实现View
的滑动,只能将View
的内容进行移动,并不能将View
本身进行移动,也就是说,不管怎么滑动,也不可能将当前View
滑动到附近View
所在的区域,这个需要仔细体会一下。
我写了一个简单的对
scrollTo
和
scrollBy
的应用, 地址
3.2.2 使用动画
本节介绍另外一种滑动方式,即使用动画,通过动画我们能够让一个View
进行平移,而平移就是一种滑动,使用动画来移动View
,主要是操作View
的translationX
和translationY
属性,既可以采用传统的View
动画,也可以采用属性动画,如果采用属性动画的话,为了能够兼容3.0以下的版本,需要采用开源动画库nineoldandroids
(http://nineoldandroids.com/)。
采用View
动画的代码,如下所示。此动画可以在100ms内将一个View
从原始位置向右下角移动100
个像素。
如果采用属性动画的话,就更简单了,以下代码可以将一个View
在100ms
内从原始位置向右平移100
像素。
ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
上面简单介绍了通过动画来移动View
的方法,使用动画来做View
的滑动需要注意一点,View
动画是对View
的影像做操作,它并不能真正改变View
的位置参数,包括宽/高,并且如果希望动画后的状态得以保留还必须将fillAfter
属性设置为true
,否则 动画完成后其动画结果会消失。比如我们要把View
向右移动100
像素,如果fillAfter
为false
,那么在动画完成的一刹那,View
会瞬间恢复到动画前的状态;如果fillAfter
为true
,在动画完成后,View
会停留在距原始位置100
像素的右边。使用属性动画并不会存在上述的问题,但是在Android3.0
以下,无法使用属性动画,这个时候我们可以使用动画兼容库nineoldandroids
来实现属性动画,尽管如此,在Android3.0
以下的手机上通过nineoldandroids
来实现的属性动画本质上仍然是View动画。
上面提到View
动画并不能真正改变View
的位置,这会带来一个很严重的问题。试想一下,比如我们通过View
动画将一个Button
向右移动100px
,并且这个View
设置的有单击事件,然后你会惊奇地发现,单击新位置无法触发onClick
事件,而单击原始位置仍然可以触发onClick
事件,尽管Button
已经不再原始位置了。这个问题带来的影响是致命的,但是它却又是可以理解的,因为不管Button
怎么做变换,但是它的位置信息(四个顶点和宽/高)并不会随着动画而改变,因此在系统眼里,这个Button
并没有发生任何改变,它的真身仍然在原始位置。在这种情况下,单击新位置当然不会触发onClick
事件了,因为Button
的真身并没有发生改变,在新位置上只是View
的影像而已。基于这一点,我们不能简单地给一个View
做平移动画并且还希望它在新位置继续触发一些单击事件。
从Android3.0
开始,使用属性动画可以解决上面的问题,但是大多数应用都需要兼容到Android2.2
,在Android2.2
上无法使用属性动画,因此这里还是会有问题。那么这种问题难道就无法解决了吗?也不是的,虽然不能直接解决这个问题,但是还可以间接解决这个问题,这里给出一个简单的解决方法。针对上面View
动画的问题,我们可以在新位置预先创建一个和目标Button
一模一样的Button
,它们不但外观一样连onClick
事件也一样。当目标Button
完成平移动画后,就把目标Button
隐藏,同时把预先创建的Button
显示出来,通过这种间接地方式我们解决了上面的问题。这仅仅是参考,面对这种问题时读者可以灵活应对。
3.23 改变布局参数
本节将介绍第三种实现View
滑动的方法,那就是改变布局参数,即改变LayoutParams
。这个比较好理解了,比如我们想把一个Button
向右平移100px
,我们只需要将这个Button
的LayoutParams
里的marginLeft
参数的值增加100px
即可,是不是很简单呢?还有一种情形,为了达到移动Button
的目的,我们可以在Button
的左边放置一个空的View
,这个空View
的默认宽度是0,当我们需要向右移动Button
时,只需要重新设置空View
的宽度即可,当空View
的宽度增大时(假设Button
的父容器是水平方向的LinearLayout
),Button
就自动被挤向右边,即实现了向右平移的效果。如何重新设置一个View
的LayoutParams
呢?很简单,如下所示。
MarginLayoutParams params = (MarginLayoutParams) mButton1.getLayoutParams();
params.width += 100;
params.leftMargin += 100;
mButton1.requestLayout();
//或者mButton1.setLayoutParams(params);
通过改变LayoutParams
的方式去实现View
的滑动同样是一种很灵活的方法,需要根据不同情况去做不同的处理。
3.2.4 各种滑动方式的对比
上面分别介绍了三种不同的滑动方式,它们都能实现View
的滑动,那么它们之间的差别是什么呢?
先看scrollTo
和scrollBy
这种方式,它是View
提供的原生方法,其作用是专门用于View
的滑动,它可以比较方便地实现滑动效果并且不影响内部元素的单击事件。但是它的缺点也是很显然的:它只能滑动View
的内容,并不能滑动View
本身。
再看动画,通过动画来实现View
的滑动,这要分情况。如果是Android3.0
以上并采用属性动画,那么采用这种方式没有明显的缺点;如果是使用View
动画或者在Android3.0
以下使用属性动画,均不能改变View
本身的属性。在实际使用中,如果动画元素不需要响应用户的交互,那么使用动画来做滑动是比较合适的,否则就不太适合。但是动画有一个很明显的优点,那就是一些复杂的效果必须要通过动画才能实现。
最后再看一下改变布局这种方式,它除了使用起来麻烦点以外,也没有明显的缺点,它的主要适用对象是一些具有交互性的View
,因为这些View
需要和用户交互,直接通过动画去实现会有问题,所以这个时候我们可以使用直接改变布局参数的方式来实现。
针对上面的分析做一下总结,如下所示。
1.
scrollTo/scrollBy
:操作简单,适合对View
内容的滑动
2.动画:操作简单,主要适用于没有交互的View
和实现复杂的动画效果
3.改变布局参数:操作稍微复杂,适用于有交互的View
。
下面我们实现一个跟手滑动的效果,这是一个自定义View
,拖动它可以让它在整个屏幕上随意滑动。这个View
实现起来很简单,我们只要重写它的onTouchEvent
方法并处理ACTION_MOVE
事件,根据两次滑动之间的距离就可以实现它的滑动了。为了实现全屏滑动,我们采用动画的方式来实现。原因很简单,这个效果无法使用scrollTo
来实现。另外,它还可以采用改变布局的方式来实现。
核心代码如下:
@Override
public boolean onTouchEvent(MotionEvent event) {
// 得到相对于屏幕左上角的距离
int x = (int) event.getRawX();
int y = (int) event.getRawY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
Log.d("TAG","deltaX:" + deltaX + "deltaY:" + deltaY);
int translationX = (int) (ViewHelper.getTranslationX(this) + deltaX);
int translationY = (int) (ViewHelper.getTranslationY(this) + deltaY);
ViewHelper.setTranslationX(this,translationX);
ViewHelper.setTranslationY(this,translationY);
break;
case MotionEvent.ACTION_UP:
break;
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
通过上述代码可以看出,首先我们通过getRawX
和getRawY
方法来获取手指当前的坐标,注意不能使用getX
和getY
方法,因为这个是要全屏滑动的,所以需要获取当前点击事件在屏幕中的坐标而不是相对于View
自身的坐标。其次,我们要得到两次滑动之间的位移,有了这个位移就可以移动当前的View
,移动方法采用的是动画兼容库nineoldandroids
中的ViewHelper
类所提供的setTranslationX
和setTranslationY
方法。实际上,ViewHelper
类提供了一系列get/set
方法,因为View
的setTranslationX
和setTranslationY
只能在Android3.0
及其以上版本才能使用,但是ViewHelper
所提供的方法是没有版本要求的,与此类似的还有setX
,setScaleX
,setAlpha
等方法,这一系列方法实际上是为属性动画服务的,这个自定义View
可以在2.x
及其以上版本工作,但是由于动画的性质,如果给它加上onClick
事件,那么在3.0以下版本它将无法在新位置响应用户的点击。
3.3 弹性滑动
知道了View
的滑动,我们还要知道如何实现View
的弹性滑动,比较生硬地滑动过去,这种方式的用户体验实在太差了,因此我们要实现渐进式滑动。那么如何实现弹性滑动呢?其实实现方法有很多,但是它们都有一个共同思想:将一次大的滑动分成若干次小的滑动并在一个时间段内完成。弹性滑动的具体实现方式有很多,比如通过Scroller
,Handler#postDelayed
以及Thread#sleep
等,下面一一进行介绍。
3.3.1 使用Scroller
Scroller
的使用方法在前面已经介绍过了,我们来分析一下它的源码,从而探究为什么它能实现View
的弹性滑动。
Scroller scroller = new Scroller(getContext());
private void smoothScrollTo(int destX,int destY){
int scrollX = getScrollX();
int deltaX = destX - scrollX;
//1000ms内滑向destX,效果是慢慢滑动
scroller.startScroll(scrollX,0,deltaX,0,1000);
invalidate();
}
@Override
public void computeScroll() {
if(scroller.computeScrollOffset()){
scrollTo(scroller.getCurrX(),scroller.getCurrY());
postInvalidate();
}
}
上面是Scroller
的典型的使用方法,这里先描述它的工作原理:当我们构造一个Scroller
对象并且调用它的startScroller
方法时,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;
}
这个方法的参数含义很清楚,startX
和startY
表示的是滑动的起点,dx
和dy
表示的是要滑动的距离,而duration
表示的是滑动时间,即整个滑动过程完成所需要的时间,注意这里的滑动是指View
内容的滑动而非View
本身位置的改变。可以看到,仅仅调用startScroll()
方法是无法让View滑动的,因为它内部并没有做滑动相关的事,那么Scroller
到底是如何让View
弹性滑动的呢?答案就是startScroll
方法下面的invalidate
方法。虽然有点不可思议,但是的确是这样的。invalidate()
方法会导致View
的重绘,在View
的draw()
方法中又会去调用computeScroll()
方法,computeScroll()
方法在View
中是一个空实现,因此需要我们自己去实现,上面的代码已经实现了computeScroll()
方法。正是因为这个computeScroll()
方法,View
才能实现弹性滑动;这看起来还是很抽象,其实是这样的:当View
重绘后,会在draw()
方法中调用computeScroll()
,而computeScroll()
又会去向Scroller
获取当前的scrollX
和scrollY
;然后通过scrollTo()
方法实现滑动;接着又调用postInvalidate()
方法来进行第二次重绘,这一次重绘的过程和第一次重绘一样,还是会导致computeScroll()
方法被调用;然后继续向Scrolle
r获取当前的scrollX
和scrollY
,并通过scrollTo
方法滑动到新的位置,如此反复,直到整个滑动过程结束。
我们再看一下Scroller的computeScrollOffset()方法的实现。
/**
* Call this when you want to know the new location. If it returns true,
* the animation is not yet finished.
*/
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;
}
这个方法会根据时间的流逝来计算出当前的scrollX和scrollY的值。计算方法也很简单,大意就是根据时间流逝的百分比来算出scrollX和scrollY改变的百分比并计算出当前的值,这个过程类似于动画中的插值器的概念,这里我们先不去深究这个具体过程。这个方法的返回值也很重要,它返回true表示滑动还未结束,false表示滑动已经结束,因此当这个方法返回true时,我们要继续进行View的滑动。
通过上面的分析,我们应该明白Scroller的工作原理了,这里做一下概括:Scroller本身并不能实现View的滑动,它需要配合View的computeScroll方法才能完成弹性滑动的效果,它不断地让View重绘,而每一次重绘距滑动起始时间会有一个时间间隔,通过这个时间间隔Scroller就可以得出View当前的滑动位置,知道了滑动位置就可以通过scrollTo方法来完成View的滑动。就这样,VIew的每一次重绘都会导致VIew进行小幅度的滑动,而多次的小幅度滑动就组成了弹性滑动,这就是Scroller的工作机制。
3.3.2 通过动画
动画本身就是一种渐进的过程,因此通过它来实现的滑动天然就具有弹性效果,比如以下代码可以让一个View在100ms内向右移动100像素。
ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
不过这里想说的并不是这个问题,我们可以利用动画的特性来实现一些动画不能实现的效果。还拿scrollTo来说,我们也想模仿Scroller来实现View的弹性滑动,那么利用动画的特性,我们可以采用如下方式来实现:
final int startX = 0;
final int deltaX = 100;
final ValueAnimator animator = ValueAnimator.ofInt(0,1).setDuration(1000);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
float fraction = animator.getAnimatedFraction();
mButton1.scrollTo(startX + (int) (deltaX * fraction),0);
}
});
animator.start();
在上述代码中,我们的动画本质上没有作用于任何对象上,它只是在1000ms内完成了整个动画过程。利用这个特性,我们就可以在动画的每一帧到来时获取动画完成的比例,然后在根据这个比例计算出当前View所要滑动的距离。注意,这里的滑动针对的是View的内容而非View的本身。可以发现,这个方法的思想其实和Scroller比较类似,都是通过改变一个百分比配合scrollTo方法来完成View的滑动。需要说明一点,采用这种方法除了能够完成弹性滑动以外,还可以实现其他动画效果,我们完全可以在onAnimationUpdate()方法中加上我们想要的其他操作。
3.3.3 使用延时策略
本节介绍另外一种实现弹性滑动的方法,那就是延时策略。它的核心思想是通过发送一系列延时消息从而达到一种渐进式的效果,具体来说可以使用Handler或View的postDelayed()方法,也可以使用线程的sleep()方法。对于postDelayed()方法来说,我们可以它来延时发送一个消息,然后在消息中来进行View的滑动,如果接连不断地这种延时消息,那么就可以实现弹性滑动的效果。对于sleep()方法来说,通过在View循环中不断地滑动View和sleep,就可以实现弹性滑动的效果。
下面来使用Handler来做个示例,其他方法请自行去尝试,思想都是类似的。下面的代码在大约1000ms内将View的内容向左移动了100像素,代码比较简单。之所以说大约1000ms,是因为采用这种方式无法精确地定时,原因是系统的消息调度也是需要时间的,并且所需时间不定。
private static final int MESSAGE_SCROLL_TO = 1;
private static final int FRAME_COUNT = 30;
private static final int DELAYED_TIME = 33;
private int mCount = 0;
@SuppressLint("HandlerLeak")
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MESSAGE_SCROLL_TO:
mCount++;
if (mCount <= FRAME_COUNT) {
float fraction = mCount / (float) FRAME_COUNT;
int scrollX = (int) (fraction * 100);
mButton1.scrollTo(scrollX,0);
mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO,DELAYED_TIME);
}
break;
default:
break;
}
}
};
上面几种弹性滑动的实现方法,在介绍中侧重更多的是实现思想,在实际使用中可以对其灵活地进行扩展从而实现更多复杂的效果。