本文为楼主原创,转载请表明出处:http://blog.csdn.net/suma_sun/article/details/52900476
上一篇博文介绍了onTouchEvent()手势控制相关的一些坐标概念,这篇文章结合上一篇内容加上Scroller实现一个简单的带黏性滑动的ScrollView。
首先看看我们需要做些什么,自定义View要复写的方法有好几个,我们要复写的有:
1. onMeasure()
2. onLayout()
3. onTouchEvent()
4. computeScroll()
因为是ViewGroup需要计算、定位子View所以需要复写1和2两个函数。
3不用说了,要实现滑动功能必须要实现的函数。
4这是配合Scroller使用的一个函数。
首先介绍下Scroller这个类,这个类是一个工具,并不是实际UI操作。这个工具提供的功能是提供差值计算,就像属性动画一样,要做一个平滑过渡的动画需要用到差值器来辅助提供偏移差值。
下面是一个不使用Scroller的Demo。
差别很明显。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//遍历子View,测量子View
int count = getChildCount();
for (int i = 0; i < count; i++) {
View view = getChildAt(i);
measureChild(view, widthMeasureSpec, heightMeasureSpec);
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
onMeasure()没什么好说的,遍历子view,测量子view。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 这里的四个坐标都是以ViewGroup来参照的,而不是屏幕的原点
// top默认0
// left默认0
// right默认屏幕宽度,ViewGroup的布局mactch_parent
// bottom默认屏幕高度,ViewGroup的布局mactch_parent
int count = getChildCount();
for (int i = 0; i < count; i++) {
View view = getChildAt(i);
//这个demo模仿垂直的ScrollView所以就没有累计宽度
int right = view.getMeasuredWidth();
//设置子View摆放位置
//mMaxHeight初始化为0
view.layout(l , mMaxHeight, right, mMaxHeight + view.getMeasuredHeight());
mMaxHeight += view.getMeasuredHeight();
//mHeights是一个集合,用于保存每个自view的高度,用于粘性滑动判断
mHeights.add(mMaxHeight);
if(right > mMaxWidth){
mMaxWidth = right;
}
}
}
要说明的都已经在注释上了。
首先提醒下getScrollY()、getScrollX()这两个方法获取的是当前的偏移量,初始状态都为0,左移累加正值,右移累加负值,上移累加正值,下移累加负值。如果看不懂其中坐标操作等请参考前一篇文章onTouchEvent(一) 你所必须知道的坐标详解。
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
int dx = 0;
int dy = 0;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//动画未停止,直接停止
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
mLastX = x;
mLastY = y;
break;
case MotionEvent.ACTION_MOVE:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
dx = (int) (mLastX - x);
dy = (int) (mLastY - y);
if (dx < 0) {//右移
// 为什么不判断getScrollX() <= 0呢,因为下面的判断已经限制了,另外三个方向手势亦然
// 限制最终偏移量不能超过0(不超过子view显示ViewGroup的背景)
if (dx + getScrollX() < 0) {
dx = 0 - getScrollX();
}
} else if (dx > 0) {//左移
// 左移动是正值偏移量,最大值不能超过内容的最大宽度,并且为了不超出显示容器的背景还要减去容器的宽度
// (为什么不用屏幕宽度呢,这是一个坑,如果容器不是match_parent的话那就有问题了)
int maxW = mMaxWidth - getWidth();
if (dx > maxW - getScrollX()) {
dx = maxW - getScrollX();
}
}
if (dy < 0) {//下滑
if (getScrollY() + dy < 0) {
dy = 0 - getScrollY();
}
} else if (dy > 0) {//上滑
// 为什么不用屏幕宽高呢?内容只能显示在ViewGroup上!状态栏、标题栏、导航栏都会占用屏幕高度,
// 还有match_parent
int maxH = mMaxHeight - getHeight();
if (maxH < dy + getScrollY()) {
dy = maxH - getScrollY();
}
}
scrollBy(dx, dy);
mLastX = x;
mLastY = y;
break;
case MotionEvent.ACTION_UP:
// mScrollY 不会小于0,为什么呢? 下滑到极限的时候是初始状态,即mScorllY=0的时候。
if (mHeights.size() == 0) {
return true;
}
int currY = getScrollY();
int i = 0;
for (; i < mHeights.size(); i++) {
if (mHeights.get(i) > currY) {//偏移量坐标 小于 集合的值 说明是前一个view
break;
}
}
View child = getChildAt(i);
int height = (child.getBottom() - child.getTop()) / 3;//计算3分之一的高度,小于三分之一回滚top,
// 大于三分之二滚到下个view头部
if (getScrollY() > (child.getBottom() - height)) {//大于三分之二view高度
// 不会有超出最后一个view的情况,因为move的时候已经限制了不能超出内容的
child = getChildAt(i + 1);
int viewHeight = child.getBottom() - child.getTop();
// 如果下一个view是最后一个view 判断其高度是否小于容器高度
if (i + 1 == mHeights.size() - 1 && viewHeight < getHeight()) {
// 最后一个view的bottom也就是maxHeight,其减去容器高度,
// 就是最后满屏的Y轴坐标,减去当前偏移量获取需要的偏移量
dy = child.getBottom() - getHeight() - getScrollY();
} else {
dy = child.getTop() - getScrollY();
}
} else if (getScrollY() < (child.getTop() + height)) {//小于三分之一view高度
dy = child.getTop() - getScrollY();
}
mScroller.startScroll(getScrollX(), getScrollY(), 0, dy);
invalidate();
break;
}
return true;
}
该说明的都在注释里标明了,毕竟后面解释的话来回滚动很麻烦。
startScroll()还有一个重载。
public void startScroll(int startX, int startY, int dx, int dy, int duration)自己设定操作的时间,上面那个重载是用得默认值250.
/**
* 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.
*/
public void computeScroll() {
}
computeScroll()是一个空实现的函数,父控件调用用于更新子View,操作的是mScrollX和mScrollY,也就是移动偏移量。
该函数在draw中被调用,故可以用来控制scroll。但其实一个空实现的函数,那怎么做才能达到我们要的效果呢?layout()重新设置布局位置,不过这个太麻烦了用起来,上一篇介绍的scrollTo()、scrollBy()是正统滑动的代码。
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) {//是否完成整个滑动,无调用startScroll()返回false
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
//通过重绘来不断调用computeScroll()
invalidate();
}
}
直接上代码就很直观了,但为什么要调用invalidate()来不断调用呢?很多文章(网上随便查)都是这么写的。
PS:其实我开始是相信大神们的,后面写着写着有自己的理解,就开始质疑了…然后注释掉跑了下,滑动是有时会黏性滚动有时不会,我才又相信大神们了囧rz。
说说我得思路,如果不对希望有人能给我指出,谢谢。
首先onTouchEvent()中调用了Scroller.startScroll()方法,该方法不涉及UI操作所以在后面调用了一次invalidate()让View去重绘,根据上面的computeScroll()的调用得知draw的时候会调用该方法,即invalidate()之后会被调用。里面通过scrollTo()来进行移动的操作,scrollTo()该方法源码中有一个函数postInvalidateOnAnimation()
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();
}
}
}
/**
* Cause an invalidate to happen on the next animation time step, typically the
* next display frame.
*
* This method can be invoked from outside of the UI thread
* only when this View is attached to a window.
*
* @see #invalidate()
*/
public void postInvalidateOnAnimation() {
// We try only with the AttachInfo because there's no point in invalidating
// if we are not attached to our window
final AttachInfo attachInfo = mAttachInfo;
if (attachInfo != null) {
attachInfo.mViewRootImpl.dispatchInvalidateOnAnimation(this);
}
}
效果跟invalidate()差不多,也会重绘,但是前面有个判断。
protected boolean awakenScrollBars() {
return mScrollCache != null &&
awakenScrollBars(mScrollCache.scrollBarDefaultDelayBeforeFade, true);
}
awakenScrollBars(int startDelay, boolean invalidate)该方法里面也有可能会调用invalidate(),但也只是有可能,里面有其他判断可能会过滤掉,所以终其原因就是有不会调动invalidate()的可能行存在,所以手动调用了一次invalidate()。
既然手动调用invalidate()就会重绘,也就会调用到computeScroll(),里面再判断Scroller的差值计算是否完毕,未完毕则继续移动就这样循环直至偏移到最终坐标。
附上布局文件
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.suma.viewdemo.widget.MyScrollerView2
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#656565">
<View
android:layout_width="1280dp"
android:layout_height="400dp"
android:background="#50acde"/>
<View
android:layout_width="720dp"
android:layout_height="400dp"
android:background="#cc53"/>
<View
android:layout_width="match_parent"
android:layout_height="400dp"
android:background="#88acde88"/>
<View
android:layout_width="match_parent"
android:layout_height="400dp"
android:background="#55bc88"/>
com.suma.viewdemo.widget.MyScrollerView2>
LinearLayout>
核心代码下载
呼,终于完成这一篇了,医生让我一周一更,还是有点累的,之前的我辣么懒,不过坚持下来我就是赚到了,下面就这个主题还准备写一两篇,又要挤时间敲敲demo了…