仿知乎可拖动悬停按钮

仿知乎可拖动悬停按钮

效果如下:

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,共同探讨,共同进步。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343