众所周知,自定义view是衡量一个安卓开发人员水平的指标之一,但是自定义view的难度还是有的,需要我们熟知view的加载的三大流程:measure,layout,draw。
对于初涉安卓的技术小白来说,想要学习这些内容是有点困难的,阅读源码需要有相当足够的耐心,和不算差的英语水平。在这里博主向大家推荐任玉刚大神的《Android开发艺术探索》,书中详解了view的事件体系还有工作原理。本篇文章,就是对于其中一个自定义view的demo的详细解读,因为demo中注释较少,向我这种技术菜分析起来还是有点困难的。友情提示,阅读本文的前提是您已经对view的加载机制有有所了解,同时,本例中没有对自定义view的margin和padding属性进行处理
好了,闲话少说,先把书中的demo贴出来(有些许改动):
package com.quicker.customview.custom;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;
/**
* Created by ZhangYang on 2017/3/20.
*/
public class MyScrollView extends ViewGroup {
private static final String TAG = "MyScrollView";
private int mChildrenSize;
private int mChildWidth;
private int mChildIndex;
//分别记录上次滑动的坐标
private int mLastX;
private int mLastY;
//分别记录上次滑动的坐标(onInterceptTouchEvent)
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
private void init() {
if (mScroller == null) {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
}
/**
* 在这个方法中解决滑动冲突
*
* @param ev 手指在屏幕上产生的事件
* @return 如果返回true,则改控件拦截事件的分发,同时自己消费掉事件
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getRawX(); //相对于屏幕左上角的坐标
int y = (int) ev.getRawY(); //相对于屏幕左上角的坐标
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
intercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept; //获取水平方向滑动的距离
int deltaY = y - mLastYIntercept; //获取垂直方向滑动的距离
if (Math.abs(deltaX) > Math.abs(deltaY)) {
intercepted = true; //解决和ListView的滑动冲突
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
mLastX = x;
mLastY = y;
mLastYIntercept = x;
mLastYIntercept = y;
return intercepted;
}
//解决外部的自定义view是如何消费滑动事件的
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);
Log.d("SCROLL", "move " + deltaX);
break;
/*----------------------------------------手指离开屏幕时,滑动方式的算法--------------------------------------*/
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
Log.d("RXRX", "scrollX " + scrollX); //没有什么事情是log解决不了的
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
}
//精妙
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
/**
* 两个参数是根据父view的MeasureSpec和此view的layoutParams计算得出
* @param widthMeasureSpec
* @param heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//调用此方法去测量所有子view的宽高
//验证了此onMeasure上面的注释
measureChildren(widthMeasureSpec, heightMeasureSpec);
final int childCount = getChildCount();
final View childView = getChildAt(0);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (childCount == 0) {
setMeasuredDimension(0, 0); //如果没有子view,这设置父view为空
} else {
if (MeasureSpec.AT_MOST == widthMode && MeasureSpec.AT_MOST == heightMode) {
//这样做的前提是所有的子元素的宽高都相同,要不然需要分别计算
setMeasuredDimension(childView.getMeasuredWidth() * childCount, childView.getMeasuredHeight());
} else if (MeasureSpec.AT_MOST == widthMode) {
setMeasuredDimension(childView.getMeasuredWidth() * childCount, heightSize);
} else if (MeasureSpec.AT_MOST == heightMode) {
setMeasuredDimension(widthSize, childView.getMeasuredHeight());
}
}
}
//根据子view的数量,宽高来放置
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childLeft = 0;
mChildrenSize = getChildCount();
for (int i = 0; i < getChildCount(); i++) {
final View childView = getChildAt(i);
if (View.GONE != childView.getVisibility()) {
final int childWidth = childView.getMeasuredWidth();
mChildWidth = childWidth;
childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
childLeft += childWidth;
}
}
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
private void smoothScrollBy(int dx, int dy) {
mScroller.startScroll(getScrollX(), 0, dx, 0, 2000);
invalidate();
}
@Override
protected void onDetachedFromWindow() {
mVelocityTracker.recycle();
super.onDetachedFromWindow();
}
public MyScrollView(Context context) {
super(context);
init();
}
public MyScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public MyScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
}
完整代码如上,本例是对ViewPager的简单模仿,运行效果是在这个自定义view里面add三个宽高相等的,可以上下滑动的布局。左右滑动切换页面,上下滑动展示更多数据。因为本view可以左右滑动,而子view中的listView可以上下滑动,因此我们需要结局滑动冲突的问题。
我们首先来看滑动冲突的解决方法:
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getRawX();
int y = (int) ev.getRawY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
intercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept; //获取水平方向滑动的距离
int deltaY = y - mLastYIntercept; //获取垂直方向滑动的距离
if (Math.abs(deltaX) > Math.abs(deltaY)) {
intercepted = true; //解决和ListView的滑动冲突
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
mLastX = x;
mLastY = y;
mLastYIntercept = x;
mLastYIntercept = y;
return intercepted;
}
采用外部拦截法,本自定义view就是所谓的外部的view,因此我们需要在此view的onInterceptTouchEvent中根据实际的滑动操作,来判断外部的view是否要响应该事件。因为是个左右滑动的view的里面嵌套了上下滑动的listView,因此很容易想到,当用户左右滑动的时候,外部的view是应该跟随手指移动的,也就是响应了滑动事件。因此,当ACTION_MOVE时我们计算出来手指在屏幕水平、垂直方向的滑动距离,如果水平方向大于垂直方向,则设置intercepted为true,表示拦截掉事件,自己在onTouchEvent方法中消费掉,不向子view传递事件。
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
intercepted = true;
}
上述代码的意思是当手指落在屏幕的时候,如果当前view正在滑动,则停止滑动,并且拦截事件。
实现滑动
上述讲了如何解决滑动冲突,那么,当我们拦截了事件之后,父view又该如何消费事件呢?放在场景中分析就是,如何像ViewPager一样实现以下功能:
1、view跟随手指滑动
2、手指在屏幕上大距离滑动时,view的切换
3、view的惯性滑动
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);
Log.d("SCROLL", "move " + deltaX);
break;
/*----------------------------------------手指离开屏幕时,滑动方式的算法--------------------------------------*/
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
}
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
首先,ACTION_DOWN不用讲,和onInterceptTouchEvent中一样,使用,但是朋友会对
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);
这个地方产生疑问,我来解释一下,scrollBy是view方法,此处省略了this. ,你懂得,参数是水平方向和垂直方向的偏移量,这个偏移量,和以往的认知相反,计算方式是起始位置减去结束位置,所以前面加了个负号。VelocityTracker是速度追踪,他能够追踪某个事件的速度,在本例中就是手指在屏幕上滑动时的速度。通过addMovement方法添加要追踪的事件。
接下来就到了最重要的ACTION_UP环节啦:
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
}
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
在ACTION_MOVE中我们实现了view跟随手指进行移动,使用的方法是view的scrollBy方法,很简单,但是当手指松开的时候,需要参考viewPager的实现效果—-如果手指离开屏幕时,手指在屏幕上横向移动了很大的距离,假设超过屏幕宽度一半,view会进行惯性滑动翻页,或者是手指离开屏幕时没有滑动很大距离,但是离开的瞬间横向滑动的速度足够快,view也会进行横向的惯性滑动进行翻页(以上为了表述方便忽略了临界时不翻页的情况,但是代码中有涉及,我也会进行相应的分析)。关于惯性滑动或者说是回弹到当前页面,任玉刚大神使用一个方法实现,那就是smoothScrollBy(dx, 0)。我们来看他的实现方法:
private void smoothScrollBy(int dx, int dy) {
mScroller.startScroll(getScrollX(), 0, dx, 0, 2000);
invalidate();
}
小伙伴也许会惊讶:WTF?这什么鬼,这样就可以实现惯性滑动和回弹到当前页面?不多说,上源码。
/**
* Start scrolling by providing a starting point, the distance to travel,
* and the duration of the scroll.
*
* @param startX Starting horizontal scroll offset in pixels. Positive
* numbers will scroll the content to the left.
* @param startY Starting vertical scroll offset in pixels. Positive numbers
* will scroll the content up.
* @param dx Horizontal distance to travel. Positive numbers will scroll the
* content to the left.
* @param dy Vertical distance to travel. Positive numbers will scroll the
* content up.
* @param duration Duration of the scroll in milliseconds.
*/
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
mMode = SCROLL_MODE;
mFinished = false;
mDuration = duration;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
mFinalX = startX + dx;
mFinalY = startY + dy;
mDeltaX = dx;
mDeltaY = dy;
mDurationReciprocal = 1.0f / (float) mDuration;
其实主要看方法注释,意思是说提供的位置点坐标,和滑动的距离,持续时间,开始滑动,怎么样,是不是感觉view滑动的神秘面纱被揭开了?
然后调用invaliafate方法通知view刷新即可。
方法很简单,那么重点就是smoothScrollBy方法的参数了。那么我们就在以下代码代码中来看一看参数dx代表的什么意思:
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
}
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx, 0);
首先mVelocityTracker.computeCurrentVelocity(1000)的意思是计算速度,然后有了这一部才可以获取速度float xVelocity = mVelocityTracker.getXVelocity(),速度的单位就是上面的参数1000毫秒内滑动像素数。然后获取滚动偏移量,这是我起得名字,他代表的确切含义不好说明白,需要读者通过log查看和思考。如果水平方向的速度大于50的话,我们就让view惯性滑到另一页,怎么指定滑到下一页呢?暂时,注意我们用的是暂时,使用全局变量mChildIndex,来保存要跳转到的view的索引。这时候值可能为-1和3,待会会进行处理。如果手指离开屏幕时速度不够快的话,我们就要判断当前的view的滑动偏移量是否超过了屏幕的一般。mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth。理解这个公式的前提是理解getScroolX所代表的意义。
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));这句话使用比较优雅的方式过滤掉了超出子view索引值的mChildIndex。
由于时间关系,就介绍到这里了。