一、根据触摸行为辨别手势动作
常见手势的行为特征及其检测办法,内容包括如何通过按压时长与按压力度区分点击和长按手势、如何根据触摸起点与终点的位置识别手势滑动的方向、如何利用双指按压以及它们的滑动轨迹辨别缩放与旋转手势。
1、区分点击和长按动作
根据触摸事件可以识别按压动作的时空关系,就能进一步判断用户的手势意图。比如区分点击和长按动作,只要看按压时长是否超过500毫秒即可,没超过的表示点击动作,超过了的表示长按动作。其实,除了按压时长之外,按压力度也是一个重要的参考指标。通常,点击时按得比较轻,长按时按得相对重。依据按压时长与按压力度两项指标即可有效地辨别点击和长按
接下来尝试自定义点击视图,且以按压点为圆心绘制圆圈,从而分别观察点击与长按之时的圆圈大小。定义点击视图的部分示例代码如下:完整代码ClickView
@Override
protected void onDraw(Canvas canvas) {
if (mPos != null) {
Log.i(TAG,"圆点半径="+dip_10*mPressure);
if (mPressure == 1 && time >500){
// 以按压点为圆心,压力值为半径,在画布上绘制实心圆
canvas.drawCircle(mPos.x, mPos.y, dip_10*mPressure*3, mPaint);
}else {
// 以按压点为圆心,压力值为半径,在画布上绘制实心圆
canvas.drawCircle(mPos.x, mPos.y, dip_10*mPressure, mPaint);
}
}
}
// 在发生触摸事件时触发
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction()==MotionEvent.ACTION_DOWN
|| (event.getPressure()>mPressure)) {
mPos = new PointF(event.getX(), event.getY());
//Android的触摸事件的压力值默认为1.0,因为大多数Android设备不支持压力感应功能。
// 如果设备支持压力感应,则可以使用event.getPressure()方法获取实际的压力值。
mPressure = event.getPressure(); // 获取本次触摸过程的最大压力值
Log.i(TAG,"压力值="+mPressure);
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: // 按下手指
mLastTime = event.getEventTime();
break;
case MotionEvent.ACTION_MOVE: // 移动手指
break;
case MotionEvent.ACTION_UP: // 松开手指
if (mListener != null) { // 触发手势抬起事件
time = event.getEventTime()-mLastTime;
mListener.onLift(time, mPressure);
}
break;
}
postInvalidate(); // 立即刷新视图(线程安全方式)
return true;
}
然后在布局文件中添加ClickView节点,并在对应的活动页面调用setLiftListener方法设置手势抬起监听器,看看点击和长按的描圆效果究竟为何。下面是设置手势监听器的部分示例代码:完整代码ClickLongActivity
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_click_long);
tv_desc = findViewById(R.id.tv_desc);
ClickView cv_gesture = findViewById(R.id.cv_gesture);
// 设置点击视图的手势抬起监听器
cv_gesture.setLiftListener((time_interval, pressure) -> {
String gesture = time_interval>500 ? "长按" : "点击";
String desc = String.format("本次按压时长为%d毫秒,属于%s动作。\n按压的压力峰值为%f",
time_interval, gesture, pressure);
tv_desc.setText(desc);
});
}
运行并测试该App,手势按压效果如下图所示。图1为点击手势的检测结果,此时圆圈较小;图2为长按手势的检测结果,此时圆圈较大。
2、识别手势滑动的方向
除了点击和长按,分辨手势的滑动方向也很重要,手势往左抑或往右代表着左右翻页,往上或者往下代表着上下滚动。另外,手势向下还可能表示下拉刷新,手势向上还可能表示上拉加载,总之,上、下、左、右四个方向各有不同的用途。
直观地看,手势在水平方向掠过,意味着左右滑动;手势在垂直方向掠过,意味着上下滚动。左右滑动的话,手势触摸的起点和终点在水平方向的位移必定大于垂直方向的位移;反之,上下滚动的话,它们在垂直方向的位移必定大于水平方向的位移。据此可将滑动方向的判定过程分解成以下三个步骤:
(1)对于按下手指事件,把当前点标记为起点,并记录起点的横纵坐标。
(2)对于松开手指事件,把当前点标记为终点,并记录终点的横纵坐标。
(3)分别计算起点与终点的横坐标距离以及它们的纵坐标距离,根据横纵坐标的大小关系判断本次手势的滑动方向。
于是重写自定义触摸视图的onTouchEvent方法,分别处理按下、移动、松开三种手势事件;同时重写该视图的onDraw方法,描绘起点与终点的位置,以及从起点到终点的路径线条。按照上述思路,编写单指触摸视图的代码:
public class SingleTouchView extends View {
private static final String TAG = "SingleTouchView";
private Paint mPathPaint, mBeginPaint, mEndPaint; // 路径的画笔,以及起点和终点的画笔
private Path mPath = new Path(); // 声明一个路径对象
private PointF mLastPos, mBeginPos, mEndPos; // 路径中的上次触摸点,本次按压的起点和终点
private int dip_17;
public SingleTouchView(Context context) {
this(context,null);
}
public SingleTouchView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
dip_17 = Utils.dip2px(context, 17);
initView(); // 初始化视图
}
// 初始化视图
private void initView() {
mPathPaint = new Paint(); // 创建路径的画笔
mPathPaint.setStrokeWidth(5); // 设置画笔的线宽
mPathPaint.setStyle(Paint.Style.STROKE); // 设置画笔的类型。STROK表示空心,FILL表示实心
mPathPaint.setColor(Color.BLACK); // 设置画笔的颜色
mBeginPaint = new Paint(); // 创建起点的画笔
mBeginPaint.setColor(Color.RED); // 设置画笔的颜色
mBeginPaint.setTextSize(dip_17); // 设置画笔的文字大小
mEndPaint = new Paint(); // 创建终点的画笔
mEndPaint.setColor(Color.DKGRAY); // 设置画笔的颜色
mEndPaint.setTextSize(dip_17); // 设置画笔的文字大小
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawPath(mPath, mPathPaint); // 在画布上绘制指定路径线条
if (mBeginPos != null) { // 存在起点,则绘制起点的实心圆及其文字
canvas.drawCircle(mBeginPos.x, mBeginPos.y, 10, mBeginPaint);
canvas.drawText("起点", mBeginPos.x-dip_17, mBeginPos.y+dip_17, mBeginPaint);
}
if (mEndPos != null) { // 存在终点,则绘制终点的实心圆及其文字
canvas.drawCircle(mEndPos.x, mEndPos.y, 10, mEndPaint);
canvas.drawText("终点", mEndPos.x-dip_17, mEndPos.y+dip_17, mEndPaint);
}
}
// 在发生触摸事件时触发
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: // 按下手指
mPath.reset();
mPath.moveTo(event.getX(), event.getY()); // 移动到指定坐标点
mBeginPos = new PointF(event.getX(), event.getY());
mEndPos = null;
break;
case MotionEvent.ACTION_MOVE: // 移动手指
// 连接上一个坐标点和当前坐标点
mPath.quadTo(mLastPos.x, mLastPos.y, event.getX(), event.getY());
break;
case MotionEvent.ACTION_UP: // 松开手指
mEndPos = new PointF(event.getX(), event.getY());
// 连接上一个坐标点和当前坐标点
mPath.quadTo(mLastPos.x, mLastPos.y, event.getX(), event.getY());
if (mListener != null) { // 触发手势飞掠动作
mListener.onFlipFinish(mBeginPos, mEndPos);
}
break;
}
mLastPos = new PointF(event.getX(), event.getY());
postInvalidate(); // 立即刷新视图(线程安全方式)
return true;
}
private FlipListener mListener; // 声明一个手势飞掠监听器
public void setFlipListener(FlipListener listener) {
mListener = listener;
}
// 定义一个手势飞掠的监听器接口
public interface FlipListener {
void onFlipFinish(PointF beginPos, PointF endPos);
}
}
然后在布局文件中添加SingleTouchView节点,并在对应的活动页面调用setFlipListener方法设置手势滑动监听器,看看手势到底往哪个方向滑动。下面是设置手势监听器的部分示例代码:完整代码SlideDirectionActivity
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_slide_direction);
tv_desc = findViewById(R.id.tv_desc);
SingleTouchView stv_gesture = findViewById(R.id.stv_gesture);
// 设置单点触摸视图的手势飞掠监听器
stv_gesture.setFlipListener((beginPos, endPos) -> {
float offsetX = Math.abs(endPos.x - beginPos.x);
float offsetY = Math.abs(endPos.y - beginPos.y);
String gesture = "";
if (offsetX > offsetY) { // 水平方向滑动
gesture = (endPos.x - beginPos.x > 0) ? "向右" : "向左";
} else if (offsetX < offsetY) { // 垂直方向滑动
gesture = (endPos.y - beginPos.y > 0) ? "向下" : "向上";
} else { // 对角线滑动
gesture = "对角线";
}
String desc = String.format("%s 本次手势为%s滑动", DateUtil.getNowTime(), gesture);
tv_desc.setText(desc);
});
}
运行并测试该App,手势滑动效果如下图所示。图1为左滑手势的检测结果,图2为右滑手势的检测结果,图3为上滑手势的检测结果,图4为下滑手势的检测结果。
3、辨别缩放与旋转手势
一个手指的滑动只能识别手势的滑动方向,两个手指的滑动才能识别更复杂的手势动作。比如两个手指张开可表示放大操作,两个手指并拢可表示缩小操作,两个手指交错旋转表示旋转操作,而旋转方向又可细分为顺时针旋转和逆时针旋转。那么如何辨别手势的缩放与旋转动作呢?由于两个手指各有自己的按下与松开事件,都有对应的触摸起点和终点,因此只要依次记录两个手指的起点和终点坐标,根据这四个点的位置关系就能算出手势的动作类别。
至于缩放手势与旋转手势的区分,则需分别计算第一个手势起点和终点的连线,以及第二个手势起点和终点的连线,再判断两根连线是倾向于在相同方向上缩放还是倾向于绕着连线中点旋转。按照上述思路编写双指触摸视图的关键代码:完整代码MultiTouchView
@Override
protected void onDraw(Canvas canvas) {
canvas.drawPath(mFirstPath, mPathPaint); // 在画布上绘制指定路径线条
canvas.drawPath(mSecondPath, mPathPaint); // 在画布上绘制指定路径线条
if (isFinish) { // 结束触摸,则绘制两个起点的连线,以及两个终点的连线
if (mFirstBeginP!=null && mSecondBeginP!=null) { // 绘制两个起点的连线
canvas.drawLine(mFirstBeginP.x, mFirstBeginP.y, mSecondBeginP.x, mSecondBeginP.y, mBeginPaint);
}
if (mFirstEndP!=null && mSecondEndP!=null) { // 绘制两个终点的连线
canvas.drawLine(mFirstEndP.x, mFirstEndP.y, mSecondEndP.x, mSecondEndP.y, mEndPaint);
}
}
}
// 在发生触摸事件时触发
@Override
public boolean onTouchEvent(MotionEvent event) {
PointF firstP = new PointF(event.getX(), event.getY());
PointF secondP = null;
if (event.getPointerCount() >= 2) { // 存在多点触摸
secondP = new PointF(event.getX(1), event.getY(1));
}
// 获得包括次要点在内的触摸行为
int action = event.getAction() & MotionEvent.ACTION_MASK;
if (action == MotionEvent.ACTION_DOWN) { // 主要点按下
isFinish = false;
mFirstPath.reset();
mSecondPath.reset();
mFirstPath.moveTo(firstP.x, firstP.y); // 移动到指定坐标点
mFirstBeginP = new PointF(firstP.x, firstP.y);
mFirstEndP = null;
} else if (action == MotionEvent.ACTION_MOVE) { // 移动手指
if (!isFinish) {
// 连接上一个坐标点和当前坐标点
mFirstPath.quadTo(mFirstLastP.x, mFirstLastP.y, firstP.x, firstP.y);
if (secondP != null) {
// 连接上一个坐标点和当前坐标点
mSecondPath.quadTo(mSecondLastP.x, mSecondLastP.y, secondP.x, secondP.y);
}
}
} else if (action == MotionEvent.ACTION_UP) { // 主要点松开
} else if (action == MotionEvent.ACTION_POINTER_DOWN) { // 次要点按下
mSecondPath.moveTo(secondP.x, secondP.y); // 移动到指定坐标点
mSecondBeginP = new PointF(secondP.x, secondP.y);
mSecondEndP = null;
} else if (action == MotionEvent.ACTION_POINTER_UP) { // 次要点松开
isFinish = true;
mFirstEndP = new PointF(firstP.x, firstP.y);
mSecondEndP = new PointF(secondP.x, secondP.y);
if (mListener != null) { // 触发手势滑动动作
mListener.onSlideFinish(mFirstBeginP, mFirstEndP, mSecondBeginP, mSecondEndP);
}
}
mFirstLastP = new PointF(firstP.x, firstP.y);
if (secondP != null) {
mSecondLastP = new PointF(secondP.x, secondP.y);
}
postInvalidate(); // 立即刷新视图(线程安全方式)
return true;
}
然后在布局文件中添加MultiTouchView节点,并在对应的活动页面调用setSlideListener方法设置手势滑动监听器,看看是缩放手势还是旋转手势(判定算法如下图)
假设手势的起点位于上图的中心位置,如果手势的终点落在上图的左下角或者右上角,则表示本次为缩放手势;如果手势的终点落在上图的左上角或者右下角,则表示本次为旋转手势。据此编写的判定算法代码如下:完整代码ScaleRotateActivity
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_scale_rotate);
tv_desc = findViewById(R.id.tv_desc);
MultiTouchView mtv_gesture = findViewById(R.id.mtv_gesture);
// 设置多点触摸视图的手势滑动监听器
mtv_gesture.setSlideListener((firstBeginP, firstEndP, secondBeginP, secondEndP) -> {
// 上次两个触摸点之间的距离
float preWholeDistance = PointUtil.distance(firstBeginP,secondBeginP);
// 当前两个触摸点之间的距离
float nowWholeDistance = PointUtil.distance(firstEndP, secondEndP);
// 主要点在前后两次落点之间的距离
float primaryDistance = PointUtil.distance(firstBeginP, firstEndP);
// 次要点在前后两次落点之间的距离
float secondaryDistance = PointUtil.distance(secondBeginP, secondEndP);
if (Math.abs(nowWholeDistance - preWholeDistance) >
(float) Math.sqrt(2) / 2.0f * (primaryDistance + secondaryDistance)) {
// 倾向于在原始线段的相同方向上移动,则判作缩放动作
float scaleRatio = nowWholeDistance / preWholeDistance;
String desc = String.format("本次手势为缩放动作,%s为%f",
scaleRatio>=1?"放大倍数":"缩小比例", scaleRatio);
tv_desc.setText(desc);
} else { // 倾向于在原始线段的垂直方向上移动,则判作旋转动作
// 计算上次触摸事件的旋转角度
int preDegree = PointUtil.degree(firstBeginP, secondBeginP);
// 计算本次触摸事件的旋转角度
int nowDegree = PointUtil.degree(firstEndP, secondEndP);
String desc = String.format("本次手势为旋转动作,%s方向旋转了%d度",
nowDegree>preDegree?"顺时针":"逆时针", Math.abs(nowDegree-preDegree));
tv_desc.setText(desc);
}
});
}
}
运行并测试该App,手势滑动效果如下图所示。图1为缩放手势的检测结果,图2为旋转手势的检测结果。
二、手势冲突处理
手势冲突的三种常见处理办法,内容包括:对于上下滚动与左右滑动的冲突,既可由父视图主动判断是否拦截,又可由子视图根据情况向父反馈是否允许拦截;对于内部滑动与翻页滑动的冲突,可以通过限定在某块区域接管特定的手势来实现对不同手势的区分处理;对于正常下拉与下拉刷新的冲突,需要监控当前是否已经下拉到页面顶部,若未拉到页面顶部则为正常下拉,若已拉到页面顶部则为下拉刷新。
1、上下滚动与左右滑动的冲突处理
Android控件繁多,允许滚动或滑动操作的视图也不少,例如滚动视图、翻页视图等,如果开发者要自己接管手势处理,比如通过手势控制横幅(Banner)轮播,那么这个页面的滑动就存在冲突的情况,如果系统响应了A视图的滑动事件,就顾不上B视图的滑动事件。
举个例子,某电商App的首页很长,内部采用滚动视图,允许上下滚动。该页面中央有一个手势控制的横幅轮播,如下图1所示。用户在横幅上左右滑动,试图查看横幅的前后广告,结果如下图2所示,原来翻页不成功,整个页面反而往上滚动了。
即使多次重复试验,仍然会发现横幅很少跟着翻页,而是继续上下滚动。因为横幅外层被滚动视图包着,系统检测到用户手势的一撇,父视图—滚动视图自作主张地认为用户要把页面往上拉,于是页面往上滚动,完全没有考虑这一撇其实是用户想翻动横幅。但是滚动视图不会考虑这些,因为没有人告诉它超过多大斜率才可以上下滚动;既然没有通知,那么滚动视图只要发现手势事件前后的纵坐标发生变化就一律进行上下滚动处理。
要解决这个滑动冲突,关键在于提供某种方式通知滚动视图,告诉它什么时候可以上下滚动、什么时候不能上下滚动。这个通知方式主要有两种:一种是父视图主动向下“查询”,即由滚动视图判断滚动规则并决定是否拦截手势;另一种是子视图向上“反映”,即由子视图告诉滚动视图是否拦截手势。下面分别介绍这两种处理方式。
(1)由滚动视图判断滚动规则
前面文章提到,容器类视图可以重写onInterceptTouchEvent方法,根据条件判断结果决定是否拦截发给子视图的手势。那么可以自定义一个滚动视图,在onInterceptTouchEvent方法中判断本次手势的横坐标与纵坐标,如果纵坐标的偏移大于横坐标的偏移,此时就是垂直滚动,应拦截手势并交给自身进行上下滚动;否则表示此时为水平滚动,不应拦截手势,而是让子视图处理左右滑动事件。
下面的代码演示了自定义滚动视图拦截垂直滚动并放过水平滚动的功能:CustomScrollView的完整代码
// 在拦截触摸事件时触发
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean result;
// 其余动作,包括手指移动、手指松开等等
if (event.getAction() == MotionEvent.ACTION_DOWN){// 按下手指
mOffsetX = 0.0F;
mOffsetY = 0.0F;
mLastPos = new PointF(event.getX(), event.getY());
result = super.onInterceptTouchEvent(event);
}else {
PointF thisPos = new PointF(event.getX(),event.getY());
mOffsetX += Math.abs(thisPos.x - mLastPos.x); // x轴偏差
mOffsetY += Math.abs(thisPos.y - mLastPos.y); // y轴偏差
mLastPos = thisPos;
if (mOffsetX < mInterval && mOffsetY < mInterval) {
result = false; // false传给表示子控件,此时为点击事件
} else if (mOffsetX < mOffsetY) {
result = true; // true表示不传给子控件,此时为垂直滑动
} else {
result = false; // false表示传给子控件,此时为水平滑动
}
}
return result;
}
再把布局里的ScrollView改成自定义的CustomScrollView,运行APP,此时翻页成功,并且整个页面固定不动,未发生上下滚动的情况。如下图所示:
(2)子视图告诉滚动视图能否拦截手势
在目前的案例中,滚动视图下面只有横幅一个视图,所以允许单独给它“开小灶”。在实际应用场合中,往往有多个横幅视图或者其他的需求,倘若都要滚动父视图帮忙,那可真是忙都忙不过来了。所以,这个时候在子视图实现是否拦截手势,就很有必要了。
具体到代码的实现,需要调用requestDisallowInterceptTouchEvent方法(输入参数为true时表示禁止上级拦截触摸事件)。至于何时调用该方法,当然是在检测到滑动前后的横坐标偏移大于纵坐标偏移时。对于横幅采用手势监听器的情况,可重写onTouchEvent方法(在该方法中加入坐标偏移的判断),示例部分代码如下:完整代码BannerPager
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean result;
if (event.getAction() == MotionEvent.ACTION_DOWN) { // 按下手指
mOffsetX = 0.0F;
mOffsetY = 0.0F;
mLastPos = new PointF(event.getX(), event.getY());
result = super.onTouchEvent(event);
} else { // 其余动作,包括移动手指、松开手指等等
PointF thisPos = new PointF(event.getX(), event.getY());
mOffsetX += Math.abs(thisPos.x - mLastPos.x); // x轴偏差
mOffsetY += Math.abs(thisPos.y - mLastPos.y); // y轴偏差
mLastPos = thisPos;
if (mOffsetX >= mOffsetY) { // 水平方向的滚动
// 如果外层是普通的ScrollView,则此处不允许父容器的拦截动作
// CustomScrollActivity通过自定义滚动视图来区分水平滑动还是垂直滑动
// DisallowScrollActivity使用滚动视图,则此处需要下面代码禁止父容器拦截
getParent().requestDisallowInterceptTouchEvent(true);
result = true; // 返回true表示要继续处理
} else { // 垂直方向的滚动
result = false; // 返回false表示不处理了
}
}
return result;
}
2、内部滑动与翻页滑动的冲突处理
在前面的手势冲突中,滚动视图是父视图,有时也是子视图,比如页面采用翻页视图的话,页面内的每个区域之间是左右滑动的关系,并且每个区域都可以拥有自己的滚动视图。如此一来,在左右滑动时,滚动视图反而变成翻页视图的子视图,前面的冲突处理办法就不能奏效了,只能另想办法。
自定义一个基于ViewPager的翻页视图是一种思路,另外还可以借鉴抽屉布局DrawerLayout。该布局允许左右滑动,在滑动时会拉出侧面的抽屉面板,常用于实现侧滑菜单。抽屉布局与翻页视图在滑动方面存在区别,翻页视图在内部的任何位置均可触发滑动事件,而抽屉布局只在屏幕两侧边缘才会触发滑动事件。
举个应用的例子,微信的聊天窗口是上下滚动的,在主窗口的大部分区域触摸都是上下滚动窗口,若在窗口左侧边缘按下再右拉,就会看到左边拉出了消息关注页面。限定某块区域接管特定的手势,这是一种处理滑动冲突行之有效的办法。
既然提到了抽屉布局,不妨稍微了解一下它。下面是DrawerLayout的常用方法:
● setDrawerShadow:设置首页面的渐变阴影图形。
● addDrawerListener:添加抽屉面板的拉出监听器,需实现DrawerListener的如下4个方法:
onDrawerSlide:抽屉面板滑动时触发。
onDrawerOpened:抽屉面板打开时触发。
onDrawerClosed:抽屉面板关闭时触发。
onDrawerStateChanged:抽屉面板的状态发生变化时触发。
● removeDrawerListener:移除抽屉面板的拉出监听器。
● closeDrawers:关闭所有抽屉面板。
● openDrawer:打开指定抽屉面板。
● closeDrawer:关闭指定抽屉面板。
● isDrawerOpen:判断指定抽屉面板是否打开。
抽屉布局不仅可以拉出左侧抽屉面板,还可以拉出右侧抽屉面板。左侧面板与右侧面板的区别在于:左侧面板在布局文件中的layout_gravity属性为left,右侧面板在布局文件中的layout_gravity属性为right。
下面是使用DrawerLayout的布局文件:
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/dl_layout"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal" >
<Button
android:id="@+id/btn_drawer_left"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="打开左边侧滑"
android:textColor="@color/black"
android:textSize="17sp" />
<Button
android:id="@+id/btn_drawer_right"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="打开右边侧滑"
android:textColor="@color/black"
android:textSize="17sp" />
</LinearLayout>
<TextView
android:id="@+id/tv_drawer_center"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="top|center"
android:paddingTop="30dp"
android:text="这里是首页"
android:textColor="@color/black"
android:textSize="17sp" />
</LinearLayout>
<!-- 抽屉布局左边的侧滑列表视图,layout_gravity属性设定了它的对齐方式 -->
<ListView
android:id="@+id/lv_drawer_left"
android:layout_width="150dp"
android:layout_height="match_parent"
android:layout_gravity="left"
android:background="#ffdd99" />
<!-- 抽屉布局右边的侧滑列表视图,layout_gravity属性设定了它的对齐方式 -->
<ListView
android:id="@+id/lv_drawer_right"
android:layout_width="150dp"
android:layout_height="match_parent"
android:layout_gravity="right"
android:background="#99ffdd" />
</androidx.drawerlayout.widget.DrawerLayout>
对应的页面部分代码如下:完整代码DrawerLayoutActivity
// 定义一个抽屉布局的侧滑监听器
private class SlidingListener implements DrawerLayout.DrawerListener {
// 在拉出抽屉的过程中触发
@Override
public void onDrawerSlide(View drawerView, float slideOffset) {}
// 在侧滑抽屉打开后触发
@Override
public void onDrawerOpened(View drawerView) {
if (drawerView.getId() == R.id.lv_drawer_left) {
btn_drawer_left.setText("关闭左边侧滑");
} else {
btn_drawer_right.setText("关闭右边侧滑");
}
}
// 在侧滑抽屉关闭后触发
@Override
public void onDrawerClosed(View drawerView) {
if (drawerView.getId() == R.id.lv_drawer_left) {
btn_drawer_left.setText("打开左边侧滑");
} else {
btn_drawer_right.setText("打开右边侧滑");
}
}
// 在侧滑状态变更时触发
@Override
public void onDrawerStateChanged(int paramInt) {}
}
抽屉布局的展示效果如下图所示:
3、正常下拉与下拉刷新的冲突处理
电商App的首页通常都支持下拉刷新,比如京东首页的头部轮播图一直顶到系统的状态栏,并且页面下拉到顶后,继续下拉会拉出带有“下拉刷新”字样的布局,此时松手会触发页面的刷新动作。虽然Android提供了专门的下拉刷新布局SwipeRefreshLayout,但是它没有实现页面随手势下滚的动态效果。一些第三方的开源库(如PullToRefresh、SmartRefreshLayout等)固然能让整体页面下滑,可是顶部的下拉布局很难个性化定制,状态栏、工具栏的背景色修改更是三不管。若想呈现完全仿照京东的下拉刷新特效,只能编写一个自定义的布局控件。
自定义的下拉刷新布局首先要能够区分是页面的正常下滚还是拉到头部要求刷新。二者之间的区别很简单,直观上就是判断当前页面是否拉到顶。倘若还没拉到顶,继续下拉动作属于正常的页面滚动;倘若已经拉到顶,继续下拉动作才会拉出头部提示刷新。所以此处需捕捉页面滚动到顶部的事件,相对应的是页面滚动到底部的事件。
鉴于大部分APP首页基本采用滚动视图实现页面滚动功能,故而该问题就变成如何监听该视图滚到顶部或者底部。ScrollView提供了滚动行为的变化方法onScrollChanged,通过重写该方法即可判断是否到达顶部或底部。
重写后的部分代码如下所示:完整代码PullDownScrollView
// 在拦截触摸事件时触发
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean result;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: // 按下手指
mOffsetX = 0.0F;
mOffsetY = 0.0F;
mLastPosX = event.getX();
mLastPosY = event.getY();
result = super.onInterceptTouchEvent(event);
break;
default: // 其余动作,包括手指移动、手指松开等等
float thisPosX = event.getX();
float thisPosY = event.getY();
mOffsetX += Math.abs(thisPosX - mLastPosX); // x轴偏差
mOffsetY += Math.abs(thisPosY - mLastPosY); // y轴偏差
mLastPosX = thisPosX;
mLastPosY = thisPosY;
if (mOffsetX < mInterval && mOffsetY < mInterval) {
result = false; // false传给表示子控件,此时为点击事件
} else if (mOffsetX < mOffsetY) {
result = true; // true表示不传给子控件,此时为垂直滑动
} else {
result = false; // false表示传给子控件,此时为水平滑动
}
break;
}
return result;
}
// 在滚动变更时触发
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
boolean isScrolledToTop;
boolean isScrolledToBottom;
if (getScrollY() == 0) { // 下拉滚动到顶部
isScrolledToTop = true;
isScrolledToBottom = false;
} else if (getScrollY() + getHeight() - getPaddingTop() - getPaddingBottom()
== getChildAt(0).getHeight()) { // 上拉滚动到底部
isScrolledToBottom = true;
isScrolledToTop = false;
} else { // 未拉到顶部,也未拉到底部
isScrolledToTop = false;
isScrolledToBottom = false;
}
if (mScrollListener != null) {
if (isScrolledToTop) { // 已经滚动到顶部
// 触发下拉到顶部的事件
mScrollListener.onScrolledToTop();
} else if (isScrolledToBottom) { // 已经滚动到底部
// 触发上拉到底部的事件
mScrollListener.onScrolledToBottom();
}
}
}
如此改造一下,只要活动代码设置了滚动视图的滚动监听器,就能由onScrolledToTop方法判断当前页面是否拉到顶了。既然能够知晓到顶与否,同步变更状态栏和工具栏的背景色也就可行了。
如下图【上拉页面时的导航栏】为上拉页面使之整体上滑,此时状态栏的背景变灰、工具栏的背景变白;如下图【下拉页面时的导航栏】为下拉页面使之完全拉出,此时状态栏和工具栏的背景均恢复透明
成功监听页面是否到达顶部或底部仅仅解决了状态栏和工具栏的变色问题,页面到顶后继续下拉滚动视图要怎么处理呢?一方面是整个页面已经拉到顶了,滚动视图已经无可再拉;另一方面用户在京东首页看到的下拉头部并不属于滚动视图管辖,即使它想拉一下,也是有心无力。不管滚动视图是惊慌失措还是不知所措,恰恰说明它是真的束手无策了,为此还要一个和事佬来摆平下拉布局和滚动视图之间的纠纷。这个和事佬必须是下拉布局和滚动视图的父布局,考虑到下拉布局在上、滚动视图在下,故它俩的父布局继承线性布局比较合适。
新的父视图需要完成以下3项任务:
(1)在子视图的最前面自动添加一个下拉刷新头部,保证该下拉头部位于整个页面的最上方。
(2)给前面自定义的滚动视图注册滚动监听器和触摸监听器。其中,滚动监听器用于处理到达顶部和到达底部的事件,触摸监听器用于处理下拉过程中的持续位移。
(3)重写触摸监听器接口需要实现的onTouch方法。这个是重中之重,因为该方法包含了所有的手势下拉跟踪处理,既要准确响应正常的下拉手势,也要避免误操作不属于下拉的手势,比如下面几种情况就要统筹考虑:
① 水平方向的左右滑动,不做额外处理。
② 垂直方向的向上拉动,不做额外处理。
③ 下拉的时候尚未拉到页面顶部,不做额外处理。
④ 拉到顶之后继续下拉,则在隐藏工具栏的同时让下拉头部跟着往下滑动。
⑤ 下拉刷新过程中松开手势,判断下拉滚动的距离,距离太短则直接缩回头部、不刷新页面,只有距离足够长才会刷新页面,等待刷新完毕再缩回头部。
有了新定义的下拉上层布局,搭配自定义的滚动视图就能很方便地实现高仿京东首页的下拉刷新效果了。
具体实现的首页布局如下所示:activity_pull_refresh
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
<com.zyd.androidevent.weiget.PullDownRefreshLayout
android:id="@+id/pdrl_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.zyd.androidevent.weiget.PullDownScrollView
android:id="@+id/pdsv_main"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.zyd.androidevent.weiget.BannerPager
android:id="@+id/banner_pager"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/tv_flipper"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="#eeffee"
android:gravity="top|center_horizontal"
android:paddingTop="10dp"
android:text="请反复下拉页面和上拉页面"
android:textColor="@color/black"
android:textSize="17sp" />
<View
android:layout_width="match_parent"
android:layout_height="1000dp"
android:background="#9999ff" />
<View
android:layout_width="match_parent"
android:layout_height="10dp"
android:background="@color/white" />
</LinearLayout>
</com.zyd.androidevent.weiget.PullDownScrollView>
</com.zyd.androidevent.weiget.PullDownRefreshLayout>
<!-- title_drag.xml是带搜索框的工具栏布局 -->
<include layout="@layout/title_drag" />
</RelativeLayout>
以上布局模板用到的自定义控件PullDownRefreshLayout代码量较多,这里就不贴出来了完整代码请点击PullDownRefreshLayout
效果如下图【正在下拉时的界面】和【松开刷新时的界面】