安卓事件交互(按键事件、触摸事件、手势识别、手势冲突处理)

发布于:2024-06-17 ⋅ 阅读:(27) ⋅ 点赞:(0)

本章介绍App开发常见的以下事件交互技术,主要包括:如何检测并接管按键事件,如何对触摸事件进行分发、拦截与处理,如何根据触摸行为辨别几种手势动作,如何正确避免手势冲突的意外状况。

按键事件

本节介绍App开发对按键事件的检测与处理,内容包括如何检测控件对象的按键事件、如何检测活动页面的物理按键、以及返回键为例说明“再按一次返回键退出”的功能实现。

检测软键盘

手机上的输入按键一般不另外进行处理,直接由系统按照默认情况操作。有时为了改善用户体验,需要让App拦截按键事件,并进行额外处理。譬如使用编辑框有时要监控输入字符的回车键,一旦发现用户敲了回车键,就将焦点自动移到下一个控件,而不是在编辑框中输入回车换行。拦截输入字符可通过注册文本观测器TextWatcher实现,但该监听器只适用于编辑框控件,无法用于其他控件。因此,若想让其他控件也能实现按键操作,则要另外调用控件对象的setOnKeyListener方法设置按键监听器,并实现监听器接口OnKeyListeneronKey方法。
监控按键事件之前,首先要知道每个按键的编码,这样才能根据不同的编码值进行相应的处理。按键编码的取值见下表。注意,监听器OnKeyListener只会检测控制键,不会检测文本键(字母、数字、标点等)。

按键编码 KeyEvent类的按键名称 说明
3 KEYCODE_HOME 首页键(未开放给普通用户App)
4 KEYCODE_BACK 返回键(后退键)
24 KEYCODE_VOLUME_UP 加大音量键
25 KEYCODE_VOLUME_DOWN 减小音量键
26 KEYCODE_POWER 电源键(未开放给普通App)
66 KEYCODE_ENTER 回车键
67 KEYCODE_DEL 删除键(退格键)
84 KEYCODE_SEARCH 搜索键
187 KEYCODE_APP_SWITCH 任务键(未开放给普通App)

实际监控结果显示,每次按下控制键,onKey方法都会收到两次重复编码的按键事件,这是为该方法把每次按键部分都分成按下与松开两个动作,所以一次按键变成了两个按键动作。解决这个问题的办法很简单,就是只监控按下动作(KeyEvent.ACTION_DOWN)的按键事件,不监控松开动作(KeyEvent.ACTION_UP)的按键事件。
下面是使用软键盘监听器的示例代码:

public class KeySoftActivity extends AppCompatActivity implements OnKeyListener {
    private TextView tv_result; // 声明一个文本视图对象
    private String desc = "";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_key_soft);
        EditText et_soft = findViewById(R.id.et_soft);
        et_soft.setOnKeyListener(this); // 设置编辑框的按键监听器
        tv_result = findViewById(R.id.tv_result);
    }

    // 在发生按键动作时触发
    @Override
    public boolean onKey(View v, int keyCode, KeyEvent event) {
        if (event.getAction() == KeyEvent.ACTION_DOWN) {
            desc = String.format("%s软按键编码是%d,动作是按下", desc, keyCode);
            if (keyCode == KeyEvent.KEYCODE_ENTER) {
                desc = String.format("%s,按键为回车键", desc);
            } else if (keyCode == KeyEvent.KEYCODE_DEL) {
                desc = String.format("%s,按键为删除键", desc);
            } else if (keyCode == KeyEvent.KEYCODE_SEARCH) {
                desc = String.format("%s,按键为搜索键", desc);
            } else if (keyCode == KeyEvent.KEYCODE_BACK) {
                desc = String.format("%s,按键为返回键", desc);
                // 延迟3秒后启动页面关闭任务
                new Handler(Looper.myLooper()).postDelayed(() -> finish(), 3000);
            } else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
                desc = String.format("%s,按键为加大音量键", desc);
            } else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
                desc = String.format("%s,按键为减小音量键", desc);
            }
            desc = desc + "\n";
            tv_result.setText(desc);
            // 返回true表示处理完了不再输入该字符,返回false表示输入该字符
            return true;
        } else {
            // 返回true表示处理完了不再输入该字符,返回false表示输入该字符
            return false;
        }
    }
}

上述代码的按键效果如下图。虽然按键编码表存在主页键、任务键、电源键的定义,但这3个键并不开放给普通用户App,普通用户App也不应该拦截这些按键。
在这里插入图片描述

检测物理按键

除了给控件注册按键监听器外,还可以在活动页面上检测物理按键,即重写Activity的onKeyDown方法。onKeyDown方法与前面的onKey方法类似,同样拥有按键编码与按键事件KeyEvent两个参数。当然,这两个方法也存在不同之处,具体说明如下:

  1. onKeyDown:只能在活动代码中使用,而onKey只要有注册的控件就能使用。
  2. onKeyDown只能检测物理按键,无法检测输入法按键(如回车键、删除键等),onKey可同时检测两类按键。
  3. onKeyDown不区分按下与松开两个动作,onKey区分这两个动作。

下面是启用物理按键监听的代码片段:

// 在发生物理按键动作时触发
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    desc = String.format("%s物理按键的编码是%d", desc, keyCode);
    if (keyCode == KeyEvent.KEYCODE_BACK) {
        desc = String.format("%s,按键为返回键", desc);
        // 延迟3秒后启动页面关闭任务
        new Handler(Looper.myLooper()).postDelayed(() -> finish(), 3000);
    } else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
        desc = String.format("%s,按键为加大音量键", desc);
    } else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
        desc = String.format("%s,按键为减小音量键", desc);
    }
    desc = desc + "\n";
    tv_result.setText(desc);
    // 返回true表示不再响应系统动作,返回false表示继续响应系统动作
    return true;
}

物理按键的监听效果如下图所示,可见分别检测到了加大音量键、减小音量键、返回键。
在这里插入图片描述

接管返回按键

检测物理按键最常见的应用时淘宝首页的“再按一次返回键退出”,在App首页按返回键,系统默认的做法是直接退出该App。有时用户有可能是不小心按了返回键,并非想退出该App,因此这里加一个小提示,等待用户再次按返回键才会确认退出意图,并执行退出操作。
“再按一次返回键退出”的实现代码很简单,在onKeyDown方法中拦截即可,具体代码如下:

// 在发生物理按键动作时触发
public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (keyCode == KeyEvent.KEYCODE_BACK) { // 按下返回键
        if (needExit) {
            finish(); // 关闭当前页面
        }
        needExit = true;
        Toast.makeText(this, "再按一次返回键退出!", Toast.LENGTH_SHORT).show();
        return true;
    } else {
        return super.onKeyDown(keyCode, event);
    }
}

重写活动代码的onBackPressed方法也能实现同样的效果,该方法专门相应按返回键事件,具体代码如下:

// 在按下返回键时触发
@Override
public void onBackPressed() {
    if (needExit) {
        finish(); // 关闭当前页面
        return;
    }
    needExit = true;
    Toast.makeText(this, "再按一次返回键退出!", Toast.LENGTH_SHORT).show();
}

该功能的界面效果如下图所示。这是一个提示小窗口,在淘宝首页按返回键时就能够看到。
在这里插入图片描述

触摸事件

本节介绍App开发对屏幕触摸事件的相关处理,内容包括:手势事件的分发流程,包括3个手势方法、3类手势执行者、派发与拦截处理;手势事件的具体方法,包括单电触摸和多点触控;一个手势触摸的具体应用–手写签名功能的实现。

手势事件的分发流程

智能手机的一大革命性技术是把屏幕变为可触摸设备,即可用于信息输出(显示页面),又可用于信息输入(检测用户的触摸行为)。位方便开发者使用,Android已可自动识别特定的几种触摸手势,包括按钮的点击事件、长按事件、滚动视图的上下滚动事件、翻页视图的左右翻页事件等。不过对于App的高级开发来说,系统自带的几个固定手势显然无法满足丰富多变的业务需求。这就要求开发者深入了解触摸行为的流程与方法,并在合适的场合接管触摸行为,进行符合需求的事件处理。
与手势事件有关的方法主要有3个(按执行顺序排列),分别说明如下:

  • dispatchTouchEvent:进行事件分发处理,返回结果表示该事件是否需要分发。默认返回true表示分发给子视图,由子视图处理该手势,不过最终是否分发成功还得看onInterceptTouchEvent方法的拦截判断结果;返回false表示不分发,此时必须实现自身的onTouchEvent方法,否则该手势将不会得到处理。
  • onInterceptTouchEvent:进行事件拦截结果,返回结果表示当前容器是否需要拦截该事件。返回true表示予以拦截,该手势不会分发给予子视图,此时必须实现自身的onTouchEvent方法,否则该手势将不会得到处理;默认返回false表示不拦截,该手势会分发给予子视图进行后续处理。
  • onTouchEvent:进行事件触摸处理,返回结果表示该事件是否处理完毕。返回true表示处理完毕,无需处理上一级视图的onTouchEvent方法,一路返回结束流程;返回false表示该手势事件尚未完成,返回继续处理上一级视图的onTouchEvent方法,然后根据上一级onTouchEvent方法的返回值判断是直接结果还是由上一级处理。

上述手势方法的执行者有3个(按执行顺序排列),具体说明如下:

  • 页面类:包括Activity及其派生类。页面类可调用dispatchTouchEvent和onTouchEvent两个方法。
  • 容器类:包括从ViewGroup类派生的各种容器类,如各种布局Layout和ListView、GridView、Spinner、ViewPager、RecycleView、ToolBar等。容器类可调用dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent三个方法。
  • 控件类:包括从View派生的各种控件,如TextView、ImageView、Button等。控件类可调用dispatchTouchEvent和onTouchEvent两个方法。

只有容器类才能调用onInterceptTouchEvent方法,这是因为该方法用于拦截发往下层视图的事件,而控件类已经位于底层,只能被拦截,不能拦截别人。页面类没有下层视图,所以不能调用onInterceptTouchEvent方法。三类执行者的手势处理流程如下图所示。
在这里插入图片描述
以上流程图涉及3个手势方法和3类手势执行者,尤其是手势流程的排列组合千变万化,并不容易解释清楚。对于实际开发来说,真正需要处理的组合并不多,所以只要常见的几种组合搞清楚就能应付大部分开发工作,这几种组合说明如下:

  • 页面类的手势处理。它的dispatchTouchEvent方法必须返回super.dispatchTouchEvent,如果不分发,页面上的视图就无法处理手势。至于页面类的onTouchEvent方法,基本没有什么作用,因为手势动作要由具体视图处理,页面直接处理手势没有什么意义。所以,页面类的手势处理可以不用关心,直接略过。
  • 控件类的手势处理。它的dispatchTouchEvent方法没有任何作用,因为下面没有子视图,无所谓分不分发。至于控件类的onTouchEvent方法,如果要进行手势处理,就需要自定义一个控件,重写自定义类中的onTouchEvent方法,注册一个触摸监听器OnTouchListener,并实现该监听器的onTouch方法。所以,控件类的手势处理只需关心onTouchEvent方法。
  • 容器类的手势处理。这才是真正要深入了解的地方。容器类的dispatchTouchEvent与onInterceptTouchEvent方法都能决定是否将手势给予视图处理。为了避免手势响应冲突,一般要重写dispatchTouchEvent或者onInterceptTouchEvent方法。这两个方法的区别可以这么理解:前者是大领导,只管派发任务,不会自己做事情;后者是小领导,尽管由拦截的权利,不过也得自己做点事情,比如处理纠纷等。容器类的onTouchEvent方法近乎摆设,因为需要拦截的在前面已经拦截了,需要处理的在子视图中已经处理了。

经过上面的详细分析,常见的手势处理方法有下面3个:

  • 页面类的dispatchTouchEvent方法:控制事件的分发,决定把手势交给谁处理。
  • 容器类的onInterceptTouchEvent方法:控制事件的拦截,决定是否要把手势交给子视图处理。
  • 控件类的onTouchEvent方法:进行手势事件的具体处理。

为了方便理解dispatchTouchEvent方法,先看下面不派发事件的自定义布局代码:

public class NotDispatchLayout extends LinearLayout {

    public NotDispatchLayout(Context context) {
        super(context);
    }

    public NotDispatchLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    // 在分发触摸事件时触发
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (mListener != null) {
            mListener.onNotDispatch();
        }
        // 一般容器默认返回true,即允许分发给下级
        return false;
    }

    private NotDispatchListener mListener; // 声明一个分发监听器对象
    // 设置分发监听器
    public void setNotDispatchListener(NotDispatchListener listener) {
        mListener = listener;
    }

    // 定义一个分发监听器接口
    public interface NotDispatchListener {
        void onNotDispatch();
    }
}

活动页面的onNotDispatch方法代码如下:

// 在分发触摸事件时触发
@Override
public void onNotDispatch() {
    desc_no = String.format("%s%s 触摸动作未分发,按钮点击不了了\n"
            , desc_no, DateUtil.getNowTime());
    tv_dispatch_no.setText(desc_no);
}

不派发事件的处理效果如下图上半部分。其中上半部分分为正常布局,此时按钮可正常响应点击事件;下半部分为不派发布局,此时按钮不会响应点击事件,取而代之的是执行不派发布局的onNotDispatch方法。
在这里插入图片描述
为了方便理解onInterceptTouchEvent方法,再看拦截事件的自定义布局代码:

public class NotDispatchLayout extends LinearLayout {

    public NotDispatchLayout(Context context) {
        super(context);
    }

    public NotDispatchLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    // 在分发触摸事件时触发
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (mListener != null) {
            mListener.onNotDispatch();
        }
        // 一般容器默认返回true,即允许分发给下级
        return false;
    }

    private NotDispatchListener mListener; // 声明一个分发监听器对象
    // 设置分发监听器
    public void setNotDispatchListener(NotDispatchListener listener) {
        mListener = listener;
    }

    // 定义一个分发监听器接口
    public interface NotDispatchListener {
        void onNotDispatch();
    }
}

活动页面实现的onIntercept方法代码如下:

// 在拦截触摸事件时触发
@Override
public void onIntercept() {
    desc_yes = String.format("%s%s 触摸动作被拦截,按钮点击不了了\n", desc_yes,
            DateUtil.getNowTime());
    tv_intercept_yes.setText(desc_yes);
}

拦截事件的处理效果如下图所示。其中,上面部分为正常布局,此时按钮可正常响应点击事件;下面部分为拦截布局,此时按钮不会响应点击事件,取而代之的是执行拦截布局的onIntercept方法。
在这里插入图片描述

接管手势事件处理

dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent三个方法的参数都是手势事件MotionEvent,其中包含触摸动作的所有信息,各种手势操作都从MotionEvent中获取信息并判断处理。
下面是MotionEvent的常用方法:

  • getAction:获取当前的动作类型。动作类型的取值说明见下表。
MotionEvent类的动作类型 说明
ACTION_DOWN 按下动作
ACTION_UP 提起动作
ACTION_MOVE 移动动作
ACTION_CANCEL 取消动作
ACTION_OUTSIDE 移出边界动作
ACTION_POINTER_DOWN 第二个点的按下动作,用于多点触控的判断
ACTION_POINTER_UP 第二个点的提起动作,用于多点触控的判断
ACTION_MASK 动作掩码,与原动作类型进行“与”(&)操作后获得多点触控信息
  • getEventTime:获取事件时间(从开机到现在的毫秒数)。
  • getX:获取在控件内部的相对横坐标。
  • getY:获取在控件内部的相对纵坐标。
  • getRawX:获取在屏幕上的绝对横坐标。
  • getRawY:获取在屏幕上的绝对纵坐标。
  • getPressure:获取触摸压力大小。
  • getPointerCount:获取触控点的数量,如果为2就表示有两个手指同时按压屏幕。如果触控点数目大于1,坐标相关方法就可以输入整数编号,表示取第几个触控点的坐标信息。

为方便理解MotionEvent的各类触摸行为,下面来看单点触摸的示例代码:

public class TouchSingleActivity extends AppCompatActivity {
    private TextView tv_touch; // 声明一个文本视图对象

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_touch_single);
        tv_touch = findViewById(R.id.tv_touch);
    }

    // 在发生触摸事件时触发
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 从开机到现在的毫秒数
        int seconds = (int) (event.getEventTime() / 1000);
        String desc = String.format("动作发生时间:开机距离现在%02d:%02d:%02d",
                seconds / 3600, seconds % 3600 / 60, seconds % 60);
        desc = String.format("%s\n动作名称是:", desc);
        int action = event.getAction(); // 获得触摸事件的动作类型
        if (action == MotionEvent.ACTION_DOWN) { // 按下手指
            desc = String.format("%s按下", desc);
        } else if (action == MotionEvent.ACTION_MOVE) { // 移动手指
            desc = String.format("%s移动", desc);
        } else if (action == MotionEvent.ACTION_UP) { // 松开手指
            desc = String.format("%s提起", desc);
        } else if (action == MotionEvent.ACTION_CANCEL) { // 取消手势
            desc = String.format("%s取消", desc);
        }
        desc = String.format("%s\n动作发生位置是:横坐标%f,纵坐标%f,压力为%f",
                desc, event.getX(), event.getY(), event.getPressure());
        tv_touch.setText(desc);
        return super.onTouchEvent(event);
    }
}

单点触控的效果如下图所示。
在这里插入图片描述
除了单点触控,智能手机还普遍支持多点触控,即响应两个及以上手指同时按压屏幕。多点触控可用于操纵图像的缩放与旋转操作以及需要多点处理的游戏界面。
下面是处理多点触控的示例代码:

public class TouchMultipleActivity extends AppCompatActivity {
    private TextView tv_touch_major; // 声明一个文本视图对象
    private TextView tv_touch_minor; // 声明一个文本视图对象
    private boolean isMinorDown = false; // 次要点是否按下

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_touch_multiple);
        tv_touch_major = findViewById(R.id.tv_touch_major);
        tv_touch_minor = findViewById(R.id.tv_touch_minor);
    }

    // 在发生触摸事件时触发
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 从开机到现在的毫秒数
        int seconds = (int) (event.getEventTime() / 1000);
        String desc_major = String.format("主要动作发生时间:开机距离现在%02d:%02d:%02d\n%s",
                seconds / 3600, seconds % 3600 / 60, seconds % 60, "主要动作名称是:");
        String desc_minor = "";
        isMinorDown = (event.getPointerCount() >= 2);
        // 获得包括次要点在内的触摸行为
        int action = event.getAction() & MotionEvent.ACTION_MASK;
        if (action == MotionEvent.ACTION_DOWN) { // 按下手指
            desc_major = String.format("%s按下", desc_major);
        } else if (action == MotionEvent.ACTION_MOVE) { // 移动手指
            desc_major = String.format("%s移动", desc_major);
            if (isMinorDown) {
                desc_minor = String.format("%s次要动作名称是:移动", desc_minor);
            }
        } else if (action == MotionEvent.ACTION_UP) { // 松开手指
            desc_major = String.format("%s提起", desc_major);
        } else if (action == MotionEvent.ACTION_CANCEL) { // 取消手势
            desc_major = String.format("%s取消", desc_major);
        } else if (action == MotionEvent.ACTION_POINTER_DOWN) { // 次要点按下
            desc_minor = String.format("%s次要动作名称是:按下", desc_minor);
        } else if (action == MotionEvent.ACTION_POINTER_UP) { // 次要点松开
            desc_minor = String.format("%s次要动作名称是:提起", desc_minor);
        }
        desc_major = String.format("%s\n主要动作发生位置是:横坐标%f,纵坐标%f",
                desc_major, event.getX(), event.getY());
        tv_touch_major.setText(desc_major);
        if (isMinorDown || !TextUtils.isEmpty(desc_minor)) { // 存在次要点触摸
            desc_minor = String.format("%s\n次要动作发生位置是:横坐标%f,纵坐标%f",
                    desc_minor, event.getX(1), event.getY(1));
            tv_touch_minor.setText(desc_minor);
        }
        return super.onTouchEvent(event);
    }
}

多点触控的效果两个手指一起点击屏幕的效果如下图所示。
在这里插入图片描述

跟踪滑动轨迹实现手写签名

为了加深对触摸事件的认识,接下来尝试实现一个手写签名控件,进一步理解手势处理的应用场合。
手写签名的原理是把手机屏幕当作画板,把用户手指当作画笔,手指在屏幕上划来划去,屏幕就会显示手指的移动轨迹,就像画笔在画板上写字一样。实现手写签名需要结合绘图的路径工具Path,具体的实现步骤说明如下:

  1. 按下手指,调用Path对象的moveTo方法,将路径起点移动到触摸点。
  2. 移动手指,调用Path对象的quadTo方法,记录本次触摸点与上次触摸点之间的路径。
  3. 移动手指或者手指提起时,调用Canvas对象的drawPath方法,将本次触摸轨迹绘制在画布上。

自定义手写签名控件的示例代码如下:

public class SignatureView extends View {
    private static final String TAG = "SignatureView";
    private Paint mPathPaint = new Paint(); // 声明一个画笔对象
    private Path mPath = new Path(); // 声明一个路径对象
    private int mPathPaintColor = Color.BLACK; // 画笔颜色
    private int mStrokeWidth = 3; // 画笔线宽
    private PathPosition mPathPos = new PathPosition(); // 路径位置
    private List<PathPosition> mPathList = new ArrayList<>(); // 路径位置列表
    private PointF mLastPos; // 上次触摸点的横纵坐标

    public SignatureView(Context context) {
        this(context, null);
    }

    public SignatureView(Context context, AttributeSet attrs) {
        super(context, attrs);
        if (attrs != null) {
            // 根据SignatureView的属性定义,从布局文件中获取属性数组描述
            TypedArray attrArray = getContext().obtainStyledAttributes(attrs, R.styleable.SignatureView);
            // 根据属性描述定义,获取布局文件中的画笔颜色
            mPathPaintColor = attrArray.getColor(R.styleable.SignatureView_paint_color, Color.BLACK);
            // 根据属性描述定义,获取布局文件中的画笔线宽
            mStrokeWidth = attrArray.getInt(R.styleable.SignatureView_stroke_width, 3);
            attrArray.recycle(); // 回收属性数组描述
        }
        initView(); // 初始化视图
    }

    // 初始化视图
    private void initView() {
        mPathPaint.setStrokeWidth(mStrokeWidth); // 设置画笔的线宽
        mPathPaint.setStyle(Paint.Style.STROKE); // 设置画笔的类型。STROK表示空心,FILL表示实心
        mPathPaint.setColor(mPathPaintColor); // 设置画笔的颜色
        setDrawingCacheEnabled(true); // 开启当前视图的绘图缓存
    }

    // 清空画布
    public void clear() {
        mPath.reset(); // 重置路径对象
        mPathList.clear(); // 清空路径列表
        postInvalidate(); // 立即刷新视图(线程安全方式)
    }

    // 撤销上一次绘制
    public void revoke() {
        if (mPathList.size() > 0) {
            // 移除路径位置列表中的最后一个路径
            mPathList.remove(mPathList.size() - 1);
            mPath.reset(); // 重置路径对象
            for (int i = 0; i < mPathList.size(); i++) {
                PathPosition pp = mPathList.get(i);
                // 移动到上一个坐标点
                mPath.moveTo(pp.prePos.x, pp.prePos.y);
                // 连接上一个坐标点和下一个坐标点
                mPath.quadTo(pp.prePos.x, pp.prePos.y, pp.nextPos.x, pp.nextPos.y);
            }
            postInvalidate(); // 立即刷新视图(线程安全方式)
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawPath(mPath, mPathPaint); // 在画布上绘制指定路径线条
    }

    // 在发生触摸事件时触发
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: // 按下手指
                mPath.moveTo(event.getX(), event.getY()); // 移动到指定坐标点
                mPathPos.prePos = new PointF(event.getX(), event.getY());
                break;
            case MotionEvent.ACTION_MOVE: // 移动手指
                // 连接上一个坐标点和当前坐标点
                mPath.quadTo(mLastPos.x, mLastPos.y, event.getX(), event.getY());
                mPathPos.nextPos = new PointF(event.getX(), event.getY());
                mPathList.add(mPathPos); // 往路径位置列表添加路径位置
                mPathPos = new PathPosition(); // 创建新的路径位置
                mPathPos.prePos = new PointF(event.getX(), event.getY());
                break;
            case MotionEvent.ACTION_UP: // 松开手指
                // 连接上一个坐标点和当前坐标点
                mPath.quadTo(mLastPos.x, mLastPos.y, event.getX(), event.getY());
                break;
        }
        mLastPos = new PointF(event.getX(), event.getY());
        postInvalidate(); // 立即刷新视图(线程安全方式)
        return true;
    }
}

手写签名的效果如下图所示。
在这里插入图片描述

根据触摸行为辨别手势动作

本节介绍常见手势的行为特征及其检测方法,内容包括如何通过按压时长与按压力度区分点击和长按手势、如何根据触摸起点与终点的位置识别手势滑行滑动方向、如何利用双指按压以及它们的滑动轨迹辨别缩放与旋转手势。

区分点击和长按动作

根据触摸事件可以识别按压动作的时空关系,就能进一步判断用户的手势意图。比如区分点击和长按动作,只要看按压时长是否超过500毫秒即可,没超过的表示点击动作,超过了的表示长按动作。其实,除了按压时长之外,按压力度也是一个重要的参考指标。通常,点击时按得比较轻,长按时按得相对重。依据按压时长与按压力度两项指标即可有效地辨别点击与长按时地圆圈大小。定义点击视图地示例代码如下:

public class ClickView extends View {
    private static final String TAG = "ClickView";
    private Paint mPaint = new Paint(); // 声明一个画笔对象
    private long mLastTime; // 上次按下手指的系统时间
    private PointF mPos; // 按下手指的坐标点
    private float mPressure=0; // 按压的压力值
    private int dip_10;

    public ClickView(Context context) {
        this(context, null);
    }

    public ClickView(Context context, AttributeSet attrs) {
        super(context, attrs);
        dip_10 = Utils.dip2px(context, 10);
        mPaint.setColor(Color.DKGRAY); // 设置画笔的颜色
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (mPos != null) {
            // 以按压点为圆心,压力值为半径,在画布上绘制实心圆
            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());
            mPressure = event.getPressure(); // 获取本次触摸过程的最大压力值
        }
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: // 按下手指
                mLastTime = event.getEventTime();
                break;
            case MotionEvent.ACTION_MOVE: // 移动手指
                break;
            case MotionEvent.ACTION_UP: // 松开手指
                if (mListener != null) { // 触发手势抬起事件
                    mListener.onLift(event.getEventTime()-mLastTime, mPressure);
                }
                break;
        }
        postInvalidate(); // 立即刷新视图(线程安全方式)
        return true;
    }

    private LiftListener mListener; // 声明一个手势抬起监听器
    public void setLiftListener(LiftListener listener) {
        mListener = listener;
    }

    // 定义一个手势抬起的监听器接口
    public interface LiftListener {
        void onLift(long time_interval, float pressure);
    }
}

然后在布局文件中添加ClickView节点,并在对应的活动页面调用setLiftListener方法设置手势抬起监听器,看看点击和长按的描圆效果究竟为何。下面是设置手势监听器的代码:

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,手势按压效果如下图所示。
在这里插入图片描述

识别手势滑动的方向

除了点击和长按,分辨手势的滑动方向也很重要,手势往左抑或往右代表着左右翻页,往上或者往下代表着上下滚动。另外,手势向下还可能表示下拉刷新,手势向上还可能表示上拉加载,总之,上、下、左、右四个方向各有不同的用途。
直观地看,手势在水平方向掠过,意味着左右滑动;手势在垂直方向掠过,意味着上下滚动。左右滑动的话,手势触摸的起点和终点在水平方向的位移必定大于垂直方向的位移;反之,上下滚动的话,它们在垂直方向的位移必定大于水平方向的位移。据此可将滑动方向的判定过程分解成以下3个步骤:

  1. 对于按下手指事件,把当前点标记为七点,并记录起点的横、纵坐标。
  2. 对于松开手指事件,把当前点标记为终点,并记录终点的横、纵坐标。
  3. 分别计算起点与终点的横坐标距离以及它们的纵坐标距离,根据横、纵坐标的大小关系判断本次手势的滑动方向。

于是重写自定义触摸视图的onTouchEvent方法,分别处理按下、移动、松开三种手势事件;同时重写该视图的onDraw方法,描绘起点与终点的位置,以及起点到终点的路径线条。按照上述思路,编写单指触摸视图的代码:

public class SingleTouchView extends View {
    private Path mPath = new Path(); // 声明一个路径对象
    private PointF mLastPos, mBeginPos, mEndPos; // 路径中的上次触摸点,本次按压的起点和终点

    @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方法设置手势滑动监听器,看看手势到底往哪个方向滑动。下面是设置手势监听器的示例代码:

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,手势滑动效果如下图所示。
在这里插入图片描述

辨别缩放与旋转手势

一个手指的滑动只能识别手势的滑动方向,两个手指的滑动才能识别更复杂的手势动作。比如两个手指张开可表示放大操作,两指并拢克表示缩小操作,两个手指交错旋转表示旋转操作,而旋转方向又可细分为顺时针旋转和逆时针旋转。
那么如何辨别手势的缩放与旋转动作呢?由于两个手指各有自己的按下与松开事件,都有对应的触摸起点和终点,因此只要依次记录两个手势的起点和终点坐标,根据这四个点的位置关系就能算出手势的动作类别。至于缩放手势与旋转手势的区分,则需要分别计算第一个手势起点和终点的连线,以及第二个手势起点和终点的连线,再判断两根连线是倾向于在相同方向上缩放还是倾向于绕着连线中点旋转。
按照上述思路编写双指触摸视图的关键代码:

public class MultiTouchView extends View {
    private Path mFirstPath = new Path(); // 声明主要动作的路径对象
    private Path mSecondPath = new Path(); // 声明次要动作的路径对象
    private PointF mFirstLastP, mFirstBeginP, mFirstEndP; // 主要动作的上次触摸点,本次按压的起点和终点
    private PointF mSecondLastP, mSecondBeginP, mSecondEndP; // 次要动作的上次触摸点,本次按压的起点和终点
    private boolean isFinish = false; // 是否结束触摸

    @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;
    }

    private SlideListener mListener; // 声明一个手势滑动监听器
    public void setSlideListener(SlideListener listener) {
        mListener = listener;
    }

    // 定义一个手势滑动的监听器接口
    public interface SlideListener {
        void onSlideFinish(PointF firstBeginP, PointF firstEndP, PointF secondBeginP, PointF secondEndP);
    }
}

然后在布局文件中添加MultiTouchView节点,并在对应的活动页面调用setSlideListener方法设置滑动监听器,看看是缩放手势还是旋转手势(判断方法参见下图)。
在这里插入图片描述
假设手势的起点位于上图的中心位置,如果手势的终点落在上图的左下角或者右上角,则表示本次为缩放手势;如果手势的终点落在上图的左上角或右下角,则表示本次为旋转手势。据此编写的判定方法算法代码如下:

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,手势滑动效果如下图所示。
在这里插入图片描述

手势冲突处理

本节介绍手势冲突的三种常见处理办法,内容包括:对于上下滚动与左右滚动的冲突,即可由父视图主动判断是否拦截,又可由子视图根据情况向父视图反馈是否允许拦截;对于内部滑动与翻页滑动的冲突,可以通过限定在某块区域接管特定的手势来实现对不同手势的区分处理;对于正常下拉与上拉刷新的冲突,需要监控当前是否已经下拉到页面顶部,若为下拉到页面顶部则为正常下拉,若已下拉到页面顶部则为下拉刷新。

上下滚动与左右滑动的冲突处理

Android控件繁多,允许滚动或滑动操作的视图也不少,例如滚动视图、翻页视图等,如果开发者自己接管手势处理,比如通过手势控制横幅(Banner)轮播,那么这个页面的滑动就存在冲突的情况,如果系统响应了A视图的滑动事件,就顾不上B视图的滑动事件。
举个例子,某电商App的首页很长,内部采用滚动视图允许上下滚动。该页面中央有一个手势控制的横幅轮播,如下图所示。
在这里插入图片描述
用户在横幅上左右滑动,试图查看横幅的前后广告,结果如下图所示,原来翻页不成功,整个页面反而往上滚动了。
在这里插入图片描述
即使多次重复试验,仍然会发现横幅很少跟着翻页,而是继续上下滚动。因为横幅外层被滚动视图包着,系统检测到用户手势的一撇,父视图–滚动视图自作主张地认为用户要把页面往上拉,于是页面往上滚动,完全没有考虑这一撇其实是用户想翻动横幅。滚动视图不会考虑这些,因为没有人告诉它超过多大斜率才可以上下滚动;既然没有通知,那么滚动视图只要发现手势事件前后的纵坐标发生变化就一律进行上下滚动处理。
要解决这个滑动冲突,关键在于提供某种方式通知滚动视图,告诉它什么时候可以上下滚动、什么时候不能上下滚动。这个通知方式主要有两种:一种是父视图主动向下“查询”,即由滚动视图判断滚动规则并决定是否拦截手势;另一种是子视图向上“反映”,即由子视图告诉滚动视图是否拦截手势。下面分别介绍这两种处理方式。

1.由滚动视图判断滚动规则

前面的“触摸事件”小节提到,容器类可以重写onInterceptTouchEvent方法,根据条件判断决定是否拦截发给视图的手势。那么可以自定义一个滚动视图,在onInterceptTouchEvent方法中判断本次手势的横坐标与纵坐标,如果纵坐标的偏移大于横坐标的偏移,此时就是垂直滚动,应拦截手势并交给自身进行上下滚动;否则表示此时为水平滚动,不应拦截手势,而是让子视图左右滑动事件。
下面的代码演示了自定义滚动视图拦截垂直滚动并放过水平滚动的功能。

public class CustomScrollView extends ScrollView {
    private float mOffsetX, mOffsetY; // 横纵方向上的偏移
    private PointF mLastPos; // 上次落点的位置
    private int mInterval; // 与边缘线的间距阈值

    public CustomScrollView(Context context) {
        this(context, null);
    }

    public CustomScrollView(Context context, AttributeSet attr) {
        super(context, attr);
        mInterval = Utils.dip2px(context, 3);
    }

    // 在拦截触摸事件时触发
    @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节点改为自定义滚动视图的完整路径名称(如com.example.chapter11.widget.CustomScrollView),重新运行App后查看横幅轮播。手势滑动效果如下图所示。此时翻页成功并且整个页面固定不动,未发生上下滚动的情况。
在这里插入图片描述

2.子视图告诉滚动视图能否拦截

在目前的案例中,滚动视图下面只有横幅一个淘气鬼,所以允许单独给它“开小灶”。在实际应用场景中,往往有多个“淘气鬼”,一个要吃苹果,另一个要吃香蕉,倘若都要滚动视图帮忙,那可真是忙不过来了。不如弄个水果篮,想吃苹果的拿苹果,想吃香蕉的拿香蕉,日次皆大欢喜。
具体到代码实现,需要调用requestDisallowInterceptTouchEvent方法(参数为true时表示静止上级拦截触摸事件)。至于何时调用该方法,当然是在检测到滑动前后的横坐标偏移大于纵坐标偏移时。对于横幅采用手势监听器的情况,可重写onTouchEvent方法(在该方法中加入坐标偏移的判断),示例代码如下:

private float mOffsetX, mOffsetY; // 横纵方向上的偏移
private PointF mLastPos; // 上次落点的位置

@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;
}

修改后的手势滑动效果如上图效果。左右滑动能够正常翻页,整个页面也不容易上下滚动。

内部滑动与翻页滑动的冲突处理

在前面的手势冲突中,滚动视图是父视图,有时也是子视图,比如页面采用翻页视图的话,页面内的每个区域之间是左右滑动的关系,并且每个区域都可以拥有自己的滚动视图。如此一来,在左右滑动时,滚动视图反而变成翻页视图的子视图,前面的冲突处理办法就不能奏效了,只能另想办法。
自定义一个基于ViewPager的翻页视图是一种思路,另外还可以借鉴抽屉布局DrawerLayout。该布局允许左右滑动,在滑动时会拉出侧面的抽屉面板,常用于实现侧滑菜单。抽屉布局与翻页视图在滑动方面存在区别,翻页视图在内部的任何位置均可触发滑动事件,而抽屉布局只在屏幕两侧边缘才会触发滑动事件。
举个实际应用的例子,微信的聊天窗口是上下滚动的,在主窗口的大部分区域触摸都是上下滚动窗口,若在窗口左侧边缘按下再右拉,就会看到左边拉出了消息关注页面。限定某块区域接管特定的手势,这是一种处理滑动冲突行之有效的办法。
既然提到了抽屉布局,不妨稍微了解以下它。下面是DrawerLayout的常用方法。

抽屉布局不仅可以拉出左侧抽屉面板,还可以拉出右侧抽屉面板。左侧面板与右侧面板的区别在于:左侧面板在布局文件中的layout_gravity属性值为left,右侧面板在布局文件中的layout_gravity属性值为right。
下面是使用DrawerLayout的布局文件:

<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>

上述布局文件对应的页面代码如下:

public class DrawerLayoutActivity extends AppCompatActivity {
    private final static String TAG = "DrawerLayoutActivity";
    private DrawerLayout dl_layout; // 声明一个抽屉布局对象
    private Button btn_drawer_left; // 声明一个按钮对象
    private Button btn_drawer_right; // 声明一个按钮对象
    private TextView tv_drawer_center; // 声明一个文本视图对象
    private ListView lv_drawer_left; // 声明左侧菜单的列表视图对象
    private ListView lv_drawer_right; // 声明右侧菜单的列表视图对象
    private String[] titleArray = {"首页", "新闻", "娱乐", "博客", "论坛"}; // 左侧菜单项的标题数组
    private String[] settingArray = {"我的", "设置", "关于"}; // 右侧菜单项的标题数组

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_drawer_layout);
        dl_layout = findViewById(R.id.dl_layout);
        dl_layout.addDrawerListener(new SlidingListener()); // 设置侧滑监听器
        btn_drawer_left = findViewById(R.id.btn_drawer_left);
        btn_drawer_right = findViewById(R.id.btn_drawer_right);
        tv_drawer_center = findViewById(R.id.tv_drawer_center);
        btn_drawer_left.setOnClickListener(v -> {
            if (dl_layout.isDrawerOpen(lv_drawer_left)) { // 左侧菜单已打开
                dl_layout.closeDrawer(lv_drawer_left); // 关闭左侧抽屉
            } else { // 左侧菜单未打开
                dl_layout.openDrawer(lv_drawer_left); // 打开左侧抽屉
            }
        });
        btn_drawer_right.setOnClickListener(v -> {
            if (dl_layout.isDrawerOpen(lv_drawer_right)) { // 右侧菜单已打开
                dl_layout.closeDrawer(lv_drawer_right); // 关闭右侧抽屉
            } else { // 右侧菜单未打开
                dl_layout.openDrawer(lv_drawer_right); // 打开右侧抽屉
            }
        });
        initListDrawer(); // 初始化侧滑的菜单列表
    }

    // 初始化侧滑的菜单列表
    private void initListDrawer() {
        // 下面初始化左侧菜单的列表视图
        lv_drawer_left = findViewById(R.id.lv_drawer_left);
        ArrayAdapter<String> left_adapter = new ArrayAdapter<>(this,
                R.layout.item_select, titleArray);
        lv_drawer_left.setAdapter(left_adapter);
        lv_drawer_left.setOnItemClickListener((parent, view, position, id) -> {
            String text = titleArray[position];
            tv_drawer_center.setText("这里是" + text + "页面");
            dl_layout.closeDrawers(); // 关闭所有抽屉
        });
        // 下面初始化右侧菜单的列表视图
        lv_drawer_right = findViewById(R.id.lv_drawer_right);
        ArrayAdapter<String> right_adapter = new ArrayAdapter<>(this,
                R.layout.item_select, settingArray);
        lv_drawer_right.setAdapter(right_adapter);
        lv_drawer_right.setOnItemClickListener((parent, view, position, id) -> {
            String text = settingArray[position];
            tv_drawer_center.setText("这里是" + text + "页面");
            dl_layout.closeDrawers(); // 关闭所有抽屉
        });
    }

    // 定义一个抽屉布局的侧滑监听器
    private class SlidingListener implements 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) {}
    }
}

抽屉布局的展示效果如下图所示。左侧图片为从左边侧边缘拉出侧滑菜单的界面,右边图片为从右边侧边缘拉出侧滑菜单的界面。
在这里插入图片描述

正常下拉与下拉刷新的冲突处理

电商App的首页通常都不支持下拉刷新,比如京东首页的头部轮播图一直顶到系统状态栏,并且页面下拉到顶后,继续下拉会拉出带有“下拉刷新”字样的布局,此时松手会触发页面的刷新动作。虽然Android提供了专门的下拉刷新布局SwipeRefreshLayout,但是它没有实现页面随手势下滚的动态效果。一些第三方的开源库(如PullToRefreshLayout、SmartRefreshLayout等)固然能让整体页面下滑,可是顶部的下拉布局很难个性化定制,状态栏、工具栏的背景色修改更是三不管。若想呈现完全仿照京东的下拉刷新特效,只能由开发者编写一个自定义的布局控件。
自定义的下拉刷新布局首先要能够区分是页面的正常下滚还是拉到头部要求刷新。二者之间的区别很简单,直观上就是判断当前页面是否拉到顶。倘若还没拉到顶,继续下拉动作属于正常的页面滚动;倘若已经拉到顶,继续下拉动作才会拉出头部提示刷新。所以此处需捕捉页面滚动到顶部的事件,相对应的是页面滚动到底部的事件。鉴于App首页基本采用滚动视图实现页面滚动功能,故而该问题变成如何监听该视图滚到顶部或者底部。ScrollView提供了滚动行为的变化方法onScrollChanged,通过重写该方法即可判断是否达到顶部或底部。重写后的代码片段如下:

// 在滚动变更时触发
@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();
        }
    }
}

private ScrollListener mScrollListener; // 声明一个滚动监听器对象
// 设置滚动监听器
public void setScrollListener(ScrollListener listener) {
    mScrollListener = listener;
}

// 定义一个滚动监听器接口,用于捕捉到达顶部和到达底部的事件
public interface ScrollListener {
    void onScrolledToBottom(); // 已经滚动到底部
    void onScrolledToTop(); // 已经滚动到顶部
}

如此改造一番,只要活动代码设置了滚动视图得滚动监听器,就能由onScrolledToTop方法判断当前页面是否拉到顶了。既然能够知晓到顶与否,同步变更状态栏和工具栏得背景色也就可行了。当前的页面效果如下图所示。
在这里插入图片描述

成功监听页面是否达到顶部或底部仅仅解决了状态栏和工具栏的变色问题,页面到顶后继续下拉滚动视图要怎么处理呢?一方面是整个页面已经拉到顶了,滚动视图已经无可再拉;另一方面用户在京东首页看到的下拉头部并不属于滚动视图管辖,即使它想拉一下,也是有心无力。不管滚动视图是惊慌失措还是不知所措,恰恰说明它是真的束手无策了,为此还要一个和事佬来摆平下拉布局和滚动视图之间的纠纷。这个和事佬必须是下拉布局和滚动视图的父布局,考虑到下拉布局在上、滚动视图在下,故它俩的父布局集成线性布局比较合适。新的父视图需要完成以下3项任务:

  1. 在子视图的最前面自动添加一个下拉刷新头部,保证该下拉头部位于整个页面的最上方。
  2. 给前面自定义的滚动视图注册滚动监听器和触摸监听器。其中,滚动监听器用于处理到达顶部和到达底部的事件,触摸监听器用于处理下拉过程中的持续位移。
  3. 重写触摸监听器接口需要实现的onTouch方法。这个是重中之重,因为该方法包含了所有的手势下拉跟踪处理,既要准确响应正常的下拉手势,也要避免误操作不属于下拉的手势,比如下main几种情况就要统筹考虑:
    1. 水平方向的左右滑动,不做额外处理。
    2. 垂直方向的向上拉动,不做额外处理。
    3. 下拉的时候尚未拉到页面顶部,不做额外处理。
    4. 拉到顶之后继续下拉,则在隐藏工具栏的同时让下拉头部跟着往下滑动。
    5. 下拉刷新过程中松开手势,判断下拉滚动的距离,距离太短则直接缩回头部、不刷新页面,只有足够长才会刷新页面,等待刷新完毕再缩回头部。

有了新定义的下拉上层布局,搭配自定义的滚动视图就能很方便地实现高仿东京首页地下拉刷新效果了。具体实现的首页布局模板如下:

<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.example.chapter11.widget.PullDownRefreshLayout
        android:id="@+id/pdrl_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <com.example.chapter11.widget.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">
				<!-- 此处放具体的布局内容 -->
            </LinearLayout>
        </com.example.chapter11.widget.PullDownScrollView>
    </com.example.chapter11.widget.PullDownRefreshLayout>
</RelativeLayout>

以上模板用到的自定义控件PullDownRefreshLayout和PullDownScrollView代码量较多,这里就不贴出来了,可点击文章最下方的工程源码下载查看。运行App,下拉效果如下图所示。
在这里插入图片描述

工程源码

文章涉及所有代码可点击工程源码下载。