书到用时方恨少,事非经过不知难。这篇文章主要讲述Android Scroller完全解析相关的知识,希望能为你提供帮助。
scrollTo()和scrollBy() 在android中,
任何一个控件都是可以滚动的,
因为在View类当中有scrollTo()和scrollBy()这两个方法,
如下图所示:
文章图片
这两个方法的主要作用是将View/ViewGroup移至指定的坐标中, 并且将偏移量保存起来。另外:
- mScrollX 代表X轴方向的偏移坐标
- mScrollY 代表Y轴方向的偏移坐标
关于偏移量的设置我们可以参看下源码:
public class View {
....
protected int mScrollX;
//该视图内容相当于视图起始坐标的偏移量,X轴方向
protected int mScrollY;
//该视图内容相当于视图起始坐标的偏移量,Y轴方向
//返回值
public final int getScrollX() {
return mScrollX;
}
public final int getScrollY() {
return mScrollY;
}
public void scrollTo(int x, int y) {
//偏移位置发生了改变
if (mScrollX !=
x || mScrollY !=
y) {
int oldX =
mScrollX;
int oldY =
mScrollY;
mScrollX =
x;
//赋新值,
保存当前便宜量
mScrollY =
y;
//回调onScrollChanged方法
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
invalidate();
//一般都引起重绘
}
}
}
// 看出区别了吧 。 mScrollX 与 mScrollY 代表我们当前偏移的位置 ,
在当前位置继续偏移(x ,y)个单位
public void scrollBy(int x, int y) {
scrollTo(mScrollX +
x, mScrollY +
y);
}
//...
}
于是, 在任何时刻我们都可以获取该View/ViewGroup的偏移位置了, 即调用getScrollX()方法和getScrollY()方法。
下面我们写个例子看下它们的区别吧:
<
?xml version=
"
1.0"
encoding=
"
utf-8"
?>
<
LinearLayout xmlns:android=
"
http://schemas.android.com/apk/res/android"
xmlns:tools=
"
http://schemas.android.com/tools"
android:id=
"
@
+
id/layout"
android:layout_width=
"
match_parent"
android:layout_height=
"
match_parent"
android:orientation=
"
vertical"
>
<
Button
android:id=
"
@
+
id/scroll_to_btn"
android:layout_width=
"
wrap_content"
android:layout_height=
"
wrap_content"
android:text=
"
scrollTo"
/>
<
Button
android:id=
"
@
+
id/scroll_by_btn"
android:layout_width=
"
wrap_content"
android:layout_height=
"
wrap_content"
android:layout_marginTop=
"
10dp"
android:text=
"
scrollBy"
/>
<
/LinearLayout>
【Android Scroller完全解析】外层使用了一个LinearLayout, 在里面包含了两个按钮, 一个用于触发scrollTo逻辑, 一个用于触发scrollBy逻辑。
public class MainActivity extends AppCompatActivity {private LinearLayout layout;
private Button scrollToBtn;
private Button scrollByBtn;
@
Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
layout =
(LinearLayout) findViewById(R.id.layout);
scrollToBtn =
(Button) findViewById(R.id.scroll_to_btn);
scrollByBtn =
(Button) findViewById(R.id.scroll_by_btn);
scrollToBtn.setOnClickListener(new View.OnClickListener() {
@
Override
public void onClick(View v) {
layout.scrollTo(getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll),
getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll));
}
});
scrollByBtn.setOnClickListener(new View.OnClickListener() {
@
Override
public void onClick(View v) {
layout.scrollBy(getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll),
getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll));
}
});
}
}
<
resources>
<
dimen name=
"
horizontal_scroll"
>
-40dp<
/dimen>
<
dimen name=
"
vertical_scroll"
>
-80dp<
/dimen>
<
/resources>
当点击了scrollTo按钮时, 我们调用了LinearLayout的scrollTo()方法, 当点击了scrollBy按钮时, 调用了LinearLayout的scrollBy()方法。那有的朋友可能会问了, 为什么都是调用的LinearLayout中的scroll方法? 这里一定要注意, 不管是scrollTo()还是scrollBy()方法, 滚动的都是该View内部的内容, 而LinearLayout中的内容就是我们的两个Button, 如果你直接调用button的scroll方法的话, 那滚动的就是button中包含的内容, 而不是button本身。
文章图片
如图, 中间的矩形相当于屏幕, 即可视区域。后面的content相当于画布, 代表视图。大家可以看到, 只有视图中间部分目前是可见的, 其他部分不可见, 可见部分我们设置了一个button, 坐标( 20,10) 。下面使用scrollBy()方法将可是区域在屏幕上向X轴正方向( 右) 平移20, 在Y正方向( 下) 平移10, 平移后的视图如图5.6, 可以发现虽然scrollBy(20,10), 均为正方向, 但Button却向X, Y负方向移动了, 这就是选择的参考系不同而产生不同效果。
为了让Button向X,Y正方向移动, 我们设置上面DEMO中X, Y的移动距离分别为-40dp, -80dp。运行一下程序:
文章图片
当我们点击scrollTo按钮时, 两个按钮会一起向右下方滚动, 之后再点击scrollTo按钮就没有任何作用了, 界面不会再继续滚动, 只有点击scrollBy按钮界面才会继续滚动, 并且不停点击scrollBy按钮界面会一起滚动下去。
Scroller类 从上面例子运行结果可以看出, 利用scrollTo()/scrollBy()方法把一个View偏移至指定坐标(x,y)处, 整个过程是直接跳跃的, 没有对这个偏移过程有任何控制, 对用户而言不太友好。于是, 基于这种偏移控制, Scroller类被设计出来了, 该类的主要作用是为偏移过程制定一定的控制流程, 从而使偏移更流畅, 更完美。
我们分析下源码里去看看Scroller类的相关方法, 其源代码(部分)如下: 路径位于 \\frameworks\\base\\core\\java\\android\\widget\\Scroller.java
public class Scroller{private int mStartX;
//起始坐标点 ,X轴方向
private int mStartY;
//起始坐标点 ,Y轴方向
private int mCurrX;
//当前坐标点X轴,
即调用startScroll函数后,经过一定时间所达到的值
private int mCurrY;
//当前坐标点Y轴,
即调用startScroll函数后,经过一定时间所达到的值private float mDeltaX;
//应该继续滑动的距离,
X轴方向
private float mDeltaY;
//应该继续滑动的距离,
Y轴方向
private boolean mFinished;
//是否已经完成本次滑动操作,
如果完成则为 true//构造函数
public Scroller(Context context) {
this(context, null);
}
public final boolean isFinished() {
return mFinished;
}
//强制结束本次滑屏操作
public final void forceFinished(boolean finished) {
mFinished =
finished;
}
public final int getCurrX() {
return mCurrX;
}
/* Call this when you want to know the new location.If it returns true,
* the animation is not yet finished.loc will be altered to provide the
* new location. */
//根据当前已经消逝的时间计算当前的坐标点,
保存在mCurrX和mCurrY值中
public boolean computeScrollOffset() {
if (mFinished) {//已经完成了本次动画控制,
直接返回为false
return false;
}
int timePassed =
(int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
if (timePassed <
mDuration) {
switch (mMode) {
case SCROLL_MODE:
float x =
(float)timePassed * mDurationReciprocal;
...
mCurrX =
mStartX +
Math.round(x * mDeltaX);
mCurrY =
mStartY +
Math.round(x * mDeltaY);
break;
...
}
else {
mCurrX =
mFinalX;
mCurrY =
mFinalY;
mFinished =
true;
}
return true;
}
//开始一个动画控制,
由(startX , startY)在duration时间内前进(dx,dy)个单位,
即到达坐标为(startX+
dx , startY+
dy)出
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
mFinished =
false;
mDuration =
duration;
mStartTime =
AnimationUtils.currentAnimationTimeMillis();
mStartX =
startX;
mStartY =
startY;
mFinalX =
startX +
dx;
mFinalY =
startY +
dy;
mDeltaX =
dx;
mDeltaY =
dy;
...
}
}
其中比较重要的两个方法为:
- public boolean computeScrollOffset()
- public void startScroll(int startX, int startY, int dx, int dy, int duration)
computeScroll() 方法介绍:
为了易于控制滑屏控制, Android框架提供了 computeScroll()方法去控制这个流程。在绘制View时, 会在draw()过程调用该方法。因此, 再配合使用Scroller实例, 我们就可以获得当前应该的偏移坐标, 手动使View/ViewGroup偏移至该处。
computeScroll()方法原型如下, 该方法位于ViewGroup.java类中
/**
* Called by a parent to request that a child update its values for mScrollX and mScrollY if necessary. This will typically be done if the child is animating a scroll using a {@
link android.widget.Scroller Scroller}
* object.
* 由父视图调用用来请求子视图根据偏移值 mScrollX,mScrollY重新绘制*/
public void computeScroll() { //空方法 ,
自定义ViewGroup必须实现方法体
}
为了实现偏移控制, 一般自定义View/ViewGroup都需要重载该方法 。其调用过程位于View绘制流程draw()过程中, 如下:
@
Override
protected void dispatchDraw(Canvas canvas){
...for (int i =
0;
i <
count;
i+
+
) {
final View child =
children[getChildDrawingOrder(count, i)];
if ((child.mViewFlags &
VISIBILITY_MASK) =
=
VISIBLE || child.getAnimation() !=
null) {
more |=
drawChild(canvas, child, drawingTime);
}
}
}
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
...
child.computeScroll();
...
}
实例演示 ViewPager相信每个人都再熟悉不过了, 因此它实在是太常用了, 我们可以借助ViewPager来轻松完成页面之间的滑动切换效果, 但是如果问到它是如何实现的话, 我感觉大部分人还是比较陌生的。其实说到ViewPager最基本的实现原理主要就是两部分内容, 一个是事件分发, 一个是Scroller。对于事件分发, 不了解的同学可以参考我这篇博客Android事件的分发、拦截和执行。
接下来我将结合事件分发和Scroller来实现一个简易版的ViewPager。首先自定义一个ViewGroup, 不了解的可以参考Android自定义ViewGroup( 一) 之CustomGridLayout这篇文章。平滑偏移的主要做法如下:
- 第一、调用Scroller实例去产生一个偏移控制(对应于startScroll()方法)
- 第二、手动调用invalid()方法去重新绘制, 剩下的就是在computeScroll()里根据当前已经逝去的时间, 获取当前应该偏移的坐标(由Scroller实例对应的computeScrollOffset()计算而得)
- 第三、根据当前应该偏移的坐标, 调用scrollBy()方法去缓慢移动至该坐标处。
public class ScrollerLayout extends ViewGroup {private Scroller mScroller;
//用于完成滚动操作的实例
private VelocityTracker mVelocityTracker =
null ;
//处理触摸的速率
public static int SNAP_VELOCITY =
600 ;
//最小的滑动速率
private int mTouchSlop =
0 ;
//最小滑动距离,
超过了,
才认为开始滑动
private float mLastionMotionX =
0 ;
//上次触发ACTION_MOVE事件时的屏幕坐标
private int curScreen =
0 ;
//当前屏幕
private int leftBorder;
//界面可滚动的左边界
private int rightBorder;
//界面可滚动的右边界//两种状态: 是否处于滑屏状态
private static final int TOUCH_STATE_REST =
0;
//什么都没做的状态
private static final int TOUCH_STATE_SCROLLING =
1;
//开始滑屏的状态
private int mTouchState =
TOUCH_STATE_REST;
//默认是什么都没做的状态public ScrollerLayout(Context context, AttributeSet attrs) {
super(context, attrs);
// 创建Scroller的实例
mScroller =
new Scroller(context);
//初始化一个最小滑动距离
mTouchSlop =
ViewConfiguration.get(context).getScaledTouchSlop();
}@
Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int childCount =
getChildCount();
for (int i =
0;
i <
childCount;
i+
+
) {
View childView =
getChildAt(i);
// 为ScrollerLayout中的每一个子控件测量大小
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
}
}@
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 =
getChildAt(i);
// 为ScrollerLayout中的每一个子控件在水平方向上进行布局
childView.layout(i * childView.getMeasuredWidth(), 0, (i +
1) * childView.getMeasuredWidth(), childView.getMeasuredHeight());
}
}
// 初始化左右边界值
leftBorder =
getChildAt(0).getLeft();
rightBorder =
getChildAt(getChildCount() - 1).getRight();
}@
Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final int action =
ev.getAction();
//表示已经开始滑动了,
不需要走该Action_MOVE方法了(第一次时可能调用)。
//该方法主要用于用户快速松开手指,
又快速按下的行为。此时认为是处于滑屏状态的。
if ((action =
=
MotionEvent.ACTION_MOVE) &
&
(mTouchState !=
TOUCH_STATE_REST)) {
return true;
}
final float x =
ev.getX();
switch (action) {
case MotionEvent.ACTION_MOVE:
final int xDiff =
(int) Math.abs(mLastionMotionX - x);
//超过了最小滑动距离,
就可以认为开始滑动了
if (xDiff >
mTouchSlop) {
mTouchState =
TOUCH_STATE_SCROLLING;
}
break;
case MotionEvent.ACTION_DOWN:
mLastionMotionX =
x;
mTouchState =
mScroller.isFinished() ? TOUCH_STATE_REST : TOUCH_STATE_SCROLLING;
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mTouchState =
TOUCH_STATE_REST;
break;
}
return mTouchState !=
TOUCH_STATE_REST;
}public boolean onTouchEvent(MotionEvent event){
super.onTouchEvent(event);
//获得VelocityTracker对象,
并且添加滑动对象
if (mVelocityTracker =
=
null) {
mVelocityTracker =
VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
//触摸点
float x =
event.getX();
switch(event.getAction()){
case MotionEvent.ACTION_DOWN:
//如果屏幕的动画还没结束,
你就按下了,
我们就结束上一次动画,
即开始这次新ACTION_DOWN的动画
if(mScroller !=
null){
if(!mScroller.isFinished()){
mScroller.abortAnimation();
}
}
mLastionMotionX =
x ;
//记住开始落下的屏幕点
break ;
case MotionEvent.ACTION_MOVE:
int detaX =
(int)(mLastionMotionX - x );
//每次滑动屏幕,
屏幕应该移动的距离
if (getScrollX() +
detaX <
leftBorder) {//防止用户拖出边界这里还专门做了边界保护,
当拖出边界时就调用scrollTo()方法来回到边界位置
scrollTo(leftBorder, 0);
return true;
} else if (getScrollX() +
getWidth() +
detaX >
rightBorder) {
scrollTo(rightBorder - getWidth(), 0);
return true;
}
scrollBy(detaX, 0);
//开始缓慢滑屏咯。 detaX >
0 向右滑动 ,
detaX <
0 向左滑动
mLastionMotionX =
x ;
break ;
case MotionEvent.ACTION_UP:
final VelocityTracker velocityTracker =
mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000);
//计算速率
int velocityX =
(int) velocityTracker.getXVelocity() ;
//滑动速率达到了一个标准(快速向右滑屏,
返回上一个屏幕) 马上进行切屏处理
if (velocityX >
SNAP_VELOCITY &
&
curScreen >
0) {
// Fling enough to move left
snapToScreen(curScreen - 1);
}
//快速向左滑屏,
返回下一个屏幕
else if(velocityX <
-SNAP_VELOCITY &
&
curScreen <
(getChildCount()-1)){
snapToScreen(curScreen +
1);
}
//以上为快速移动的 ,
强制切换屏幕
else{
//我们是缓慢移动的,
因此先判断是保留在本屏幕还是到下一屏幕
snapToDestination();
}
//回收VelocityTracker对象
if (mVelocityTracker !=
null) {
mVelocityTracker.recycle();
mVelocityTracker =
null;
}
//修正mTouchState值
mTouchState =
TOUCH_STATE_REST ;
break;
case MotionEvent.ACTION_CANCEL:
mTouchState =
TOUCH_STATE_REST ;
break;
}
return true ;
}//我们是缓慢移动的,
因此需要根据偏移值判断目标屏是哪个
private void snapToDestination(){
//判断是否超过下一屏的中间位置,
如果达到就抵达下一屏,
否则保持在原屏幕
//公式意思是:
假设当前滑屏偏移值即 scrollCurX 加上每个屏幕一半的宽度,
除以每个屏幕的宽度就是我们目标屏所在位置了。
int destScreen =
(getScrollX() +
getWidth() / 2 ) / getWidth() ;
snapToScreen(destScreen);
}//真正的实现跳转屏幕的方法
private void snapToScreen(int whichScreen){
//简单的移到目标屏幕,
可能是当前屏或者下一屏幕,
直接跳转过去,
不太友好,
为了友好性,
我们在增加一个动画效果
curScreen =
whichScreen ;
//防止屏幕越界,
即超过屏幕数
if(curScreen >
getChildCount() - 1)
curScreen =
getChildCount() - 1 ;
//为了达到下一屏幕或者当前屏幕,
我们需要继续滑动的距离.根据dx值,
可能向左滑动,
也可能向右滑动
int dx =
curScreen * getWidth() - getScrollX() ;
mScroller.startScroll(getScrollX(), 0, dx, 0, Math.abs(dx) * 2);
//由于触摸事件不会重新绘制View,
所以此时需要手动刷新View 否则没效果
invalidate();
}@
Override
public void computeScroll() {
//重写computeScroll()方法,
并在其内部完成平滑滚动的逻辑
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();
}
}
}
代码比较长, 但思路比较清晰。
( 1) 首先在ScrollerLayout的构造函数里面我们创建Scroller的实例, 由于Scroller的实例只需创建一次, 因此我们把它放到构造函数里面执行。另外在构建函数中我们还初始化的TouchSlop的值, 这个值在后面将用于判断当前用户的操作是否是拖动。
( 2) 接着重写onMeasure()方法和onLayout()方法, 在onMeasure()方法中测量ScrollerLayout里的每一个子控件的大小, 在onLayout()方法中为ScrollerLayout里的每一个子控件在水平方向上进行布局, 布局类似于方向为horizontal的LinearLayout, 然后获取ViewGroup的左右边界位置。
( 3) 接着重写onInterceptTouchEvent()方法, 在这个方法中我们记录了用户手指按下时的X坐标位置, 以及用户手指在屏幕上拖动时的X坐标位置, 当两者之间的距离大于TouchSlop值时, 就认为用户正在拖动布局, 置状态为TOUCH_STATE_SCROLLING, 当用户手指抬起, 重置状态为TOUCH_STATE_REST。这里当状态值为TOUCH_STATE_SCROLLING时返回true, 将事件在这里拦截掉, 阻止事件传递到子控件当中。
( 4) 那么当我们把事件拦截掉之后, 就会将事件交给ScrollerLayout的onTouchEvent()方法来处理。
如果当前事件是ACTION_MOVE, 说明用户正在拖动布局, 那么我们就应该对布局内容进行滚动从而影响拖动事件, 实现的方式就是使用我们刚刚所学的scrollBy()方法, 用户拖动了多少这里就scrollBy多少。另外为了防止用户拖出边界这里还专门做了边界保护, 当拖出边界时就调用scrollTo()方法来回到边界位置。
如果当前事件是ACTION_UP时, 说明用户手指抬起来了, 但是目前很有可能用户只是将布局拖动到了中间, 我们不可能让布局就这么停留在中间的位置, 因此接下来就需要借助Scroller来完成后续的滚动操作。首先计算滚动速率, 判断当前动作是scroll还是fling。如果是fling, 再根据fling的方向跳转到上一页或者下一页, 调用函数snapToScreen。如果是scroll, 就调用函数snapToDestination, 函数中首先根据当前的滚动位置来计算布局应该继续滚动到哪一页, 滚动到哪一页同样调用snapToScreen。再来看看snapToScreen写法吧, 其实是调用startScroll()方法来滚动数据, 紧接着调用invalidate()方法来刷新界面。
( 5) 重写computeScroll()方法, 并在其内部完成平滑滚动的逻辑 。在整个后续的平滑滚动过程中, computeScroll()方法是会一直被调用的, 因此我们需要不断调用Scroller的computeScrollOffset()方法来进行判断滚动操作是否已经完成了, 如果还没完成的话, 那就继续调用scrollTo()方法, 并把Scroller的curX和curY坐标传入, 然后刷新界面从而完成平滑滚动的操作。
现在ScrollerLayout已经准备好了, 接下来我们修改activity_main.xml布局中的内容, 如下所示:
<
?xml version=
"
1.0"
encoding=
"
utf-8"
?>
<
com.hx.scroller.ScrollerLayout xmlns:android=
"
http://schemas.android.com/apk/res/android"
android:layout_width=
"
match_parent"
android:layout_height=
"
match_parent"
>
<
ImageView
android:layout_width=
"
match_parent"
android:layout_height=
"
200dp"
android:background=
"
@
drawable/bg_1"
/>
<
ImageView
android:layout_width=
"
match_parent"
android:layout_height=
"
200dp"
android:background=
"
@
drawable/bg_2"
/>
<
ImageView
android:layout_width=
"
match_parent"
android:layout_height=
"
200dp"
android:background=
"
@
drawable/bg_3"
/>
<
ImageView
android:layout_width=
"
match_parent"
android:layout_height=
"
200dp"
android:background=
"
@
drawable/bg_4"
/>
<
/com.hx.scroller.ScrollerLayout>
运行一下程序来看一看效果了, 如下图所示:
文章图片
Demo下载地址
推荐阅读
- 本图文详细教程教你运用win10的onedrive
- 安卓博客资源分享
- Android中的动态加载机制--薛彦顺
- Android 动画
- Android 手机卫士15--程序锁
- Mac版 Android Studio快捷键大全
- Android 手机卫士14--Widget窗口小部件AppWidgetProvider
- android打开关闭屏幕
- Android 导入jar包 so模块--导入放置的目录