Scroller译为“滚动器”,是ViewGroup类中原生支持的一个功能。在Android中,如果一个控件需要实现滚动的功能,就需要用到Scroller类。在Android系统的控件中,比如ListView、ViewPager等都用到了。这篇博客就来学习一下Scroller类,并使用Scroller类和事件的分发写一个实例。
在说Scroller类之前,先说两组相关的API:
1.invalidate()和postInvalidate()
重载方法invalidate(int l, int t, int r, int b)、invalidate(Rectdirty)和postInvalidate(int left, int top, int right, int bottom)
invalidate()方法用于重绘组件,不带参数表示重绘整个视图区域,带参数表示重绘指定的区域。调用View的invalidate()方法就相当于调用了onDraw()方法,而onDraw()方法中就是我们编写的绘图代码。需要注意的是invalidate()方法只能在UI线程中调用,如果需要在子线程中刷新组件,那就需要调用View类另一组名为postInvalidate()的方法。
了解invalidate()方法实现重新绘制界面的过程,可以查看《invalidate()和requestLayout()方法调用过程》这篇博客
2.scrollTo()和ScrollBy()
这两个方法是在View类中定义的,也就说明了在Android中所有的空间都是可以滚动的,但是这两个方法有什么区别呢?不解释,我们先来看看这两个方法在View类中是怎样实现的。
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, int y)方法中,参数x、y是目标位置,该方法先判断新的滚动位置是否发生了变化,如果是,先保存上一次的位置,再应用新位置(x,y),接着调用onScrollChanged()方法,并调用postInvalidateOnAnimation()方法(该方法和invalidate()方法效果一样,只是postInvalidateOnAnimation()更加流畅,不会失帧)刷新View组件。scrollTo()方法表示滚动到指定位置。
scrollBy(int x, int y)方法则不同,是要原来的基础上水平方向滚动x个距离,垂直方向滚动y个距离,最终还是调用了scrollTo(int x, int y)方法。scrollBy()方法表示从某一点开始滚动指定距离。
下面用一个实例来展示一下他们两个方法的区别:
首先是布局文件:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:orientation="vertical">
<LinearLayout
android:id="@+id/view1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ScrollTo" />
<Button
android:id="@+id/bt_scrollto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="开始" />
</LinearLayout>
<LinearLayout
android:id="@+id/view2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ScrollBy" />
<Button
android:id="@+id/bt_scrollby"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="开始" />
</LinearLayout>
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="#80ff0000"
android:padding="5dp"
android:text="text_scrollby" />
</LinearLayout>
接着Activity代码:
public class MainActivity extends AppCompatActivity {
private LinearLayout view1;
private LinearLayout view2;
private Button btScrollTo;
private Button btScrollBy;
private TextView textView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().requestFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.activity_main);
view1 = (LinearLayout) findViewById(R.id.view1);
view2 = (LinearLayout) findViewById(R.id.view2);
btScrollTo = (Button) findViewById(R.id.bt_scrollto);
btScrollBy = (Button) findViewById(R.id.bt_scrollby);
textView = (TextView) findViewById(R.id.textView);
btScrollTo.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
view1.scrollTo(-20,0);
}
});
btScrollBy.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
view2.scrollBy(-20, 0);
}
});
textView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
textView.scrollBy(-20,0);
}
});
}
}
效果如图所示:
从效果图上可以看出,scrollTo()方法当我们点击一次之后,接着不管怎样点击都没有效果,不会再移动了,因为它已经移动到了指定的位置;而scrollBy()不同,不管我们点击多少次,都是每点击一次就会在水平方向向右移动20像素,因为它是在原来的基础上进行偏移。
几点注意:
① 在布局文件中可以看到,前面两个都是用一个线性布局包裹了一个textView和一个button,最后一个是一个TextView;在代码中可以看到,在点击按钮时是调用线性布局的scrollTo()和scrolBy()方法,但最终移动的并不是线性布局,而是线性布局的孩子控件,也就是textView和Button两个控件;在点击TextView的时候,调用的就是TextView的scrollBy()方法,移动的是TextView中显示的文字,也就是TextView的内容。所以,第一点注意,我们在使用scrollTo()和scrollBy()的时候,如果是容器布局调用的话,那么移动的是他的孩子控件,并不是他自身,如果不是容器控件调用的话,那么移动的就是控件中的内容。
② 在图中,我们看到的移动方向是向右,但是在代码中调用时所传的参数是负数,这也就是第二点需要注意的,如果需要向右或者向下(就是沿着坐标轴的正方向移动),那么我们调用这两个方法传的参数是负数,否则就是传正数。
在上面的代码中,我们看到了不管是View,还是ViewGroup,都是可以滚动的。同时,我们也发现了一个问题,如果我需要从位置(0,0)滚动到位置(200,0),如果我们调用scrollTo(200,0),那么他可以直接跳过去,目的达到了,但是对用户好像不太友好,如果我们使用scrollBy()方法一点一点设置,那么我们自己有不好控制每隔多久调用一次以及一次移动多少距离,还要定义一个变量计算移动了多少距离,太麻烦。其实,在Android中,想要实现这样的效果很简单,只需要简单的几步就可以完成。这就是利用这篇博客的主角Scroller类。
Scroller类:
Scroller 类在滚动过程的的几个主要作用如下:
◆启动滚动动作;
◆根据提供的滚动目标位置和持续时间计算出中间的过渡位置;
◆判断滚动是否结束;
◆介入View或ViewGroup的重绘流程,从而形成滚动动画。
Scroller类对于滚动的作用非常重大,但是他定义的方法不是太多,下面列出了Scroller类中比较常见的方法:
// 构造方法,interpolator指定插速器,如果没有指定,
// 默认插速器为ViscousFluidInterpolator,flywheel参数为true可以提供类似“飞轮”的行为
public Scroller(Context context)
public Scroller(Context context, Interpolator interpolator)
public Scroller(Context context, Interpolator interpolator, boolean flywheel)
// 设置一个摩擦系数,默认为 0.015f, 摩擦系数决定惯性滑行的距离
public final void setFriction(float friction)
// 返回起始 x 坐标值
public final int getStartX()
// 返回起始 y 坐标值
public final int getStartY()
// 返回结束 x 坐标值
public final int getFinalX()
// 返回结束 y 坐标值
public final int getFinalY()
// 返回滚动过程中的 x 坐标值,滚动时会提供startX(起始)和finalX(结束),currX根据这两个值计算而来
public final int getCurrX()
// 返回滚动过程中的 y 坐标值,滚动时会提供 startY(起始)和finalY(结束),currY根据这两个值计算而来
public final int getCurrY()
// 计算滚动偏移量,必调方法之一。主要负责计算currX和currY两个值,其返回值为true表示滚动尚未完成, 为false表示滚动已结束
public boolean computeScrollOffset()
// 启动滚动行为,startX和startY表示起始位置,dx、dy表示要滚动的x、y方向的距离,duration表示持续时间,默认时间为 250 毫秒
public void startScroll(int startX, int startY, int dx, int dy)
public void startScroll(int startX, int startY, int dx, int dy, int duration)
// 判断滚动是否已结束,返回true表示已结束
public final boolean isFinished()
// 强制结束滚动,currX、 currY 即为当前坐标;
public final void forceFinished(boolean finished)
// 与forceFinished功用类似,停止滚动,但currX、currY设置为终点坐标
public void abortAnimation()
// 延长滚动时间
public void extendDuration(int extend)
// 返回滚动已耗费的时间,单位为毫秒
public int timePassed()
// 设置终止位置的 x 坐标,可能需要调用extendDuration()延长或缩短动画时间
public void setFinalX(int newX)
// 设置终止位置的 y 坐标,可能需要调用extendDuration()延长或缩短动画时间
public void setFinalY(int newY)
上面的方法中,
常用的主要有startScroll()、computeScrollOffset()、getCurrX()、getCurrY()和abortAnimation()等几个方法,下面就通过一个这些方法实现一个简单的案例:
下面直接上代码:
1.自定义的ScrollerTest.java类
public class ScrollerTest extends ViewGroup {
/**定义Scroller对象*/
private Scroller mScroller;
/**定义系统默认滑动系数*/
private int mTapSlop;
/**记录按下时的x方向坐标*/
private int mDownX;
/**控件左边界*/
private int mLeft;
/**控件右边界*/
private int mRight;
/**屏幕宽度*/
private int mScreenWidth;
/**当前显示的页面角标*/
private int mIndex = 0;
public ScrollerTest(Context context) {
this(context, null);
}
public ScrollerTest(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ScrollerTest(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 获取屏幕宽度,为了简单将所有的直接孩子控件的宽度设置成屏幕宽度
WindowManager systemService = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics outMetrics = new DisplayMetrics();
systemService.getDefaultDisplay().getMetrics(outMetrics);
mScreenWidth = outMetrics.widthPixels;
// 获取系统默认滑动系数
ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
mTapSlop = viewConfiguration.getScaledDoubleTapSlop();
// 1、初始化Scroller对象
mScroller = new Scroller(context);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 测量每一个孩子控件的大小
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = this.getChildAt(i);
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
}
// getDefaultSize():作用是返回一个默认的值,如果MeasureSpec没有强制限制的话则使用提供的大小.否则在允许范围内可任意指定大小
setMeasuredDimension(getDefaultSize(0, widthMeasureSpec), getDefaultSize(0, heightMeasureSpec));
}
// 继承至ViewGroup,重写onLayout()方法确定每一个孩子控件的位置
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (changed) { // 判断是否需要重新布局
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = this.getChildAt(i);
int childViewMeasuredWidth = childView.getMeasuredWidth();
// 指定每一个孩子控件的位置,这里就是直接水平排列每一个孩子控件
childView.layout(i * childViewMeasuredWidth, 0, (i + 1) * childViewMeasuredWidth, childView.getMeasuredHeight());
}
mLeft = this.getChildAt(0).getLeft();
mRight = this.getChildAt(childCount - 1).getRight();
}
}
@Override // 判断是否需要拦截事件的方法
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
mDownX = (int) ev.getX();
break;
case MotionEvent.ACTION_MOVE:
int moveX = (int) ev.getX();
int diffX = Math.abs(moveX - mDownX);
mDownX = moveX;
if (diffX > mTapSlop) {
// 如果移动的距离大于默认滑动系数就拦截事件
return true;
}
break;
default:
break;
}
return super.onInterceptTouchEvent(ev);
}
@Override // 处理事件的方法
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if(mScroller != null && !mScroller.isFinished()){
// 如果当前还没有完成滑动,就强制结束滑动状态
mScroller.abortAnimation();
}
mDownX = (int) event.getX();
break;
case MotionEvent.ACTION_MOVE:
int moveX = (int) event.getX();
int dX = mDownX - moveX;
// 边界处理
if (getScrollX() + dX < mLeft) {
scrollTo(mLeft, 0);
} else if (getScrollX() + mScreenWidth + dX > mRight) {
scrollTo(mRight - mScreenWidth, 0);
} else {
// 非边界,直接移动
scrollBy(dX, 0);
mDownX = moveX;
}
break;
case MotionEvent.ACTION_UP:
// 计算应该显示的是第几页
mIndex = (getScrollX() + mScreenWidth / 2) / mScreenWidth;
// 计算需要滑动的距离
int scrollX = mIndex * mScreenWidth - getScrollX();
///2、开始滑动
mScroller.startScroll(getScrollX(), 0, scrollX, 0, scrollX);
invalidate();
break;
default:
break;
}
return true;
}
// 3、维持滑动状态
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) { // 判断是否已经完成滑动
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();// 不断通过postInvalidate()方法调用draw()方法重绘界面
}
}
// 滑动到指定位置
public void setPosition(int position) {
if (position > getChildCount() - 1 || position < 0) {
throw new ArrayIndexOutOfBoundsException("页面位置角标错误");
}
int dX = position * mScreenWidth - getScrollX();
mScroller.startScroll(getScrollX(), 0, dX, 0, dX);
postInvalidate();
}
}
2.布局文件代码:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<renj.customerview.widget.ScrollerTest
android:id="@+id/scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:background="#80ff0000">
<TextView
android:textSize="30sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="第一页" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:background="#8000ff00">
<TextView
android:textSize="30sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="第二页" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:background="#800000ff">
<TextView
android:textSize="30sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="第三页" />
</LinearLayout>
</renj.customerview.widget.ScrollerTest>
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="5dp"
android:text="跳转到第三页" />
</LinearLayout>
3.Activity中的代码:
public class MainActivity extends AppCompatActivity {
private ScrollerTest scrollTest;
private Button button;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().requestFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.activity_main);
scrollTest = (ScrollerTest) findViewById(R.id.scroll);
button = (Button) findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
scrollTest.setPosition(2);
}
});
}
}
4.最终运行结果展示:
通过上面的实例可以看出,使用Scroller类可以归纳为三步:
① 创建Scroller对象,可以直接通过构造方法创建;
② 通过Scroller类的startScroll()开启动画;
③ 重写View中的computeScroll()方法维持动画直到结束(控件到达指定位置)。
在第三步中的computeScroll()方法中调用Scroller类的computeScrollOffset()方法判断是否已经移动到最终位置,如果返回false,表示移动到最终位置,结束动画;如果返回true,表示没有结束动画,那么就可以调用Scroller类中的getCurrX()/getCurrY()获取下一个移动的位置并调用scrollTo()进行移动,最后调用postInvalidate()方法重新绘制界面实现动画效果。
在文章开篇说道了调用invalidate()就是调用了空间的绘制方法,但是在draw()方法中是怎样调用View中的computeScroll()方法实现不断刷新界面的,可以查看《Android自定义View之View的绘制流程》这篇博客的绘制(draw)部分。
在最后的这一个案例中使用到了事件分发相关的知识,事件的分发在《Android中的事件分发机制》这篇博客当中已经聊过了,这里就不在啰嗦。