这一次拆解的是今日头条的关注页面:点击关注的头像会弹出一个文章列表。在边界拖拽会出现关闭提示。这次同时实现了Android端和IOS端的效果。
先讲解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个自定义属性
复制代码
分别是:回弹方向、阻力系数、回弹时间,剩余属性可以调用**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端效果复杂一丢丢,大家留心看。
效果如下:
当页面不能拖动时(右滑、左滑、下滑),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仔仍需努力呀。