第八章 Scorller 与平滑滚动
8.1 概述
Scroller 译为“滚动器”,是 ViewGroup 类中原生支持的一个功能。我们经常有这样的体验;打开联系人,手指向上滑动,联系人列表也会跟着一起滑动。但是,我们松手之后,滑动并不会因此而停下。而是伴随着一段惯性继续滑动。最后才慢慢的停止。这样的用户体验完全照顾了人的习惯和对事物的感知,是一种非常舒服的自然操作。要实现这样的功能,需要 Scroller 类的支持 Scroller 类并不负责“滚动”这个动作,只是根据滚定的其实位置和结束位置生成中间过渡位置,从而形成一个滚动的动画。这一点至关重要 所谓的“滚动”,事实上就是一个不断刷新 View 的绘图过程,给定一个起始位置、结束位置、滚动的持续时间,Scroller 自动计算中间位置和滚动节奏,而调用 invalidate( )方法不断刷新。从这点看,好像也不是那么复杂。 还有一点需要强调的是,一个 View 的滚动不是自身发起的动作。而是,由父容器驱动子组件来完成的。换句话说,需要 Scroller 和 ViewGroup 的配合才能产生滚至二个过程。所以,我们不要误以为是 View 自己在滚动。显然不是,而是容器让子组件滚动,主动权在 ViewGroup 手中。View 也可以滚动,但是滚动的不是自己,而是 View 中的内容 滚动往往为两个阶段:第一个阶段是手指在屏幕上滑动,容器的子组件跟随手指的速率一起滑动,当手指松开后,进入第二个阶段—— 惯性滚动,滚动不会马上停止。而而是,给出一个负的加速度,滚动速度会越来越慢,直到最后处于静态状态。这符合 Android 中很多组件的使用场景8.2 认识 scrollTo( ) 和 scrollBy( )方法
View 类中有两个与滚动有关的方法——scrollTo( ) 和 ScrollBy( )方法,这两个方法的源码如下: 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);
}
scrollTo(int x,inty)方法中,参数 x,y 是目标位置,方法先判断新的滚动位置是否确实发生了变化。;如果是,先保存上一次的位置,在应用这一次的心位置(x,y),接着调用 invalidateParentCaches()方法,并刷新 View组件。scrollTo( )方法表示“滚动到……”之意
scrollBy(int x,int y)方法则不同。是在原来的基础上水平滚动 x 个距离,垂直方向滚动 y 个距离。最终还是调用 scrollTo(int x,int y)方法。本质上讲,之二两个方法是一样的。scrollBy( )方法表示“滚动了……”之意
通过下面的示例来帮助了解 scrollTo( ) 和 scrollBy( )方法。并了解这两个 方法给组件带来的影响。定义一个 TextView 组件,并放了两个 button,两个按钮 分别调用 scrollTo( ) 和 scrollBy( )两个方法,并实现相同功能。创建 layout.xml如下:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="android.ceshi.MainActivity"
android:orientation="vertical"
>
<TextView
android:id="@+id/tv"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="#99CCCCCC"
android:text="@string/hello_world" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal"
>
<Button
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:onClick="scrollBy"
android:text="scrollBy"
/>
<Button
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:onClick="scrollTo"
android:text="scrollTo"
android:background="@android:color/darker_gray"
/>
</LinearLayout>
</LinearLayout>
public void scrollBy(View view){
tv.scrollBy(-5, 0) ;
}
public void scrollTo(View view){
int x = tv.getScrollX() ;
int y = tv.getScrollY() ;
tv.scrollTo(x-5, y) ;
System.out.println("[x=" + x + ",y=" + y + "]");
}
scrollBy( )方法是第一个按钮的点击事件,调用了 tv.scrollBy(-5,0) 语句,表示 x 方向每次移动 5 个单位距离。y 轴不变;scrollTo( )方法是第二个按钮的点击事件,先调用 tv.getScrollX() 和 tv.getScrollY() 获取当前 tv 对象的滚动距离,再通过 tv.srcollTo(x-5,y)方法在 x 方向移动 5 个单位距离,y 不变。这两个方法的 功能实现是相同的。运行如下 :
通过观察运行结果,大致可以得出以下几个结论
- 移动的并不是 view 组件自身,而是组件的内容。当我们点击按钮时,是按钮上的文字的位置在向右移动
- 因为移动的是 view 组件的内容。所以,我们发现其方向与图像坐标系相反。也就是说:scrollBy( )方法在 x 方向上参数为负时,向右移动;为正时,想做移动。y 方向的参数为 负时,向下移动;为正时,向上移动。scrollTo( )方法的新坐标比原坐标小,x 方向向右移,y 方向向下移。反之亦然
我们可能会疑惑为什么滚动子组件的时候方向与我们的习惯是相反的。其实通过下面的源码片段能帮助我们理解。启动滚动后,调用 invalidate( )方法刷新绘制。在该方法中,有如下的实现:
....
public void invalidate(int l, int t, int r, int b) {
final int scrollX = mScrollX;
final int scrollY = mScrollY;
invalidateInternal(l - scrollX, t - scrollY, r - scrollX, b - scrollY, true, false);
}
....
l - scrollX, t - scrollY, r - scrollX, b - scrollY 用于重新定义自组件的位子和大小,。通过下面的小示例来演示 scrollTo( )和scrollBy( )方法对布局容器的影响。布局内容如下:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="android.ceshi.MainActivity"
android:orientation="vertical"
android:id="@+id/linearlayout"
>
<TextView
android:id="@+id/tv"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="#99CCCCCC"
android:text="@string/hello_world" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal"
>
<Button
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:onClick="scrollBy"
android:text="scrollBy"
/>
<Button
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:onClick="scrollTo"
android:text="scrollTo"
android:layout_marginLeft="10dp"
/>
</LinearLayout>
</LinearLayout>
在实例中,我们为 LinreaLayout 布局定义了 id 为 linearlayou,并且调用了对象的 scrollTo( )和scrollBy( )方法,一观察对 LinearLayout 布局的影响。
public void scrollBy(View view){
layout.scrollBy(-5, 0) ;
}
public void scrollTo(View view){
int x = layout.getScrollX() ;
int y = layout.getScrollY() ;
layout.scrollTo(x-5, y) ;
System.out.println("[x=" + x + ",y=" + y + "]");
}
运行效果和上图一样,当调用 layout.scrollBy(-5, 0)和layout.scrollTo(x-5, y)方法移动的并不是 LinreaLayout 本身,而是移动 LinreaLayout中的子组件。一个 TextView,和 两个 botton。
在 View 中,还定义获取滚动距离的方法,方法的原型如下
- public final int getScrollX()
- 返回 x 方向滚动过的距离,也就是当前 view 的左上角的 x 轴偏移量
- public final int getScrollY()
- 返回 y 方向滚动过的距离,也是当前 view 的左上角相对于父视图的左上角的 y 轴偏移量
8.3 Scroll 类
Scroll 类在滚动过程的几个主要作用如下:- 启动滚动动作
- 根据提供的滚动目标位置和持续时间计算出中间的过渡位置
- 判断滚动是否结束
- 介入 View 或 ViewGroup 的重绘流程,从而形成滚动动画
Scroller 类虽然对滑动作用非同小可但定义的方法并不多,下面是一些 scroller 的方法说明
- public Scroller(Context context)
- public Scroller(Context context, Interpolator interpolator)
- public Scroller(Context context, Interpolator interpolator, boolean flywheel)
- 构造方法,interpolator 指定插值器。如果没有指定,默认插值器为 ViscousFluidInterpolato,flywheel 参数为 true 可以提供类似“飞轮”的行为
- public final void setFriction(float friction)
- 设置一个摩察系数,默认为 0.015f。摩察系数决定惯性滑行的距离
- public final int getStartX()
- 返回起始 x 坐标
- public final int getStartY()
- 返回起始的 y 坐标值
- public final int getFinalX()
- 返回结束的 x 坐标值
- public final int getFinalY()
- 返回结束的 y 坐标值
- public final int getCurrX()
- 返回滚动过程中的 x 坐标值,滚动时胡提供 startX(起始)和 finalX(结束),currX 根据这二个值计算而来
- public final int getCurrY()
- 返回滚动过程中的 y 坐标值,滚动时胡提供 startY(起始)和 final Y(结束),currY 根据这二个值计算而来
- public boolean computeScrollOffset()
- 计算滚动偏移量,必调用方法之一。主要负责计算 currX 和 currY 两个值,其返回值为 true 表示滚动尚未结束。为 false 表示滚动已结束
- public void startScroll(int startX, int startY, int dx, int dy)
- public void startScroll(int startX, int startY, int dx, int dy, int duration)
- 启动滚动行为,startX 表示起始位置,dx、dy 表示要滚动的 x、y 方向的距离,duration 表示持续的时间,默认为 250 毫秒
- public final boolean isFinished()
- 判断滚动是否已结束,返回 true 表示已结束
- public final void forceFinished(boolean finished)
- 强制结束滚动,currX、currY 即为当前坐标
- public void abortAnimation()
- 与 forceFinish 功能类似,停止滚动。但 currX‘currY 设置为终点坐标’
- public int timePassed()
- 返回已滚动的耗时,单位(毫秒)
- public void setFinalX(int newX)
- 设置终止位置的 x 坐标,可能需要调用 extendDuration( )延长激活缩短动画的时间
- public void setFinalY(int newY)
- 设置终止位置的 Y 坐标,可能需要调用 extendDuration( )延长或缩短动画时间
上面的方法中,常用的主要方法有 startScroll( )、computeScrollOffset( )、getCurrX( )、getCurrY( ) 和 abortAnimation( )等几个方法,通过下面的实例来辅助了解一下。定义一个 BaseScrollerViewGroup 的类,继承自 ViewGroup,在该类中使用代码(非配置),定义一个子组件 Button。为了将重点放在 Scroller 类使用上。BaseScrollerViewGroup 在定义时进行了简单化定义。比如,宽高固定死,子组件固定死。
public class BaseScrollerViewGroup extends ViewGroup {
private Scroller scroller ;
private Button btnAndroid ;
public BaseScrollerViewGroup(Context context) {
this(context,null);
}
public BaseScrollerViewGroup(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public BaseScrollerViewGroup(Context context, AttributeSet attrs,
int defStyle) {
super(context, attrs, defStyle);
scroller = new Scroller(context) ;
btnAndroid = new Button(context) ;
LayoutParams params = new LayoutParams(
LayoutParams.WRAP_CONTENT,LayoutParams.WRAP_CONTENT);
btnAndroid.setText("Android Scroller");
this.addView(btnAndroid,params);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if(MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST
|| MeasureSpec.getMode(heightMeasureSpec)== MeasureSpec.AT_MOST)
throw new IllegalStateException("Must be MeasureSpec.EXACTLY.");
measureChildren(widthMeasureSpec, heightMeasureSpec);
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),MeasureSpec.getSize(heightMeasureSpec));
}
@Override
protected void onLayout(boolean arg0, int arg1, int arg2, int arg3, int arg4) {
btnAndroid.layout(10, 10, btnAndroid.getMeasuredWidth(), btnAndroid.getMeasuredHeight());
}
/**实现平滑滚动*/
@Override
public void computeScroll() {
if(scroller.computeScrollOffset()){
//设置容器内组件的新位置
this.scrollTo(scroller.getCurrX(), scroller.getCurrY()) ;
Log.i("info", "currX = "+scroller.getCurrX()+",currY = "+scroller.getCurrY()+ ",timePass = " + scroller.timePassed());
postInvalidate();
}
}
/**开始滚动,外部调用*/
public void start(){
//从当前位置滚动,x 方向向右 900,y 方向不变的水平滚动
scroller.startScroll(this.getScrollX(), this.getScrollY(),
-900,0,50000);
postInvalidate();
}
/**取消滚动*/
public void abort(){
scroller.abortAnimation();
}
}
Log的打印
12-01 22:48:05.299: I/info(2106): currX = 0,currY = 0,timePass = 3 12-01 22:48:05.315: I/info(2106): currX = 0,currY = 0,timePass = 21 12-01 22:48:05.331: I/info(2106): currX = 0,currY = 0,timePass = 37 12-01 22:48:05.347: I/info(2106): currX = 0,currY = 0,timePass = 53 12-01 22:48:05.367: I/info(2106): currX = 0,currY = 0,timePass = 71 12-01 22:48:05.383: I/info(2106): currX = 0,currY = 0,timePass = 87 12-01 22:48:05.399: I/info(2106): currX = 0,currY = 0,timePass = 103 12-01 22:48:05.415: I/info(2106): currX = 0,currY = 0,timePass = 121 12-01 22:48:05.431: I/info(2106): currX = 0,currY = 0,timePass = 137 12-01 22:48:05.447: I/info(2106): currX = 0,currY = 0,timePass = 154 12-01 22:48:05.467: I/info(2106): currX = 0,currY = 0,timePass = 171 12-01 22:48:05.483: I/info(2106): currX = 0,currY = 0,timePass = 187 12-01 22:48:05.499: I/info(2106): currX = 0,currY = 0,timePass = 204 12-01 22:48:05.515: I/info(2106): currX = -1,currY = 0,timePass = 221 12-01 22:48:05.531: I/info(2106): currX = -1,currY = 0,timePass = 237 12-01 22:48:05.547: I/info(2106): currX = -1,currY = 0,timePass = 254 12-01 22:48:05.563: I/info(2106): currX = -1,currY = 0,timePass = 270 12-01 22:48:05.583: I/info(2106): currX = -1,currY = 0,timePass = 288 12-01 22:48:05.599: I/info(2106): currX = -1,currY = 0,timePass = 304 12-01 22:48:05.615: I/info(2106): currX = -1,currY = 0,timePass = 320 12-01 22:48:05.631: I/info(2106): currX = -1,currY = 0,timePass = 337 12-01 22:48:05.647: I/info(2106): currX = -1,currY = 0,timePass = 354 12-01 22:48:05.667: I/info(2106): currX = -2,currY = 0,timePass = 371 12-01 22:48:05.687: I/info(2106): currX = -2,currY = 0,timePass = 392 12-01 22:48:05.699: I/info(2106): currX = -2,currY = 0,timePass = 403 12-01 22:48:05.715: I/info(2106): currX = -2,currY = 0,timePass = 420 12-01 22:48:05.731: I/info(2106): currX = -2,currY = 0,timePass = 437 12-01 22:48:05.747: I/info(2106): currX = -2,currY = 0,timePass = 453 12-01 22:48:05.767: I/info(2106): currX = -2,currY = 0,timePass = 470 12-01 22:48:05.783: I/info(2106): currX = -3,currY = 0,timePass = 488 12-01 22:48:05.799: I/info(2106): currX = -3,currY = 0,timePass = 503 12-01 22:48:05.815: I/info(2106): currX = -3,currY = 0,timePass = 520 12-01 22:48:05.835: I/info(2106): currX = -3,currY = 0,timePass = 542 12-01 22:48:05.847: I/info(2106): currX = -3,currY = 0,timePass = 554 12-01 22:48:05.867: I/info(2106): currX = -4,currY = 0,timePass = 571 12-01 22:48:05.883: I/info(2106): currX = -4,currY = 0,timePass = 587我们首先定义了一个 SCroll 类型的成员变量 scroller,并在构造方法中进行了实例化。重点是重写了 ViewGroup 的 computeSrcoll( )方法,该方法的默认实现是空方法。在绘制 View 时调用。在 computeScroll( )方法中,调用 scroller.computeScrollOffset( )计算下一个位置的坐标值 currX,currY ;在通过 this.scrollTo(scroller.getCurrX,scroller.getCurrY)语句移动到该坐标。特别要注意的是一定要调用 invalidate( ) 或 postInvalidate( )方法重绘。一旦, computeSrcollOffset( )方法返回 false 表示滚动结束,停止重绘。 另外,我们还定义了两个用来与外部交互的方法,start( ) 和 abort( )。start( )方法用于启动滚动动作,执行了 scroller.startScroll(this.getScrollX(), this.getScrollY(),-900,0,50000),其中参数 this.getSrcoll 和 this.getScrollY 是容器内容的初始位置。x 方法向右移动 900个单位距离(为负表示向右),y 方向不变,也就是水平向右移动。为了更好的查看动画古城,将滚动的持续性时间设为 5 秒。在 abort( )方法中调用了 scroller.abortAnimation( ) 用来停止滚动
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="android.ceshi.ThreeActivity" >
<android.ceshi.widget.BaseScrollerViewGroup
android:id="@+id/scroll_layout"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal"
android:layout_alignParentBottom="true"
>
<Button
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:onClick="start"
android:text="开始滚动" />
<Button
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:onClick="abort"
android:text="停止滚动"
android:layout_marginLeft="5dp"
/>
</LinearLayout>
</RelativeLayout>
8.4 平滑滚动的工作原理
从 8.3 小节中我们可以总结出平滑滚动的基本工作流程- 调用 scroll 的 startScroll( )方法定义滚动的其实位置和滚动的距离
- 通过 invalidate( ) 或 postInvalidate( )方法刷新,调用 draw(Cavas) 方法重绘组件
- 通过 computeScroll( )计算下一个位置的坐标
- 再次调用 invalidate( ) 或 postInvalidate( )方法刷新重绘
- 判断 computeScroll( )方法返回值,如果为false 表示结束滚动,为 true 表示继续滚动
上面的步骤其实构建了一个方法调用循环 1 -> 2 -> 3 ->4 -> 5 ->3 -> 4 -> 5 ….. ->3 -> 4 -> 5 就是一个循环,该循环用于不断计算下一个位置,并通过重绘移动到这个位置,这样就产生了动画的效果
我们通过阅读源码片段进一步了解平滑的工作原理,当调用 invalidate( )方法或 postInvalidate( )方法后(详见 3.2),将重绘请求发送到 viewRoot。再分发带对应的组件,调用 draw(Canvas canvas)方法
public void draw(Canvas canvas) {
....
// Step 4, draw the children
dispatchDraw(canvas);
....
}
在 draw(Canvas canvas)方法又调用 dispatchDraw(canvas)方法,该方法负责将绘制请求分发给组件
protected void dispatchDraw(Canvas canvas) {
...
more |= drawChild(canvas, child, drawingTime);
..
}
在 dispatchDraw(Canvas canvas)方法又调用了 drawChild( )方法完成子组件的绘制工作
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
...
child.computeScroll();
...
}
重点来了,drawChild( )方法又调用了 computeScroll( )方法。但是,该方法是一个空方法,什么也没有。我们需要重写该方法才能实现平滑滚动
8.5 案例:触摸滑屏
8.5.1 触摸滑屏技术分析
Android 手机一个最明显的表示就是进入桌面后可以左右滑屏查看 App 应用图标。和 windowsXP 的卓淼有显著区别。这也给有限的桌面带来了无限的空间,和垂直滑动显示内容相比,左右滑动更方便用户的手势操作,带来更好的用户体验,并获得用户一直认可触摸滑屏分为两个过程:一是,手指在屏幕上滑动时屏幕跟随一起滑动。滑动速度与手指速度相同。现在的硬件质量有很大提升,基本上很是黏手的。二是,手指松开后,根据手指的速度、已滑动距离判断屏幕是要回滚还是滑动到下一屏。这两个过程共同构成了滑屏的基本动作
如果熟悉 Android 的时间处理机制(见下面参考链接),一定清楚 public boolean onIntercept TouchEvent(MotionEvent ev ) 方法的作用。主要用于拦截事件。事件一旦被拦截,并无法将事件传递给子组件。触摸滑动时,必须考虑这个问题。当屏幕正处于滑动状态时,容器内的子组件便不再接受任何事件,onInterceptTouchEvent( ) 方法必须返回 true,事件便绕过子组件往回传递。所以,我们必须在该方法中判断用户手指的状态是不是滑动状态。如果是滑动状态,返回 true,否则返回 false 值。
触摸滑动事件的操作在 public boolean onTouchEvent(MotionEvent event) 方法中完成。手指按下时,判断是否转正在滑动。如果是,这马上停止,同时记下手指的初始坐标。手指移动过程中,获取手指移动的距离。并让容器内容以相同的方向移动相同的距离。手指松开后,根据手指移动速度和已移动的距离判断是要回滚还是移动到下一屏。
ViewGroup 的内容区域是无限大的,我们可以将无数组件都放进去,但因为屏幕空间有限,所以只能看见一部分内容。就像运行中的游戏,场景很大,但是看到的确实很少。要实现触摸分屏,必须将容器内的每个子组件设置成语屏幕大小相同,但一次只显示其中的一个。
8.5.2 速度跟踪器 VelocityTracker
VelovityTracker 主要用于跟踪触摸事件 (fling 事件 和 其他 gestures 手势事件 )的速率。当我们要跟踪一个 touch 事件的时候,使用 obtain( ) 方法得到这个类的实例。然后用 addMovement(MotionEvent) 函数将你接受的 motion event 加入到 VelocityTracker 类实例中。当我们需要使用到速率时,使用 computeCurrentVelocity(int)初始化速率单位。并获得当前时间的速率,然后使用 getXVelocity() 或 getXVelocity() 获得横向和竖向的速率。另外,通过 VelocityTracker 还可以知道手指的滑动方向VelocityTracker 的基本使用如下 :
- 手指按下时 (ACTION_DOWN),获取 VelocityTracker 对象
if(velocityTracker == null){ //创建velocityTracker对象 velocityTracker = VelociotyTranker.obtain(); } //关联事件对象 velocityTracker.addMovement(MotionEvent event);
- 手指移动过程中(ACTION_MOVE),计算速率
velocityTracker.computeCurrentVelocity(1000); 获取 x、y 两个方向的速率 int velocityX = velocityTracker.getXVelocity(); int velocityY = velocityTracker.getYVelocity();
- 手指松开后(ACRION_UP),释放并回收资源
//释放 VelocityTracker 资源 if(velocityTracker != null){ velocityTracker.clear(); velocityTracker.recycle(); velocityTracker = null; }
8.5.3 触摸滑屏的分步实现
定义一个容器类 MultiLauncher 继承自 ViewGroup,容器类的子组件将容器大小相同。先上代码,在写步骤 :public class MultiLauncher extends ViewGroup {
private static final String TAG = "info" ;
//停止
private static final int TOUCH_STATE_STOP = 0X001 ;
//滑动
private static final int TOUCH_STATE_FLING = 0X002 ;
//当前状态
private int touchState = TOUCH_STATE_STOP ;
//最小滑动距离,超过了,才认为开始滑动
private int touchSlop = 0 ;
//上次触摸屏的 x 位置
private float lastionMotionX = 0 ;
private static final int SNAP_VELOCITY = 100;
private Scroller scroller ;
//当前屏
private int curScreen ;
//速率跟踪器
private VelocityTracker velocityTracker ;
public MultiLauncher(Context context) {
this(context,null);
}
public MultiLauncher(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public MultiLauncher(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
scroller = new Scroller(context) ;
touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
measureChildren(widthMeasureSpec, heightMeasureSpec) ;
int width = this.measureWidth(widthMeasureSpec) ;
int height = this.measureHeight(heightMeasureSpec);
setMeasuredDimension(width, height);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int n = this.getChildCount() ;
int w = (r - l) / n ; //分屏的宽
int h = b- t ; //容器的高度
for(int i = 0 ; i < n ; i++){
View child = getChildAt(i) ;
int left = i * w ;
int top = 0 ;
int right = (i + 1) * w;
int bottom = h ;
child.layout(left, top, right, bottom);
}
}
@Override
public void computeScroll() {
if(scroller.computeScrollOffset()){
//设置容器内组件的新位置
this.scrollTo(scroller.getCurrX(), scroller.getCurrY()) ;
Log.i(TAG, "currX = "+scroller.getCurrX()+",currY = "+scroller.getCurrY()+ ",timePass = " + scroller.timePassed());
postInvalidate();
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction() ;
final int x = (int) ev.getX() ;
final int y = (int) ev.getY() ;
if(action == MotionEvent.ACTION_MOVE
&& touchState == TOUCH_STATE_STOP){
return true ;
}
switch (action) {
case MotionEvent.ACTION_DOWN:
lastionMotionX = x ;
touchState = scroller.isFinished() ? TOUCH_STATE_STOP : TOUCH_STATE_FLING ;
break;
case MotionEvent.ACTION_MOVE:
//滑动距离过小,不算滑动
final int dx = (int) Math.abs(x - lastionMotionX ) ;
if(dx > touchSlop){
touchState = TOUCH_STATE_FLING;
}
break ;
case MotionEvent.ACTION_CANCEL :
case MotionEvent.ACTION_UP :
touchState = TOUCH_STATE_STOP ;
break ;
}
return touchState != TOUCH_STATE_STOP ;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if(velocityTracker == null){
velocityTracker = VelocityTracker.obtain();
}
velocityTracker.addMovement(event) ;
super.onTouchEvent(event);
int action = event.getAction();
final int x = (int) event.getX();
switch (action) {
case MotionEvent.ACTION_DOWN:
//手指按下时,如果正在滚动,则立刻停止
if(scroller != null && !scroller.isFinished()){
scroller.abortAnimation();
}
lastionMotionX = x;
break;
case MotionEvent.ACTION_MOVE :
//随手指滑动
int dx = (int) (lastionMotionX - x) ;
scrollBy(dx, 0) ;
lastionMotionX = x ;
Log.i(TAG, "onTouch dx = "+dx + "lasttionMotionX = "+ lastionMotionX + " x = "+x) ;
break ;
case MotionEvent.ACTION_UP :
final VelocityTracker velocityTracker = this.velocityTracker;
velocityTracker.computeCurrentVelocity(1000) ;
int velocityX = (int) velocityTracker.getXVelocity() ;
//通过 velocityX 正负值可以判断滑动方向
if(velocityX > SNAP_VELOCITY && curScreen > 0){
moveToPrevious() ;
}else if(velocityX < -SNAP_VELOCITY && curScreen < getChildCount()-1){
moveToNext();
}else {
moveToDestination();
}
if(velocityTracker != null){
this.velocityTracker.clear();
this.velocityTracker.recycle() ;
this.velocityTracker = null ;
}
touchState = TOUCH_STATE_STOP;
break;
case MotionEvent.ACTION_CANCEL:
touchState = TOUCH_STATE_STOP;
break;
}
return true;
}
/**进行具体的自行滚动操作*/
public void moveToScreen(int whichScreen){
Log.i(TAG, " -------- moveToScreen -------- ") ;
curScreen = whichScreen ;
Log.i(TAG, "当前屏(curScreen) = " + curScreen) ;
if(curScreen > getChildCount() -1 ){ //最后一个
curScreen = getChildCount() - 1;
}
if(curScreen < 0){ //第一个
curScreen = 0;
}
int scrollX = this.getScrollX() ;
//每一屏的宽度
int splitWidht = getWidth() / getChildCount() ;
//要移动的距离
int dx = (curScreen * splitWidht) - scrollX ;
Log.i(TAG, "sceollX = "+scrollX + "_要移动的距离 (dx) = "+dx + "_单屏的宽度(splitWidht) = "+splitWidht);
//开始移动
scroller.startScroll(scrollX, 0, dx, 0,1000) ;
invalidate();
}
/**计算为那一屏(回滚或者进入下一屏)*/
public void moveToDestination(){
Log.i(TAG, " -------- moveToDestination -------- ") ;
//每一屏的宽度
int splitWidht = getWidth() / getChildCount() ;
//判断是回滚还是进入下一屏
int toScreen = (getScrollX() + (splitWidht /2)) / splitWidht;
//移动到目标分屏
moveToScreen(toScreen);
}
/**滚动到下一屏*/
public void moveToNext(){
moveToScreen(curScreen + 1);
}
/**滚动到上一屏*/
public void moveToPrevious(){
moveToScreen(curScreen - 1);
}
private int measureWidth(int widthMeasureSpec){
int mode = MeasureSpec.getMode(widthMeasureSpec);
int size = MeasureSpec.getSize(widthMeasureSpec) ;
int width = 0 ;
if(mode == MeasureSpec.AT_MOST){
throw new IllegalAccessError("layout must match_parent") ;
}else if(mode == MeasureSpec.EXACTLY){
width = size;
}
return width * this.getChildCount() ;
}
private int measureHeight(int heightMeasureSpec){
int mode = MeasureSpec.getMode(heightMeasureSpec) ;
int size = MeasureSpec.getSize(heightMeasureSpec) ;
int height = 0;
if(mode == MeasureSpec.AT_MOST){
throw new IllegalAccessError("layot must match_parent") ;
}else if(mode == MeasureSpec.EXACTLY){
height = size ;
}
return height ;
}
}
大致步骤
- 第一步:初始化。平滑滚动需要使用的 scroller 对象,另外还需要给定一个最小的滑动距离。通过 ViewConfiguration.get(context).getScaledTouchSlop( ) 可以获取到当前手机上默认的最小滑动距离
- 第二步:测量容器的宽度和高度。为了简单,不允许使用 wrap_content。每个子组件与容器相同。容器的 layout_widht 值虽然为 MeasureSpec. EXACTLY。但容器的大小 = 父容器的宽度 * 子组件的个数。高度与父容器相同
- 第三步:定位子组件。默认情况下,屏幕出现第一个子组件。子组件占容器的可见区域。其他子组件以相同大小依次排列在后面
- 第四步:判断滚动状态,状态分为两种:停止状态和滑动状态。容器根据状态决定是否拦截事件
- 第五步:惯性滚屏。这其中语句 int dx = (curScreen * splitWidht) - scrollX ,获得当前屏幕的索引 curScreen(从 0 开始),乘以一屏的宽度。减去容器滚过的距离。得到的值就是剩下的惯性距离。假设一共有 5 屏,每屏的宽度为 10,当前 curScreen 为 1时,表示将滚动到第 2 屏。如果容器一滚动到 6,则 dx = 1 * 10 -6 = 4,意思是剩下的 4个单位距离将自行滚动过去。
- 手指滑动距离如果超过容器一半或者滑动速度足够快,则进入下一屏(或者上一屏)。如果没有超过一半或速度很慢则会滚到初始位置。定义 moveToDestination()方法中,最关键的语句是 int toScreen = (getScrollX() + (splitWidht /2)) / splitWidht ,getScrollX( )是容器滚动过的距离,是spliWidth 是每一屏的宽度。比如,每一屏的宽度为 10,当前屏为 第 2 屏。容器已滚过 23,则 toScreen = (23 + 10 / 2) / 10 = (23 + 5) / 10 = 28 / 10 = 2.8 = 2,也就是说要回滚到第 2屏;如果容器已滚动 28,则 toScreen = (28 + 10 / 2) / 10 = 32 / 10 = 3.2 = 3,表示要滚动到第 3 屏
- 第六步:响应用户手指按下、移动、松开事件,这是整个滑动的关键,特别是松开后。要判断滚屏还是回屏。为了支持上一屏和下一屏,需要辨别手指滑动的方向。VelocityTracker 类可以获取 x 方向的速率,其正值代表向左滑动。负值,代表向右滑动。如果 x 方向的速率在 [- SNAP_VELOCITY��SNAP_VELOCITY]之间,则要根据用户滑动的距离(滑动距离是否超过一屏的 1/2),决定是要继续滚屏还是回屏带初始状态
8.6 参考链接
- 博客:工匠若水 -> Android触摸屏事件派发机制详解与源码分析一(View篇)
- 博客:工匠若水 -> Android触摸屏事件派发机制详解与源码分析二(ViewGroup篇)
- 博客:工匠若水 -> Android应用坐标系统全面详解
- 博客:工匠若水 -> Android应用开发Scroller详解及源码浅析
- 博客:工匠若水 -> Android应用开发之自定义View触摸相关工具类全解
- 博客:启舰 -> ListView滑动删除实现之二——scrollTo、scrollBy详解
- 博客:启舰 -> ListView滑动删除实现之三——创建可滑动删除的ListView
- 博客:启舰 -> ListView滑动删除实现之四——Scroller类与listview缓慢滑动
- 博客:启舰 -> PullScrollView详解(一)——自定义控件属性