Android拖拽、回弹布局

这一次拆解的是今日头条的关注页面:点击关注的头像会弹出一个文章列表。在边界拖拽会出现关闭提示。这次同时实现了Android端和IOS端的效果。

先讲解Android端的实现吧,毕竟我是个Android开发仔呀

效果如下图:


Android端

弹出来的页面可以左右切换,每个页面是单独的列表,能上下滑动,所以这里直接用viewPager+recycelrView实现。
当viewPager不能左右滑动的时候,移动整个viewPager,出现文字提示,当滑动距离超过阈值时,文字改变。
当手指松开时,若滑动距离未到达阈值,回弹;否则结束页面。
同样,当recyclerView在顶部不能滑动时,移动recyclerView,出现提示,后续跟viewPager一致故不再赘述。

ReBoundLayout

这里的回弹我自定义了一个回弹布局,下面介绍一下回弹布局的几个重要方法:
onInterceptTouchEvent()

@Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                //记录坐标
                break;
            case MotionEvent.ACTION_MOVE:
                int difX = (int) (ev.getX() - mDownX);
                int difY = (int) (ev.getY() - mDownY);
                if (orientation == LinearLayout.HORIZONTAL) {
                        .....
                    if (水平滑动) {
                        if (!innerView.canScrollHorizontally(-1) && difX > 0) {
                            //右拉到边界
                            return true;
                        }
                        if (!innerView.canScrollHorizontally(1) && difX < 0) {
                            //左拉到边界
                            return true;
                        }
                    }
                } else {
                     ......
                    if (竖直滑动) {
                        if (!innerView.canScrollVertically(-1) && difY > 0) {
                            //下拉到边界
                            return true;
                        }
                        if (!innerView.canScrollVertically(1) && difY < 0) {
                            //上拉到边界
                            return true;
                        }
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
               ......重置变量
                break;
            default:
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }

当控件方向为横向且滑动为水平滑动时,检测innerView能否在该方向上滑动;若不能,则拦截事件,交给自身处理(纵向同理)。
拦截事件后,在onTouchEvent()进行处理,实现移动和回弹。

@Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_MOVE:
                if (orientation == LinearLayout.HORIZONTAL) {
                    int difX = (int) ((event.getX() - mDownX) / resistance);
                    boolean isRebound = false;
                    if (!innerView.canScrollHorizontally(-1) && difX > 0) {
                        //右拉到边界
                        isRebound = true;
                    } else if (!innerView.canScrollHorizontally(1) && difX < 0) {
                        //左拉到边界
                        isRebound = true;
                    }
                    if (isRebound) {
                        //移动和回调
                        return true;
                    }
                } else {
                    int difY = (int) ((event.getY() - mDownY) / resistance);
                    boolean isRebound = false;
                    if (!innerView.canScrollVertically(-1) && difY > 0) {
                        //下拉到边界
                        isRebound = true;
                    } else if (!innerView.canScrollVertically(1) && difY < 0) {
                        //上拉到边界
                        isRebound = true;
                    }
                    if (isRebound) {
                        //移动和回调
                        return true;
                    }
                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                if (orientation == LinearLayout.HORIZONTAL) {
                    int difX = (int) innerView.getTranslationX();
                    if (difX != 0) {
                        if (Math.abs(difX) <= resetDistance || isNeedReset) {
                            innerView.animate().translationX(0).setDuration(mDuration).setInterpolator(mInterpolator);
                        }
                        //回调
                    }
                } else {
                    int difY = (int) innerView.getTranslationY();
                    if (difY != 0) {
                        if (Math.abs(difY) <= resetDistance || isNeedReset) {
                            innerView.animate().translationY(0).setDuration(mDuration).setInterpolator(mInterpolator);
                        }
                        //回调
                    }
                }
                break;
            default:
                break;
        }
        return super.onTouchEvent(event);
    }

MOVE事件
利用setTranslationX()setTranslationY()改变innerView的位置,同时将滑动距离和方向通过接口回调到外面。
UP事件
判断滑动距离是否小于阈值,小于则执行回弹动画;同时回调到外面。
以上就是回弹布局的简单实现,主要是对滑动事件进行拦截处理,如果不清楚事件传递机制可以到这里查看。
布局有3个自定义属性

<declare-styleable name="ReBoundLayout">
        <attr name="reBoundOrientation" format="enum">
            <enum name="horizontal" value="0" />
            <enum name="vertical" value="1" />
        </attr>
        <attr name="resistance" format="float" />
        <attr name="reBoundDuration" format="integer" />
</declare-styleable>

分别是:回弹方向、阻力系数、回弹时间,剩余属性可以调用set()方法修改。


好了,现在回弹实现了,接下来就是将文字提示加上,结束动画加上。这里有一点需要注意的是:demo中使用的是reBoundLayout+viewPager+fragment(reBoundLayout+recyclerView)的结构实现的。而文字是跟viewPager同一层级的,所以需要把fragment的回调回调到activity里(也可以getActivity()获取对应的文字控件),详见代码。
以下是回调的伪代码:

 @Override
    public void onDistanceChange(int distance, int direction) {
        switch (direction) {
            case DIRECTION_LEFT:
                if (distance > showTipDistance) {
                    //文字改变,移动
                } else {
                    rightTip.setVisibility(View.GONE);
                }
                break;
            case DIRECTION_RIGHT:
                if (distance > showTipDistance) {
                   //文字改变,移动
                } else {
                    leftTip.setVisibility(View.GONE);
                }
                break;
            case DIRECTION_UP:
                break;
            case DIRECTION_DOWN:
                //fragment的回调会走到这里
                if (distance > showTipDistance) {
                    //文字改变,移动
                } else {
                    topTip.setVisibility(View.GONE);
                }
                break;
            default:
                break;
        }
    }

    @Override
    public void onFingerUp(int distance, int direction) {
        switch (direction) {
            case DIRECTION_LEFT:
                if (distance > mResetDistance) {
                    //结束页面
                } else {
                    //文字重置
                }
                break;
            case DIRECTION_RIGHT:
                if (distance > mResetDistance) {
                   //结束页面
                } else {
                    //文字重置
                }
                break;
            case DIRECTION_DOWN:
                if (distance > mResetDistance) {
                  //结束页面
                } else {
                    //文字重置
                }
                break;
            default:
                break;
        }
    }

大功告成,Android端的效果比较简单,实现起来也比较容易。

IOS端效果复杂一丢丢,大家留心看。

效果如下:


IOS端

当页面不能拖动时(右滑、左滑、下滑),view的位置开始改变,并且整个页面会缩小成一个圆;当松手时距离大于阈值,view缩小为一个圆并平移到进入的那个圆位置,结束当前页面;否则回弹(demo中只给出一个圆,若需实现头条的效果,只需更改对应Point点得坐标即可)。
同样,自定义一个布局进行滑动事件的处理,至于整个页面的缩小变圆,这里通过裁剪画布的方式去实现(圆心固定在屏幕中央),也可以通过别的方法(Xfermode)去实现同样的效果,有兴趣的朋友自行探索。

PS:如果想圆心跟随手指移动,需要增加以下计算:圆最大半径、圆可移动距离与半径变化关系

DragZoomLayout

关键变量:

  • mMinRadius 圆最小半径
  • mMaxRadius 圆最大半径
  • mRadius 当前半径
  • mTranslationX 当前X移动距离
  • mTranslationY 当前Y移动距离

事件拦截跟ReBoundLayout一致,所以不赘述,主要看看滑动事件的处理

 @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_MOVE:
                int difX = (int) ((event.getX() - mDownX) / resistance);
                int difY = (int) ((event.getY() - mDownY) / resistance);
                if (orientation == LinearLayout.HORIZONTAL) {
                    boolean needDrag = false;
                    if (!innerView.canScrollHorizontally(-1) && difX > 0) {
                        //右啦到边界
                        needDrag = true;
                    } else if (!innerView.canScrollHorizontally(1) && difX < 0) {
                        //左拉到边界
                        needDrag = true;
                    }
                    if (needDrag) {
                        //半径计算
                        mTranslationX = difX;
                        mTranslationY = difY;
                        invalidate();
                        //回调
                        return true;
                    }
                } else {
                    if (!innerView.canScrollVertically(-1) && difY > 0) {
                        //下拉到边界
                        //回调
                        return true;
                    } else if (!innerView.canScrollVertically(1) && difY < 0) {
                        //上啦到边界
                        innerView.setTranslationY(difY);
                        return true;
                    }
                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                if (orientation == LinearLayout.HORIZONTAL) {
                    //水平
                    if (Math.abs(mTranslationX) >= resetDistance) {
                        //回调
                    } else {
                        //重置状态
                    }
                } else {
                    //竖直
                    if (innerView.getTranslationY() < 0) {
                        innerView.animate().setDuration(mDuration).translationY(0).setInterpolator(mInterpolator);
                    } else {
                        //回调
                    }
                }
                break;
            default:
                break;
        }
        return super.onTouchEvent(event);
    }

这里跟ReBoundLayout有以下几点区别:

  • 通过裁剪画布的方式达到view缩小成圆的效果
  • 通过移动画布达到移动view的效果(setTranslation会触发view的重绘,同时改变x跟y,会调用2次,而修改画布大小又需要重绘,调用次数太多,因此不使用该方式)
  • 下滑跟左右滑动一样,缩小、移动的都是最外层的DragZoomLayout(这样视觉效果最好,而且能统一处理);上滑只做移动和回弹。

PS:DragZoomLayout一定要设置背景,不然调用invalidate()会无效;上下滑动的mTranslationX、mTranslationY一直都是0(因为下滑我们已经回调给最层的DragZoomLayout),所以在ACTION_UP、ACTION_CANCEL事件,竖直方向回调时是使用当前事件的x、y跟点击的x、y相减的值去回调。

布局绘制
 @Override
    protected void onDraw(Canvas canvas) {
        if (Math.abs(mTranslationX) > mLargeX) {
            mTranslationX = mTranslationX > 0 ? mLargeX : -mLargeX;
        }
        if (Math.abs(mTranslationY) > mLargeY) {
            mTranslationY = mTranslationY > 0 ? mLargeY : -mLargeY;
        }
        canvas.translate(mTranslationX, mTranslationY);
        mPath.reset();
        mPath.addCircle(mPoint.x, mPoint.y, mRadius, Path.Direction.CCW);
        canvas.clipPath(mPath);
        super.onDraw(canvas);
    }

进行了一些位置和半径的限制。
布局完成,接下来处理页面间的接口回调及结束动画


动画的计算有一点点麻烦,数学不好的同学请多看几遍,还是不懂的趁着过年回高中找数学老师要回学费吧。


先来看没有移动画布的情况:

启动页面时,通过getLocationOnScreen()获取进入时的坐标,退出时的坐标通过最外层的dragLayout的坐标加上宽高的一半,再减去圆的最小半径得到,最后通过这2个差值进行平移。
那么有平移并且半径未到最小的情况也可以通过这种方式计算:

我们已经有一个translationX了,那可以计算出目标的translationX,然后使用ValueAnimator不断去改变它进行重绘,得到一个平移效果(translationY同理)。那这个值要怎么得到呢?上面已经说了怎么计算了,没看懂的再看一遍。看几遍还是不懂的,回去找老师要学费吧。

至于进入动画原理相同,只是反过来执行罢了,这里不再赘述,详见代码。
有更好实现方式的欢迎下方留言讨论,有bug或者疑问的也可以留言,有空会回复的。
由于篇幅关系,一些细小的地方没有提及,有兴趣的朋友可以自行查看。
最后奉上源码;


这是年前最后一篇博客了,今年立的flag好像都没有实现,跟大佬的差距还是那么大,Bug仔仍需努力呀。


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

推荐阅读更多精彩内容