仿知乎可拖动悬停按钮

仿知乎可拖动悬停按钮

效果如下:

image

实现的主要功能有:

1. 随手拖动

2. 响应点击事件

3. 全屏拖动,也可以限定位置

4. 可通过xml配置颜色和内部样式

快速使用

1.在工程根目录的build.gradle中添加依赖

并在工程目录的build.gradle中添加依赖(最新版本可查看TrackView)


implementation 'com.github.XiaogegeChen:TrackView:1.0'

2.在xml中配置属性


<com.xiaoegeg.arttest.TrackView

        android:id="@+id/track_view"

        android:layout_marginTop="50dp"

        android:layout_width="50dp"

        android:layout_height="100dp" />

3.通过findViewById()方法找到控件,如果需要响应点击事件,使用setOnClickListener()方法添加监听器即可。如:


        final TrackView trackView = findViewById (R.id.track_view);

        trackView.setOnClickListener (new View.OnClickListener () {

            @Override

            public void onClick(View v) {

                Toast.makeText (MainActivity.this, "点击了拖动按钮",  Toast.LENGTH_SHORT).show ();

            }

        });

可选属性

可以根据需要配置相应的属性

app:inner_distance是两个箭头之间的间距

app:inner_length是每个箭头的边长

app:inner_stroke_width是两个箭头的线条宽

app:blank_bottom是底部留白的高度

app:blank_left是左侧留白的高度

app:blank_right是右侧留白的高度

app:blank_top是顶部留白的高度

app:inner_content_color是圆形内部的填充色

app:inner_stroke_color是两个箭头的线条颜色

app:out_stroke_color是外圆线条的颜色

app:out_stroke_width是外圆线条的线宽

至此, 就可以实现演示的功能了。有兴趣可以接着分析一下实现方法。

实现方法

主要是通过重写onTouchEvent()方法,下面按照功能分步完成onTouchEvent()方法。

1.随手拖动

在自定义view中,如果需要实现随手拖动功能,可以从Android的事件分发机制入手。 手指从接触屏幕到离开屏幕是一个事件序列,这个序列一定是从MotionEvent.ACTION_DOWN开始,到MotionEvent.ACTION_UP结束,如果是滑动,中间会有一系列的MotionEvent.ACTION_MOVE事件,如果是点击或者长按事件,则不会有MotionEvent.ACTION_MOVE事件。因此可以在MotionEvent.ACTION_MOVE事件发生时候拿到手指点击位置的坐标,并将view移动到这个位置,即可实现随手拖动。

所以,通过重写onTouchEvent()方法,在event为MotionEvent.ACTION_MOVE时候移动view,就可以实现随手拖动,并返回true来截获并消费触摸事件序列,不再继续传递。伪代码就可以这样写:


    @Override

    public boolean onTouchEvent(MotionEvent event) {

        // 获得触摸点的绝对坐标

        int x = (int) event.getRawX ();

        int y = (int) event.getRawY ();

        switch(event.getAction ()){

            case MotionEvent.ACTION_DOWN:

            case MotionEvent.ACTION_UP:

                break;

            case MotionEvent.ACTION_MOVE:

                int dx;

                int dy;

                // 拿到位置差

                dx = x - mLastX;

                dy = y - mLastY;

                // 移动view

                setTranslationX (getTranslationX () + dx);

                setTranslationY (getTranslationY () + dy);

                break;

        }

        // 更新位置

        mLastX = x;

        mLastY = y;

        return true;

    }

2.响应点击

如果按照上面的代码,可以实现随手拖动,但是不能响应点击事件。这时注意这个警告:

[图片上传失败...(image-762412-1557739293460)]

意思是说我们在调用onTouchEvent()时要考虑在它的内部调用performClick()方法,因为view的点击事件其实是在onTouchEvent()调用的,如果我们在重写onTouchEvent()时没有调用performClick(),就会导致点击事件无法响应,从源码中也能印证这一点

image
在这里插入图片描述

这个方法在MotionEvent.ACTION_UP,就是一个事件序列结束时候调用。因此要响应点击事件,需要在onTouchEvent()中调用performClick()方法。MotionEvent.ACTION_UP代表一个事件序列的结束,因此需要在这时调用performClick()方法如下:


    @Override

    public boolean onTouchEvent(MotionEvent event) {

        // 获得触摸点的绝对坐标

        int x = (int) event.getRawX ();

        int y = (int) event.getRawY ();

        switch(event.getAction ()){

            case MotionEvent.ACTION_DOWN:

                break;

            case MotionEvent.ACTION_UP:

                performClick ();

                break;

            case MotionEvent.ACTION_MOVE:

                int dx;

                int dy;

                // 拿到位置差

                dx = x - mLastX;

                dy = y - mLastY;

                // 移动view

                setTranslationX (getTranslationX () + dx);

                setTranslationY (getTranslationY () + dy);

                break;

        }

        // 更新位置

        mLastX = x;

        mLastY = y;

        return true;

    }

但是这样会出现滑动与点击冲突,滑动结束的MotionEvent.ACTION_UP同样会触发点击事件。

点击时间与滑动事件的区别在于,点击事件的事件序列中无MotionEvent.ACTION_MOVE事件,因此可以通过这个差异来进行区分(这里增加了一种情况,如果触摸着这个view超过500ms没有离开,将其判定为用户在犹豫,因此判定为取消,不判定为点击)。如下:


@Override

    public boolean onTouchEvent(MotionEvent event) {

        int x = (int) event.getRawX ();

        int y = (int) event.getRawY ();

        switch(event.getAction ()){

            case MotionEvent.ACTION_DOWN:

                // 重置这个开始的时间

                mDownTime = System.currentTimeMillis ();

                break;

            case MotionEvent.ACTION_UP:

                // 重置这个结束的时间

                mUpTime = System.currentTimeMillis ();

                // 设置当前的模式

                if(mMode != Mode.MOVE){

                    if(mUpTime - mDownTime >= CANCEL_INTERVAL_DEFAULT){

                        mMode = Mode.CANCEL;

                    }else{

                        mMode = Mode.CLICK;

                    }

                }

                // 根据当前的模式设置是否调用点击事件

                if(mMode == Mode.CLICK){

                    performClick ();

                }

                // 这个事件序列结束,重置当前的模式

                mMode = Mode.NONE;

                break;

            case MotionEvent.ACTION_MOVE:

                // 只要触发了ACTION_MOVE就设置为move模式

                mMode = Mode.MOVE;

                int dx;

                int dy;

                dx = x - mLastX;

                dy = y - mLastY;

                setTranslationX (getTranslationX () + dx);

                setTranslationY (getTranslationY () + dy);

                break;

        }

        // 更新位置

        mLastX = x;

        mLastY = y;

        return true;

    }

    /**

    * 该控件的三种模式,只要触发了ACTION_MOVE就是MOVE模式

    * 没有触发ACTION_MOVE但是从ACTION_DOWN开始超过了500ms就是CANCEL模式

    * 未超过就是CLICK模式

    */

    private enum Mode{

        // 取消,不执行任何逻辑

        CANCEL,

        // 点击,执行点击事件

        CLICK,

        // 移动模式,随手移动

        MOVE,

        // 无模式,就是复位后的状态

        NONE

    }

记录事件序列开始,就是MotionEvent.ACTION_DOWN的时间和事件序列结束,就是MotionEvent.ACTION_UP的时间。如果有出现MotionEvent.ACTION_MOVE,直接判定MOVE模式,如果时间差超过500ms并且没MotionEvent.ACTION_MOVE,判定为CANCEL模式,剩下的就是CLICK模式了。判定完模式之后,就可以根据模式来决定是否调用performClick()以响应点击事件了。

3.限定位置

由于是全屏滑动,如果不设置限定,会出现view飞出视野的情况。

因此,在执行view的移动前预先判断一下不加限制将会到达的位置,如果位置在限定范围之外,就调整移动的距离即可,如下:


    @Override

    public boolean onTouchEvent(MotionEvent event) {

        int x = (int) event.getRawX ();

        int y = (int) event.getRawY ();

        switch(event.getAction ()){

            case MotionEvent.ACTION_DOWN:

                // 重置这个开始的时间

                mDownTime = System.currentTimeMillis ();

                // 拿到拖动点相对view的位置

                mDisX = (int) event.getX ();

                mDisY = (int) event.getY ();

                break;

            case MotionEvent.ACTION_UP:

                // 重置这个结束的时间

                mUpTime = System.currentTimeMillis ();

                // 设置当前的模式

                if(mMode != Mode.MOVE){

                    if(mUpTime - mDownTime >= CANCEL_INTERVAL_DEFAULT){

                        mMode = Mode.CANCEL;

                    }else{

                        mMode = Mode.CLICK;

                    }

                }

                // 根据当前的模式设置是否调用点击事件

                if(mMode == Mode.CLICK){

                    performClick ();

                }

                // 这个事件序列结束,重置当前的模式

                mMode = Mode.NONE;

                break;

            case MotionEvent.ACTION_MOVE:

                // 只要触发了ACTION_MOVE就设置为move模式

                mMode = Mode.MOVE;

                int dx;

                int dy;

                // 预测量的边距

                int preXLeft = x - mDisX;

                int preXRight = mScreenWidthInPixel - (x + Math.min (mOutWidth, mOutHeight) - mDisX);

                int preYUp = y - mDisY;

                int preYDown = mScreenHeightInPixel - (y + Math.min (mOutWidth, mOutHeight) - mDisY);

                // 处理X坐标

                if(preXLeft <= mBlankLeft){

                    // 超出左边界

                    dx = x - mLastX + mBlankLeft - preXLeft;

                    x = x + mBlankLeft - preXLeft;

                }else if(preXRight <= mBlankRight){

                    // 超出右边界

                    dx = x - mLastX - (mBlankRight - preXRight);

                    x = x - (mBlankRight - preXRight);

                }else{

                    // 正常

                    dx = x - mLastX;

                }

                // 处理Y坐标

                if (preYUp <= mBlankTop) {

                    // 超出上边界

                    dy = y - mLastY + mBlankTop - preYUp;

                    y = y + mBlankTop - preYUp;

                }else if(preYDown <= mBlankBottom){

                    // 超出下边界

                    dy = y - mLastY - (mBlankBottom - preYDown);

                    y = y - (mBlankBottom - preYDown);

                }else {

                    // 正常

                    dy = y - mLastY;

                }

                setTranslationX (getTranslationX () + dx);

                setTranslationY (getTranslationY () + dy);

                break;

        }

        // 更新位置

        mLastX = x;

        mLastY = y;

        return true;

    }

画个图来辅助理解

在这里插入图片描述

拿左边界来说。存在一个临界点,上一次未到达边界,下一次将会到达边界,因此预先计算一下,如果是这种情况,将多出的长度减掉即可。

4.自动吸附

知乎的悬浮按钮可以自动靠边,不然影响阅读。

在事件序列结束时,即MotionEvent.ACTION_UP中判断当前view处于屏幕的左半部还是右半部,然后直接移动到边上即可。


    @Override

    public boolean onTouchEvent(MotionEvent event) {

        int x = (int) event.getRawX ();

        int y = (int) event.getRawY ();

        switch(event.getAction ()){

            case MotionEvent.ACTION_DOWN:

                // 重置这个开始的时间

                mDownTime = System.currentTimeMillis ();

                mDisX = (int) event.getX ();

                mDisY = (int) event.getY ();

                break;

            case MotionEvent.ACTION_UP:

                // 重置这个结束的时间

                mUpTime = System.currentTimeMillis ();

                // 设置当前的模式

                if(mMode != Mode.MOVE){

                    if(mUpTime - mDownTime >= CANCEL_INTERVAL_DEFAULT){

                        mMode = Mode.CANCEL;

                    }else{

                        mMode = Mode.CLICK;

                    }

                }

                // 根据当前的模式设置是否调用点击事件

                if(mMode == Mode.CLICK){

                    performClick ();

                }

                // 回到侧面

                if(event.getRawX () < mScreenWidthInPixel / 2){

                    // 回到最左侧

                    setTranslationX (getTranslationX () + (-1 * (x - mDisX - mBlankLeft)));

                    x = x - (x - mDisX - mBlankLeft);

                }else{

                    // 回到最右侧

                    setTranslationX (getTranslationX () + ((mScreenWidthInPixel - mBlankRight) - (Math.min (mOutWidth, mOutHeight) - mDisX + x)));

                    x = x + (mScreenWidthInPixel - mBlankRight) - (Math.min (mOutWidth, mOutHeight) - mDisX + x);

                }

                // 这个事件序列结束,重置当前的模式

                mMode = Mode.NONE;

                break;

            case MotionEvent.ACTION_MOVE:

                // 只要触发了ACTION_MOVE就设置为move模式

                mMode = Mode.MOVE;

                int dx;

                int dy;

                // 预测量的边距

                int preXLeft = x - mDisX;

                int preXRight = mScreenWidthInPixel - (x + Math.min (mOutWidth, mOutHeight) - mDisX);

                int preYUp = y - mDisY;

                int preYDown = mScreenHeightInPixel - (y + Math.min (mOutWidth, mOutHeight) - mDisY);

                // 处理X坐标

                if(preXLeft <= mBlankLeft){

                    // 超出左边界

                    dx = x - mLastX + mBlankLeft - preXLeft;

                    x = x + mBlankLeft - preXLeft;

                }else if(preXRight <= mBlankRight){

                    // 超出右边界

                    dx = x - mLastX - (mBlankRight - preXRight);

                    x = x - (mBlankRight - preXRight);

                }else{

                    // 正常

                    dx = x - mLastX;

                }

                // 处理Y坐标

                if (preYUp <= mBlankTop) {

                    // 超出上边界

                    dy = y - mLastY + mBlankTop - preYUp;

                    y = y + mBlankTop - preYUp;

                }else if(preYDown <= mBlankBottom){

                    // 超出下边界

                    dy = y - mLastY - (mBlankBottom - preYDown);

                    y = y - (mBlankBottom - preYDown);

                }else {

                    // 正常

                    dy = y - mLastY;

                }

                setTranslationX (getTranslationX () + dx);

                setTranslationY (getTranslationY () + dy);

                break;

        }

        // 更新位置

        mLastX = x;

        mLastY = y;

        return true;

    }

至此,完整的onTouchEvent()方法就完成了,其它的就是一些自定义view常用的方法了,完整代码可以参考TrackView.java

项目托管在GitHub上,欢迎提出issue,共同探讨,共同进步。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。