打造双向滑动的ScrollView
原文出处:http://blog.csdn.net/dantestones/article/details/47659411
注:这个ScrollView是自定义FrameLayout实现的,性能方面可能不如sdk中的ScrollView,但是这是一个学习自定义布局、自定义view、手势处理的实例教程。而且对于性能要求不高的场景,滚动内容不多的情况下,完全可以使用。不过这种控件的使用场景其实不多。以下是原文:
Android View的用法中事件拦截和Scroller的滑动一直是值得注意的地方,这次的双向滑动ScrollView就是利用这2个知识点来实现。
关于Scroller
Scroller是用来帮助实现滑动的辅助类,它的内部封装了关于滚动的参数比如getCurrX(),getCurrY()获取目前应该滚动的位置,通过调用scrollTo(),scrollBy()方法来进行滚动(scrollBy()方法其实也就是调用的scrollTo())。滚动的时候会启动一个循环进程定期的去调用 computeScroll()方法,我们可以在这个方法中判断滚动的状态,如何判断滚动状态呢? 调用computeScrollOffset()方法会返回true或者false,true表示滑动未完成,未完成我们就继续 scrollTo() 然后postInvalidate() 让它完成滑动。整个过程有点费解,不过没关系,下面看代码的时候大家可以明白。
开始实现
首先开始做点准备工作。
public class MyScrollView extends FrameLayout {
//处理滑动的Scroller 这里如果是api 9以上最好使用OverScroller代替Scroller
//Scroller的速滑效果很差
private OverScroller mScroller;
//判断滑动速度
private VelocityTracker mVelocityTracker;
//滑动的阀值
private int mTouchSlop;
//滑动速度
private int mMaxVelocity, mMinVelocity;
//滑动锁
private boolean mDragging = false;
//上一次移动事件的位置
private float mLastX, mLastY;
public MyScrollView(Context context) {
super(context);
init(context);
}
public MyScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public MyScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
public void init(Context context) {
mScroller = new OverScroller(context);
mVelocityTracker = VelocityTracker.obtain();
//获取系统的触摸阀值
//可以认为用户是在滑动的距离
mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
//滑动的最快速度
mMaxVelocity = ViewConfiguration.get(context).getScaledMaximumFlingVelocity();
//滑动的最慢速度
mMinVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity();
}
这里系统给我们提供了很多阀值给我们使用,比如这里的判断用户是否是滑动 ViewConfiguration.get(context).getScaledTouchSlop(),在滑动的时候有快速滑动的情况这种情况,需要使用检测类VelocityTracker来帮我们计算用户的滑动速度,同时获取系统的滑动速度阀值,来判断是否进行速滑操作。
这里还需要去重写测量方法,原因如果不复写父视图的默认方案会强制子视图和父视图一样大 也就是按父视图的方案来实现
//这里的方案是不测量保证视图尽可能按自己的大小来 如果不复写父视图的默认方案会强制子视图和父视图一样大 也就是按父视图的方案来实现
@Override
protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) {
int childWidthMeasureSpec;
int childHeightMeasureSpec;
childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
//与上面一样
@Override
protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
int childWidthMeasureSpec;
int childHeightMeasureSpec;
childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(lp.leftMargin + lp.rightMargin, MeasureSpec.UNSPECIFIED);
childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
还有一种速滑的情况Scroller有方法支持就是 fling()方法
//处理快速滑动的方法 参数是滑动速度
public void fling(int VelocityX, int VelocityY) {
if (getChildCount() > 0) {
int height = getHeight() - getPaddingTop() - getPaddingBottom();
int width = getWidth() - getPaddingLeft() - getPaddingRight();
int bottom = getChildAt(0).getHeight();
int right = getChildAt(0).getWidth();
mScroller.fling(getScrollX(), getScrollY(), VelocityX, VelocityY, 0, Math.max(0, right - width), 0, Math.max(0, bottom - height));
invalidate();
}
}
初期工作已经准备完成,现在需要考虑一种情况,就是滑动的位置超过了View的边界,这种情况需要自己判断解决。n是要滚动的值,my是父视图的边界大小,child是子视图的边界大小。默认是返回n,当子View比父视图还要小的时候就不需要滑动了返回0,当父视图边界加上滚动的距离超出子视图的边界的时候,就返回
child-my 直接滑动到子视图的边界。光靠文字不好理解,直接上图
//辅助方法判断是否超过边界
private int clamp(int n, int my, int child) {
//子View小于父视图或者滑动小于0 不滑动
if (my >= child || n < 0) {
return 0;
}
//滚动超过了子View的边界,直接滑到边界
if ((my + n) > child) {
return child - my;
}
return n;
}
现在准备工作已经完成,开始处理Scroller的滑动:
//computeScroll会被定期调用 判断滑动状态
@Override
public void computeScroll() {
//判断滑动状态 返回true表示没有完成
//使用这个方法保证滑动动画的完成
if (mScroller.computeScrollOffset()) {
int oldx = getScrollX();
int oldy = getScrollY();
//现在滚动到的x的位置
int x = mScroller.getCurrX();
//现在滚总到的y位置
int y = mScroller.getCurrY();
if (getChildCount() > 0) {
View child = getChildAt(0);
x = clamp(x, getWidth() - getPaddingLeft() - getPaddingRight(), child.getWidth());
y = clamp(y, getHeight() - getPaddingTop() - getPaddingBottom(), child.getHeight());
if (x != oldx || y != oldy) {
scrollTo(x, y);
}
}
//滑动完成之前一直绘制 就是保证这个方法还会进来
postInvalidate();
}
}
@Override
public void scrollTo(int x, int y) {
//依赖View.ScrollBy方法调用ScrollTo
if (getChildCount() > 0) {
View child = getChildAt(0);
//边界检查
x = clamp(x, getWidth() - getPaddingLeft() - getPaddingRight(), child.getWidth());
y = clamp(y, getHeight() - getPaddingTop() - getPaddingBottom(), child.getHeight());
//如果x== getScrollX()滚动已经完成??
if (x != getScrollX() || y != getScrollY()) {
super.scrollTo(x, y);
}
}
}
其实整个逻辑并不复杂,只是有点困惑,系统会定期调用computeScroll()方法,我们在这之中通过computeScrollOffset()方法判断滚动状态,未完成则调用scrollTo()接着滚动,同时滚动的值都接受了边界的判断。这里还重写了scrollTo()方法添加了边界的判断。
滑动工作已经完成最后还剩下事件拦截:
//监控传递给子视图的触摸事件 一旦进行拖拽就拦截
//如果子视图是可交互的,允许子视图接收事件
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//终止正在进行的滑动
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
//还原速度追踪器
mVelocityTracker.clear();
mVelocityTracker.addMovement(ev);
//保存初始触点
mLastX = ev.getX();
mLastY = ev.getY();
break;
case MotionEvent.ACTION_MOVE:
final float x = ev.getX();
final float y = ev.getY();
final int DiffX = (int) Math.abs(x - mLastX);
final int DiffY = (int) Math.abs(y - mLastY);
//检查x或者Y方向是否达到了滑动的阀值
if (DiffX > mTouchSlop || DiffY > mTouchSlop) {
mDragging = true;
mVelocityTracker.addMovement(ev);
//开始自己捕捉触摸事件
return true;
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mDragging = false;
mVelocityTracker.clear();
break;
}
return super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//这里所有的事件都会交给检测器
mVelocityTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//已经保留了初始的触点,如果这里不返回true,后续的触摸事件就不会再传递
return true;
case MotionEvent.ACTION_MOVE:
final float x = event.getX();
final float y = event.getY();
final float DeltaX = mLastX - x;
final float DeltaY = mLastY- y;
//判断阀值
if ((Math.abs(DeltaX) > mTouchSlop || Math.abs(DeltaY) > mTouchSlop) && !mDragging) {
mDragging = true;
}
if(mDragging){
//滚动视图 这里的scrollBy 还是去调用scrollTo
//至于为什么要用scrollBy 有兴趣可以去看看源码
scrollBy((int)DeltaX,(int)DeltaY);
//更新坐标
mLastX = x;
mLastY = y;
}
break;
case MotionEvent.ACTION_CANCEL:
//终止滑动
mDragging = false;
if(!mScroller.isFinished()){
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_UP:
mDragging = false;
//处理快速滑动的情况
mVelocityTracker.computeCurrentVelocity(1000,mMaxVelocity);
final int VelocityX = (int)mVelocityTracker.getXVelocity();
final int VelocityY = (int)mVelocityTracker.getYVelocity();
if(Math.abs(VelocityX) > mMinVelocity || Math.abs(VelocityY) > mMinVelocity){
//为什么要取负值? 因为滑动的时候 正值是向上滑动 负值是向下滑动
fling(-VelocityX,-VelocityY);
}
break;
}
return super.onTouchEvent(event);
}
整个代码在注释下还是很好理解的,就是通过阀值来判断是否在滑动,是否是速滑,然后调用相关方法。需要注意一些小细节,在onInterceptTouchEvent完成拦截后在onTouchEvent中需要在MotionEvent.ACTION_DOWN动作中返回true,如果不这样后续的事件就不会给你了,默认情况下return请写成return super.onTouchEvent(event); return super.onInterceptTouchEvent(ev); 除非你需要拦截返回true。如果你都修改的话会导致触摸事件出问题,因为父类还有许多方法,你不会想去重写的,最后注意一点: scrollTo(100,100) 是向上滑动100 和 向左滑动100,scrollTo(-100,-100) 才是向下滑动100,向右滑动100 跟第一映像是相反的
如何使用
到此为止MyScrollView 就已经完成了,使用起来也非常简单
public class MainActivity extends AppCompatActivity {
int\[\] data = {R.mipmap.am,R.mipmap.fw,R.mipmap.jiansheng,R.mipmap.kaer,R.mipmap.tf};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
MyScrollView myScrollView = new MyScrollView(this);
int size = (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,400,getResources().getDisplayMetrics());
LinearLayout layout = new LinearLayout(this);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(size,size);
layout.setOrientation(LinearLayout.VERTICAL);
for(int i = 0 ; i< 5; i++){
ImageView image = new ImageView(this);
image.setImageResource(data\[i\]);
layout.addView(image,lp);
}
myScrollView.addView(layout);
setContentView(myScrollView);
}
}
跟系统的ScrollView非常类似也是需要一个子View但是只能有一个,这里使用的是纵向的滑动layout.setOrientation(LinearLayout.VERTICAL) 只需要把这里的方法修改一下就可以成横向的了,大家可以试一试。最后贴上代码和效果图,虚拟机上滑动不是很好操作,真机上测试效果更好