http://leoray.leanote.com/post/viewgroup-custom
本文实现的效果如下:
一个自定义ViewGroup
,左右滑动切换不同的页面,类似一个特别简化的ViewPager
(当然,如果你设置的子View的宽度不是一个屏幕的宽度,那滑动出来就是另外一种效果了,当然,建议自己学示例的时候先按最普通的效果来),他涉及到的内容如下:
1. Scroller
:弹性滑动
,水平滑动到一半的时候,放开以后View滑动到一个页面完整显示的位置(这里的页面指的是一个子View),滑动超过一半则滑动到下一页或者上一页,没有超过则回到当前页面;
2. VelocityTracker
:用来检测速度,如果水平速度达到一定阈值则切换页面;
3. onInterceptTouchEvent
:拦截机制,这里用来解决滑动冲突,当一个TouchSlop
滑动的水平位移大于垂直位移的时候就由当前组件处理触摸事件,否则交给子View去处理,比如子View是一个ListView
的时候。
4. 自定义ViewGroup
:需要自己实现onMeasure
(尤其要处理wrap_content的情况),自己去实现onLayout
(需要去调用每一个子View的layout函数去布局子View)
本文的例子和《开发艺术探索》上的例子几乎一模一样,不过我们这里会一步步的是实现这个虽然不是特别复杂但是还是牵涉了很多知识点的自定义View。
虽然看着不复杂,但是要完全自己去写那还真不一定能写出来(就算你已经看过了源代码)。
其实每一个步骤的内容写完以后都可以跑一跑看看效果。
相关内容可以查看我的另外的一些博文:
1. 《View的事件基础知识》会介绍上面提到的VelocityTracker,Scroller,坐标体系等
2. 《View的事件分发机制》和View以及ViewGroup源码解析会有拦截,测量布局等相关内容。
建议先看这几篇文章,可以有一个概念的理解和简单的应用。
当然,里面有一块内容未介绍,就是自定义属性。不过这块比较简单,我们后面会有一个自定义View的文章,里面会介绍这个内容。
1. 新建HorizontalView继承ViewGroup
这个很简单了,创建一个类HorizontalView
继承ViewGroup
,调用父类构造方法,实现抽象方法等。
import android.content.Context;
import android.util.AttributeSet;
import android.view.ViewGroup;
/**
* Created by leoray on 16/1/15.
*/
public class HorizontalView extends ViewGroup{
//1. 至少要调用一个父类的构造方法
public HorizontalView(Context context) {
super(context);
}
public HorizontalView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public HorizontalView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public HorizontalView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
//2. 至少要实现抽象方法onLayout,我们这里先空实现了
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
}
}
2. 处理wrap_content的情况
为什么先处理这个呢?因为这个最简单,方法也是通用的。在设置在ViewGroup的layout_width和layout_height 为wrap_content 的时候,如果不设置一个指定的值,那么他的效果和match_parent是一样的,所以我们需要判断layout_width和layout_height为wrap_content(也就是widthMode或者heightMode为AT_MOST)的时候设置默认值。这里将它设置为第一个子元素的宽和高。
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
/**
* 可以水平滑动的ViewGroup
* Created by leoray on 16/1/15.
*/
public class HorizontalView extends ViewGroup {
//...这里是那几个构造方法的代码
//这里需要测量这个ViewGroup的宽和高
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//得到宽和高的MODE和SIZE
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
//测量所有子元素,先执行,不然后面拿不到第一个子元素的测量宽/高
measureChildren(widthMeasureSpec, heightMeasureSpec);
//处理wrap_content的情况
if (getChildCount() == 0) { //如果没有子元素,就设置宽高都为0(简化处理)
setMeasuredDimension(0, 0);
}
//宽和高都是AT_MOST,则设置宽为第一个子元素的宽度乘以子元素的个数(这里默认每个子元素都和第一个元素一样的宽度);高度设置为第一个元素的高度;
//当然,我们最后用的时候子元素的宽度和高度就是屏幕的宽度和高度
else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
View childOne = getChildAt(0);
int childWidth = childOne.getMeasuredWidth();
int childHeight = childOne.getMeasuredHeight();
setMeasuredDimension(childWidth * getChildCount(), childHeight);
}
//这里只有宽度是AT_MOST,那就设置高度为系统测量的高度,宽度和第一个if中的一样
else if (widthMode == MeasureSpec.AT_MOST) {
View childOne = getChildAt(0);
int childWidth = childOne.getMeasuredWidth();
setMeasuredDimension(childWidth * getChildCount(), heightSize);
}
//这里只有高度是AT_MOST,那就设置宽度为系统测量的宽度,高度和第一个if中的一样
else if (heightMode == MeasureSpec.AT_MOST) {
int childHeight = getChildAt(0).getMeasuredHeight();
setMeasuredDimension(widthSize, childHeight);
}
//宽度和高度都不是AT_MOST的情况在super方法中已经设置了:宽高都是系统测量的结果;
}
//...这里还是onLayout的空实现的代码
}
3. 实现onLayout去布局子元素
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;
/**
* 可以水平滑动的ViewGroup
* Created by leoray on 16/1/15.
*/
public class HorizontalView extends ViewGroup {
//... 这里省略了那几个构造方法代码和onMeasure的代码
//这里实现布局,主要是对子View进行布局,因为每一种布局方式子View的布局都是不同的,所以这个是ViewGroup唯一一个抽象方法,需要我们自己去实现
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount(); //子元素的个数
int left = 0; //左边的距离
View child;
//遍历布局子元素
for (int i = 0; i < childCount; i++) {
child = getChildAt(i);
int width = child.getMeasuredWidth();
//调用每个子元素的layout方法去布局这个子元素,这里相当于默认第一个子元素占满了屏幕,后面的子元素就是在第一个屏幕后面紧挨着和屏幕一样大小的后续元素,所以left是一直累加的,top保持0,bottom保持第一个元素的高度,right就是left+元素的宽度
child.layout(left, 0, left + width, child.getMeasuredHeight());
left += width;
}
}
}
就像下面这张图一样,每个子View的布局的四个坐标就按照第一个View来设置的。第一个大的虚线框是手机屏幕。
3. 处理滑动冲突,实现onInterceptTouchEvent
上图中我们做好的测量和布局,但是我们只能看到第一个子View,那么怎么看到第二个,第三个及后面的View,怎么在这些View中切换呢?就需要通过滑动了。
但是我这里没有直接先去写滑动,而是先去处理滑动冲突,主要有两个原因:
1. 滑动冲突的逻辑很简单,好写
2. 因为他原本的测试的例子子View都是一个ListView,这样,我滑动的时候肯定是要先保证我的父View能拦截到触摸事件,不然默认都跑到子View去了
这里ViewGroup是水平滑动,里面的ListView是垂直滑动,一般我们设置的逻辑是:
1. 如果我们检测到的滑动方向是水平的话,就让父View拦截用来进行View的滑动切换
2. 如果检测到方向是垂直的话,就不进行拦截,交给子View去处理,比如ListView的垂直滑动
当然,也有些逻辑是触摸起始位置在边缘且是水平方向才会进行页面的切换,因为子View可能也需要水平方向的滑动事件。
我们这里就直接选择第一种处理逻辑。
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;
/**
* 可以水平滑动的ViewGroup
* Created by leoray on 16/1/15.
*/
public class HorizontalView extends ViewGroup {
//... 这里省略了构造函数的代码
int lastInterceptX; //记录上一次触摸的位置
int lastInterceptY;
@Override
public boolean onInterceptHoverEvent(MotionEvent event) {
//处理滑动冲突,也就是什么时候返回true的问题
//规则:开始滑动时水平距离超过垂直距离的时候
boolean intercept = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - lastInterceptX; //水平方向滑动的距离(有正有负)
int deltaY = y - lastInterceptY; //垂直方向滑动的距离(有正有负)
if (Math.abs(deltaX) - Math.abs(deltaY) > 0) { //水平方向距离更长,说明用户是想水平滑动的,所以拦截
intercept = true;
}
break;
case MotionEvent.ACTION_UP:
break;
}
//因为一个滑动事件是先经过的DOWN,所以在MOVE的时候,这两个值已经设置过了
lastInterceptX = x;
lastInterceptY = y;
return intercept;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
//... 这里省略了onMeasure和onLayout的代码
}
4. 滑动页面
上面一节已经确保了我们执行水平滑动的时候由当前父View去进行事件处理,这里就会进入onTouchEvent事件。然后我们需要进行滑动切换页面。
我们先不去实现切换页面,先实现怎么样让页面滑动,也就是手指拖动的时候页面也跟着移动,这里用到了scrollTo/scrollBy这样的方法,第一个方法是将View滑动到指定的位置,第二个是将View滑动指定的距离。
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;
/**
* 可以水平滑动的ViewGroup
* Created by leoray on 16/1/15.
*/
public class HorizontalView extends ViewGroup {
//...这里省略了构造方法和onInterceptTouchEvent方法的代码
int lastX; //记录上一次触摸事件的位置
int lastY;
@Override
public boolean onTouchEvent(MotionEvent event) {
//得到本次触摸的位置
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - lastX; //手指滑动的距离
//调用该方法让View也对应的移动指定的距离,这样就实现了跟随手指滑动的效果,垂直方向不移动
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP:
break;
}
lastX = x; //存储当前位置为上一次位置
lastY = y;
//return super.onTouchEvent(event);
// 这里不能调用父类的方法,因为ViewGroup是没有去实现onTouchEvent的方法的,所以super调用的是View的实现;
// 而View的默认实现是没有点击长按等事件导致在DOWN的时候就直接返回false了,导致没有执行MOVE和UP方法
return true;
}
//这里省略了onMeasure和onLayout的代码
}
如果你执行当前状态的代码去测试的话,你会发现又一个问题。我按下滑动的时候,刚按下,整个View瞬间跳到了我手指按下的地方然后开始跟随手指滑动,这是为什么呢?
其实原因很简单,我这里执行了scrollBy()让水平方向移动了deltaX个单位,说明问题就出在计算这个deltaX上了。其实原因在于lastX的值的设置上,这个和ViewGroup的拦截机制相关,我们前面在MOVE的时候拦截了触摸事件,但是在DOWN的时候是返回false的,所以DOWN时候的触摸事件被子View消耗掉了,所以onTouchEvent中是无法看到DOWN的,所以lastX就不会像在onInterceptTouchEvent中一样,在DOWN的时候被赋值,然后第一个MOVE的时候就可以判断当前的滑动式水平还是垂直了。由于onTouchEvent中没有DOWN事件了,第一个MOVE的时候,lastX=0,而第一个MOVE的getX就是当前鼠标的位置,两个值相减得到的还是x,而不是一个TouchSlop
,所以View内容就立即滑动到了当前手指的位置,解决办法就是将lastX和lastY在onInterceptTouchEvent中也进行赋值,因为DOWN事件在onInterceptTouchEvent中也可以得到,这种,第一个MOVE拿到的也是一个TouchSlop:
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;
/**
* 可以水平滑动的ViewGroup
* Created by leoray on 16/1/15.
*/
public class HorizontalView extends ViewGroup {
int lastInterceptX;
int lastInterceptY;
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
//...这里省略了方法体内一对代码
lastInterceptX = x; //因为先经过的DOWN,所以在MOVE的时候,这两个值已经有了
lastInterceptY = y;
lastX=x; //**这里加了关键的两行代码**
lastY=y;
return intercept;
}
int lastX;
int lastY;
int currentIndex = 0; //当前子元素
private Scroller scroller;
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Log.d("HV", "TouchEvent.DOWN");
break;
case MotionEvent.ACTION_MOVE:
Log.d("HV", "TouchEvent.MOVE");
int deltaX = x - lastX; //跟随手指滑动
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP: //释放手指以后开始自动滑动到目标位置
Log.d("HV", "TouchEvent.UP");
break;
}
lastX = x;
lastY = y;
return super.onTouchEvent(event);
}
//...这里省略了测量onMeasure和布局onLayout的代码
}
5. 滑动到下/上一个页面
处理逻辑:
1. 如果当前页面是第一个页面,向右滑动任意单位后均弹性滑动
回当前页面
2. 如果当前页面是最后一个页面,向左滑动任意单位均弹性滑动
回当前页面
3. 如果向左滑动滑动超过宽度(也就是屏幕)的一半则跳转到下一个页面
4. 如果向右滑动超过宽度的一般则跳转到上一个页面
方法是在ACTION_UP中进行处理,因为只有在滑动完成释放的时候,我们才会让页面去自动滑动到下/上一个View或者滑动回当前View。
5.1 弹性滑动测试
这里需要用到一个新的内容Scroller
,弹性滑动,他的使用方法很通用,主要需要设置三个地方,然后在ACTION_UP的时候调用一次,如下面代码中的1-4个步骤:
package com.leishengwei.viewstudy;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;
/**
* 可以水平滑动的ViewGroup
* Created by leoray on 16/1/15.
*/
public class HorizontalView extends ViewGroup {
//1.1 声明Scroller对象,1.2在init()方法内
public HorizontalView(Context context) {
super(context);
init();
}
public HorizontalView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public HorizontalView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public HorizontalView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init();
}
private Scroller scroller;
public void init() {
//1.2 在构造函数中初始化Scroller
scroller = new Scroller(getContext());
}
//...这里省略了onInterceptTouchEvent的代码
int lastX;
int lastY;
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Log.d("HV", "TouchEvent.DOWN");
break;
case MotionEvent.ACTION_MOVE:
Log.d("HV", "TouchEvent.MOVE");
int deltaX = x - lastX; //跟随手指滑动
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP: //释放手指以后开始自动滑动到目标位置
Log.d("HV", "TouchEvent.UP");
//这里我们先测试一下,每次都滑动到第一个子View的位置,也就是(0,0)的位置,可以再去运行一下,看是不是每次滑动释放以后,View都滑动到了最初始的状态
smoothScrollTo(0, 0);
break;
}
lastX = x;
lastY = y;
return super.onTouchEvent(event);
}
//...这里省略了 onLayout和onMeasure的代码
//2. 重写这个方法,执行如下内容
@Override
public void computeScroll() {
super.computeScroll();
//2.1 先计算当前Scroller的偏移
if (scroller.computeScrollOffset()) {
//2.2 然后调用我们熟悉的scrollTo将View移动到getCurrX,getCurrY的位置
scrollTo(scroller.getCurrX(), scroller.getCurrY());
//2.3 通知刷新界面
postInvalidate();
}
}
//3. 这个是工具方法,弹性滑动到指定位置
public void smoothScrollTo(int destX, int destY) {
//3.1 调用startScroll
scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000);
//3.2 刷新
invalidate();
}
}
5.2 滑动到其他页面
然后就是具体的切换逻辑实现了。新代码主要是在onTouchEvent中的ACTION_UP部分
1. 首先我们需要一个页面索引来记录当前的页面(也就是子View的index),用这个currentIndex可以快速计算出需要切换的页面需要滑动的目标位置。
2. 然后就是数学问题了,判断怎么样才会切换页面并且调用方法去切换
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;
/**
* 可以水平滑动的ViewGroup
* Created by leoray on 16/1/15.
*/
public class HorizontalView extends ViewGroup {
//... 省略构造函数,init方法,onInterceptTouchEvent
int lastInterceptX;
int lastInterceptY;
int lastX;
int lastY;
int currentIndex = 0; //当前子元素
int childWidth = 0; //子元素的宽度,这个我们可以在onLayout或者onMeasure中进行赋值 ,只要保证在measure结束之后,把他设置为第一个子View的宽度即可(本例中在下面的`onLayout`中进行设置)
private Scroller scroller;
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Log.d("HV", "TouchEvent.DOWN");
break;
case MotionEvent.ACTION_MOVE:
Log.d("HV", "TouchEvent.MOVE");
int deltaX = x - lastX; //跟随手指滑动
Log.d("HV", "move:" + -deltaX);
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP: //释放手指以后开始自动滑动到目标位置
Log.d("HV", "TouchEvent.UP");
//这里都是逻辑实现代码,需要自己思考了,比如我们计算的这个distance
int distance = getScrollX() - currentIndex * childWidth; //相对于当前View滑动的距离,正为向左,负为向右
if (Math.abs(distance) > childWidth / 2) {//必须滑动的距离要大于1/2个宽度,否则不会切换到其他页面
if (distance > 0) { //切换到下一个页面
currentIndex++;
} else { //切换到上一个页面
currentIndex--;
}
}
currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex; //这里保证边界值
smoothScrollTo(currentIndex * childWidth, 0); //滑动到指定位置,每一个子View的滑动位置设置其实很简单
break;
}
lastX = x;
lastY = y;
return super.onTouchEvent(event);
}
//...省略onMeasure,computeScroll,smoothScrollTo方法
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int left = 0; //左边的距离
View child;
//遍历布局子元素
for (int i = 0; i < childCount; i++) {
child = getChildAt(i);
int width = child.getMeasuredWidth();
childWidth = width; //**新代码: 赋值为子元素的宽度**
child.layout(left, 0, left + width, child.getMeasuredHeight());
left += width;
}
}
}
这样,就实现了页面之间的滑动切换了。
6. 快速滑动进行切换
这里增加一个逻辑,就是我们不需要滑动超过一般才切换到上/下一个页面,如果滑动速度很快的话,我们也可以判定为用户想要滑动到其他页面,这样的体验也是好的。
这部分也是在onTouchEvent中的ACTION_UP部分,处理逻辑如下:
1. 检测当前滑动的速度,如果超过一定的阈值则就算滑动没有超过一半也进行页面切换;
2. 向左快速滑动则切换到下一个子View;
3. 向右快速滑动则切换到上一个子View。
这里又需要用到一个新的内容:VelocityTracker,用来测试滑动速度的。使用方法也很简单,首先在构造函数中进行初始化,也就是前面的init方法中增加一条语句(首先声明成员变量):
...
private VelocityTracker tracker;
...
public void init() {
scroller = new Scroller(getContext());
tracker=VelocityTracker.obtain();
}
...
然后的代码就在onTouchEvent中的ACTION_UP部分了:
...
@Override
public boolean onTouchEvent(MotionEvent event) {
...
case MotionEvent.ACTION_UP: //释放手指以后开始自动滑动到目标位置
Log.d("HV", "TouchEvent.UP");
int distance = getScrollX() - currentIndex * childWidth; //相对于当前View滑动的距离,正为向左,负为向右
if (Math.abs(distance) > childWidth / 2) {//必须滑动的距离要大于1/2个宽度,否则不会切换到其他页面
if (distance > 0) {
currentIndex++;
} else {
currentIndex--;
}
}
//**新代码主要是增加了一个else部分**
else {
//调用该方法计算1000ms内滑动的平均速度 tracker.computeCurrentVelocity(1000);
float xV = tracker.getXVelocity(); //获取到水平方向上的速度
if (Math.abs(xV) > 50) { //如果速度的绝对值大于50的话,就认为是快速滑动,就执行切换页面
if (xV > 0) { //大于0切换上一个页面
currentIndex--;
} else { //小于0切换到下一个页面(子View)
currentIndex++;
}
}
}
currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex;
smoothScrollTo(currentIndex * childWidth, 0);
//最后要进行的是VelocityTracker#clear重置速度计算器
tracker.clear();
break;
…
7. 优化:弹性滑动过程中再次触摸屏幕阻止页面继续滑动的情况
例如,当我们快速向左滑动切换到下一个页面的情况,在手指释放(ACTION_UP)以后,页面会弹性滑动到下一个页面,可能需要一秒才完成滑动,这个时间内,我们再次触摸屏幕,希望能拦截这次滑动,然后再次去操作页面。
这部分的方法如下:
要实现在弹性滑动过程中再次触摸拦截,肯定要在onInterceptTouchEvent中的ACTION_DOWN中去判断,如果在ACTION_DOWN的时候,scroller还没有完成,说明上一次的滑动还正在进行中,则直接终端scroller并且返回true,表示在DOWN的时候就拦截事件,那么后续的MOVE,UP都不会传递到子View中去了。
...
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
//处理滑动冲突,也就是什么时候返回true的问题
//规则:开始滑动时水平距离超过垂直距离的时候
boolean intercept = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: //DOWN返回false,导致onTouchEvent中无法获取到DOWN
intercept = false;
Log.d("HV", "Intercept.DOWN");
//**新代码**
if (!scroller.isFinished()) { //如果动画还没有执行完成,则打断,这种情况肯定还是由父组件处理触摸事件所以返回true
scroller.abortAnimation();
intercept = true;
}
break;
case MotionEvent.ACTION_MOVE:
Log.d("HV", "Intercept.MOVE");
int deltaX = x - lastInterceptX;
int deltaY = y - lastInterceptY;
if (Math.abs(deltaX) - Math.abs(deltaY) > 0) { //水平方向距离长 MOVE中返回true一次,后续的MOVE和UP都不会收到此请求
intercept = true;
Log.d("HV", "intercepted");
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
Log.d("HV", "Intercept.UP");
intercept = false;
break;
}
//因为DOWN返回true,所以onTouchEvent中无法获取DOWN事件,所以这里要负责设置lastX,lastY
lastX = x;
lastY = y;
lastInterceptX = x; //因为先经过的DOWN,所以在MOVE的时候,这两个值已经有了
lastInterceptY = y;
return intercept;
}
...
测试代码的话,可以试着往这个自定义View中添加三四个ListView,每个ListView最好设置一个HeaderView来标识这个ListView是第几个页面,然后随机添加一些列表项进去,最后添加到一个Activity中去就可以了。子View的宽设置为MATCH_PARENT就是一个滑动切换页面了,你要是想试着设置为固定值也可以看看效果了。
完整的源码
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;
/**
* 可以水平滑动的ViewGroup
* 总有有以下几个知识点:
* 1. VelocityTracker:用来检测速度,如果水平速度达到一定阈值则切换页面
* 2. Scroller:弹性滑动,水平滑动到一半的时候,放开以后View滑动到标准的某个页面的位置
* 3. onInterceptTouchEvent:拦截机制,也是解决滑动冲突,当水平位移大于垂直位移的时候就有当前组件处理触摸事件而不是子View
* 4. 自定义ViewGroup:需要自己实现onMeasure(尤其要处理wrap_content的情况),自己去实现onLayout(需要去调用每一个子View的layout函数去布局子View)
* Created by leoray on 16/1/15.
*/
public class HorizontalView extends ViewGroup {
public HorizontalView(Context context) {
super(context);
init();
}
public HorizontalView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public HorizontalView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public HorizontalView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init();
}
public void init() {
scroller = new Scroller(getContext());
tracker = VelocityTracker.obtain();
}
int lastInterceptX;
int lastInterceptY;
//todo intercept的拦截逻辑
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
//处理滑动冲突,也就是什么时候返回true的问题
//规则:开始滑动时水平距离超过垂直距离的时候
boolean intercept = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: //DOWN返回false,导致onTouchEvent中无法获取到DOWN
intercept = false;
Log.d("HV", "Intercept.DOWN");
if (!scroller.isFinished()) { //如果动画还没有执行完成,则打断,这种情况肯定还是由父组件处理触摸事件所以返回true
scroller.abortAnimation();
intercept = true;
}
break;
case MotionEvent.ACTION_MOVE:
Log.d("HV", "Intercept.MOVE");
int deltaX = x - lastInterceptX;
int deltaY = y - lastInterceptY;
if (Math.abs(deltaX) - Math.abs(deltaY) > 0) { //水平方向距离长 MOVE中返回true一次,后续的MOVE和UP都不会收到此请求
intercept = true;
Log.d("HV", "intercepted");
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
Log.d("HV", "Intercept.UP");
intercept = false;
break;
}
//因为DOWN返回true,所以onTouchEvent中无法获取DOWN事件,所以这里要负责设置lastX,lastY
lastX = x;
lastY = y;
lastInterceptX = x; //因为先经过的DOWN,所以在MOVE的时候,这两个值已经有了
lastInterceptY = y;
return intercept;
}
int lastX;
int lastY;
int currentIndex = 0; //当前子元素
int childWidth = 0;
private Scroller scroller;
private VelocityTracker tracker; //增加速度检测,如果速度比较快的话,就算没有滑动超过一半的屏幕也可以
@Override
public boolean onTouchEvent(MotionEvent event) {
tracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Log.d("HV", "TouchEvent.DOWN");
if (!scroller.isFinished()) {
scroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
Log.d("HV", "TouchEvent.MOVE");
int deltaX = x - lastX; //跟随手指滑动
Log.d("HV", "move:" + -deltaX);
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP: //释放手指以后开始自动滑动到目标位置
Log.d("HV", "TouchEvent.UP");
int distance = getScrollX() - currentIndex * childWidth; //相对于当前View滑动的距离,正为向左,负为向右
if (Math.abs(distance) > childWidth / 2) {//必须滑动的距离要大于1/2个宽度,否则不会切换到其他页面
if (distance > 0) {
currentIndex++;
} else {
currentIndex--;
}
} else {
tracker.computeCurrentVelocity(1000);
float xV = tracker.getXVelocity();
if (Math.abs(xV) > 50) {
if (xV > 0) {
currentIndex--;
} else {
currentIndex++;
}
}
}
currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex;
smoothScrollTo(currentIndex * childWidth, 0);
tracker.clear();
break;
}
lastX = x;
lastY = y;
// return super.onTouchEvent(event);
// 这里不能调用父类的方法,因为ViewGroup是没有去实现onTouchEvent的方法的,所以super调用的是View的实现;
// 而View的默认实现是没有点击长按等事件导致在DOWN的时候就直接返回false了,导致没有执行MOVE和UP方法
return true;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
//测量所有子元素
measureChildren(widthMeasureSpec, heightMeasureSpec);
//处理wrap_content的情况
if (getChildCount() == 0) {
setMeasuredDimension(0, 0);
} else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
View childOne = getChildAt(0);
int childWidth = childOne.getMeasuredWidth();
int childHeight = childOne.getMeasuredHeight();
setMeasuredDimension(childWidth * getChildCount(), childHeight);
} else if (widthMode == MeasureSpec.AT_MOST) {
View childOne = getChildAt(0);
int childWidth = childOne.getMeasuredWidth();
setMeasuredDimension(childWidth * getChildCount(), heightSize);
} else if (heightMode == MeasureSpec.AT_MOST) {
int childHeight = getChildAt(0).getMeasuredHeight();
setMeasuredDimension(widthSize, childHeight);
}
}
//scroller的标准用法步骤
@Override
public void computeScroll() {
super.computeScroll();
if (scroller.computeScrollOffset()) {
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
//scroller的标准用法步骤
public void smoothScrollTo(int destX, int destY) {
scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000);
invalidate();
}
//不要问我当前View参数中的四个位置是哪儿来的
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int left = 0; //左边的距离
View child;
//遍历布局子元素
for (int i = 0; i < childCount; i++) {
child = getChildAt(i);
int width = child.getMeasuredWidth();
childWidth = width; //赋值给子元素宽度变量
child.layout(left, 0, left + width, child.getMeasuredHeight());
left += width;
}
}
}