ViewDragHelper实战:APP内“悬浮球”
Cha7096
8年前
<h3><strong>前言</strong></h3> <p>“悬浮球”最初是iPhone手机上的一个虚拟按键,它会悬浮于所有APP之上,手指随意拖动,松开后会自动贴边显示。现在满大街都是iPhone手机,相信大家都用过或者看过这个效果,这里就不上图了~</p> <p>当前,很多Android手机也都有了这个功能,并且很多第三方APP也实现了此功能,比如某垃圾清理软件。可能大家立马就会想到,这个不就是使用 <strong>WindowManager</strong> 实现的 <strong>悬浮窗</strong> ,然后在 onTouch 事件里面根据手指的移动来改变位置吗?</p> <p>确实,如果你的 <strong>“悬浮球”</strong> 是在桌面,实现方案的确如此(也只能如此)。但是,本文需要实现的是 <strong>应用内“悬浮球”</strong> ,即:退出应用不需要显示,并且我们不希望使用 android.permission.SYSTEM_ALERT_WINDOW 这个权限,要知道Android M 6.0此权限属于危险权限,需要动态申请授权后才能使用,且使用 <strong>WindowManager</strong> 实现 <strong>悬浮窗</strong> <strong>“必须”</strong> ( 此处有引号~ )使用此权限。</p> <p>上文的 <strong>“必须”</strong> 加引号的原因:WindowManager特定情况是可以无权限显示悬浮框的,但这不是本文讨论的范畴,总结来说,无权限的坑还是很多~</p> <p><strong>效果图</strong></p> <p>下面的效果图,是一款线上App新版即将发布的功能。</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/a0bf5d3762b42f698b3051c89abcfd75.gif"></p> <p>可以看到, <strong>“悬浮球”</strong> 在App内所有界面都 <strong>“独立”</strong> 显示,每个界面都支持拖动并 <strong>自动贴边</strong> ,且所有界面的 <strong>“悬浮球”</strong> 位置都保持一致。</p> <p>实现步骤</p> <p>我们将“悬浮球”实现步骤分解为以下几步:</p> <ol> <li>屏幕范围内任意位置拖动</li> <li>释放后自动贴边</li> <li>解决UI刷新,恢复到原始位置的问题</li> <li>提供统一入口给所有Activity</li> <li>所有Activity保持“实时”位置一致</li> </ol> <p>下面,我们就每个步骤进行分别讲解:</p> <p><strong>一、屏幕范围内任意位置拖动</strong></p> <p>我们在 Android自定义ViewGroup神器-ViewDragHelper 一文中已经做过详细的讲解,通过重写 ViewDragHelper.Callback 的以下方法实现:</p> <ol> <li> <p>tryCaptureView 判断 View 是否是我们要拖动的</p> <pre> <code class="language-java">@Override public boolean tryCaptureView(View child, int pointerId) { return child == floatingBtn; }</code></pre> </li> <li> <p>clampViewPositionHorizontal 和 clampViewPositionVertical ,返回水平和垂直方向可移动的范围</p> <pre> <code class="language-java">@Override public int clampViewPositionVertical(View child, int top, int dy) { if (top > getHeight() - child.getMeasuredHeight()) { top = getHeight() - child.getMeasuredHeight(); } else if (top < 0) { top = 0; } return top; } @Override public int clampViewPositionHorizontal(View child, int left, int dx) { if (left > getWidth() - child.getMeasuredWidth()) { left = getWidth() - child.getMeasuredWidth(); } else if (left < 0) { left = 0; } return left; }</code></pre> </li> <li> <p>如果可拖动的 View 是可点击的(Button or 其他), getViewHorizontalDragRange 和 getViewVerticalDragRange 需要返回水平和垂直可移动的范围</p> <pre> <code class="language-java">@Override public int getViewVerticalDragRange(View child) { return getMeasuredHeight() - child.getMeasuredHeight(); } @Override public int getViewHorizontalDragRange(View child) { return getMeasuredWidth() - child.getMeasuredWidth(); }</code></pre> </li> </ol> <p><strong>二、释放后自动贴边</strong></p> <p>需要监听手指“释放”被拖拽 View 的事件,可以重写 ViewDragHelper.Callback 的 onViewReleased 方法。</p> <p>我们观察下,自动贴边是根据当前 View 所在的区域,决定贴在哪一个方向。这个是和产品的需求有关,以下代码仅供参考:</p> <pre> <code class="language-java">@Override public void onViewReleased(View releasedChild, float xvel, float yvel) { if (releasedChild == floatingBtn) { float x = floatingBtn.getX(); float y = floatingBtn.getY(); if (x < (getMeasuredWidth() / 2f - releasedChild.getMeasuredWidth() / 2f)) { // 0-x/2 if (x < releasedChild.getMeasuredWidth() / 3f) { x = 0; } else if (y < (releasedChild.getMeasuredHeight() * 3)) { // 0-y/3 y = 0; } else if (y > (getMeasuredHeight() - releasedChild.getMeasuredHeight() * 3)) { // 0-(y-y/3) y = getMeasuredHeight() - releasedChild.getMeasuredHeight(); } else { x = 0; } } else { // x/2-x if (x > getMeasuredWidth() - releasedChild.getMeasuredWidth() / 3f - releasedChild.getMeasuredWidth()) { x = getMeasuredWidth() - releasedChild.getMeasuredWidth(); } else if (y < (releasedChild.getMeasuredHeight() * 3)) { // 0-y/3 y = 0; } else if (y > (getMeasuredHeight() - releasedChild.getMeasuredHeight() * 3)) { // 0-(y-y/3) y = getMeasuredHeight() - releasedChild.getMeasuredHeight(); } else { x = getMeasuredWidth() - releasedChild.getMeasuredWidth(); } } // 移动到x,y dragHelper.smoothSlideViewTo(releasedChild, (int) x, (int) y); invalidate(); } }</code></pre> <p>根据你的产品的需求(上面模仿了iPhone的悬浮球),计算好最终的 x 和 y ,然后使用 ViewDragHelper 的 smoothSlideViewTo 方法,将 View 移动到指定位置。</p> <p><strong>三、解决UI刷新,恢复到原始位置的问题</strong></p> <p>这个问题在做Demo的时候并没有遇到,但当集成到项目中的时候,就出现了这个问题,如下图:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/53d2f98c50316f9ff57fb6d39ff0449b.gif"></p> <p style="text-align:center">move</p> <p>首页点击某个 <strong>Item</strong> 展开( ExpandableListView )或者切换底部 <strong>Tab</strong> ( Fragment 显示与隐藏),“悬浮球”会恢复到原始的位置,我们来分析下为什么?</p> <p>我们先来简单分析下 ViewDragHelper 的部分源码实现。</p> <p>从 smoothSlideViewTo 这个方法切入,该方法内部的实现如下:</p> <p><img src="https://simg.open-open.com/show/2b9294b5588f0ffdd8478bab04105bbe.png"></p> <p style="text-align:center">smoothSlideViewTo</p> <p>545行, forceSettleCapturedViewAt 方法</p> <p><img src="https://simg.open-open.com/show/63cbaced33a5e5a6275c8c744999cd18.png"></p> <p style="text-align:center">forceSettleCapturedViewAt</p> <p>600行,使用 Scroller 来实现 View 的位置滑动,熟悉 Scroller 的同学应该都知道,需要在自定义 ViewGroup 的 computeScroll 方法做处理</p> <pre> <code class="language-java">@Override public void computeScroll() { if (dragHelper.continueSettling(true)) { invalidate(); } }</code></pre> <p>关键代码在 if 语句的 continueSettling 方法:</p> <p><img src="https://simg.open-open.com/show/d6b9f2a2685e1cbb0d2082642fb34136.png"></p> <p style="text-align:center">continueSettling</p> <p>733、736行,使用 offsetLeftAndRight 和 offsetTopAndBottom 来设置 View 的位置,这个方法与 View 的 setX 和 setY 方法有异曲同工之效。</p> <p>通过这种方式,的确是真实改变了 View 的 x 和 y 坐标。但是,当UI刷新后,我们自定义的 ViewGroup 的 onMeasure 、 onLayout 等方法会被调用,我们都知道 onLayout 方法直接决定了子 View 的位置。</p> <p>但是 onLayout 方法是不会根据子 View 的 x 和 y 来排列它的位置,而是根据 LayoutParams 来决定,关键源码如下:</p> <pre> <code class="language-java">@Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { layoutChildren(left, top, right, bottom, false /* no force left gravity */); } void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) { final int count = getChildCount(); final int parentLeft = getPaddingLeftWithForeground(); final int parentRight = right - left - getPaddingRightWithForeground(); final int parentTop = getPaddingTopWithForeground(); final int parentBottom = bottom - top - getPaddingBottomWithForeground(); for (int i = 0; i < count; i++) { final View child = getChildAt(i); if (child.getVisibility() != GONE) { final LayoutParams lp = (LayoutParams) child.getLayoutParams(); ... final int width = child.getMeasuredWidth(); final int height = child.getMeasuredHeight(); ... int childLeft; int childTop; ... childLeft = parentLeft + lp.leftMargin; ... childTop = parentTop + lp.topMargin; ... child.layout(childLeft, childTop, childLeft + width, childTop + height); } } }</code></pre> <p>所以,我们的解决方案很简单,就是重写 ViewGroup 的 onLayout 方法,设置被拖拽 View 的位置:</p> <pre> <code class="language-java">@Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); restorePosition(); } // 记录最后的位置 float mLastX = -1; float mLastY = -1; public void restorePosition() { if (mLastX == -1 && mLastY == -1) { // 初始位置 mLastX = getMeasuredWidth() - floatingBtn.getMeasuredWidth(); mLastY = getMeasuredHeight() * 2 / 3; } floatingBtn.layout((int)mLastX, (int)mLastY, (int)mLastX + floatingBtn.getMeasuredWidth(), (int)mLastY + floatingBtn.getMeasuredHeight()); }</code></pre> <p>mLastX 和 mLastY 是用来记录“悬浮球”最后的位置,需要在 ViewDragHelper.Callback 的 onViewPositionChanged 方法中处理</p> <pre> <code class="language-java">@Override public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { super.onViewPositionChanged(changedView, left, top, dx, dy); mLastX = changedView.getX(); mLastY = changedView.getY(); }</code></pre> <p>只要“悬浮球”的位置发生变化,就会回调这个方法。</p> <p>四、提供统一入口给所有Activity</p> <p>基本所有项目都会有一个 BaseActivity (如果没有,只能呵呵了~),重写 setContentView 方法,统一接入我们的“悬浮球”:</p> <pre> <code class="language-java">public class BaseActivity extends AppCompatActivity{ ... @Override public void setContentView(int layoutResID) { super.setContentView(new FloatingDragger(this, layoutResID).getView()); } ... }</code></pre> <p>这样,所有 Activity 的代码可以保持不变,只要继承自 BaseActivity ,就会拥有“悬浮球”功能,所有业务全部封装在 FloatingDragger 这个类中。</p> <p>五、所有Activity保持“实时”位置一致</p> <p>FloatingDragger 这个类,实际上是在 Activity 原有的布局 layoutResID 之上添加了一个 View ,也就是我们的“悬浮球”,所以每个 Activity 都拥有一个不同的 FloatingDragger 对象。</p> <p>我们可以实时保存“悬浮球”的位置,这样每次重新打开APP,“悬浮球”总会在上次的位置。如果进入下一个 Activity2 ,它的位置也总是和上一个 Activity1 一致。这个实现比较简单,将上文的 mLastX 和 mLastY 存储到配置文件</p> <pre> <code class="language-java">@Override public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { super.onViewPositionChanged(changedView, left, top, dx, dy); int x = changedView.getX(); int y = changedView.getY(); spdbHelper.putFloat(KEY_FLOATING_X, x); spdbHelper.putFloat(KEY_FLOATING_Y, y); }</code></pre> <p>然后位置从配置文件读取</p> <pre> <code class="language-java">public void restorePosition() { float x = spdbHelper.getFloat(KEY_FLOATING_X, -1); float y = spdbHelper.getFloat(KEY_FLOATING_Y, -1); if (x == -1 && y == -1) { // 初始位置 x = getMeasuredWidth() - floatingBtn.getMeasuredWidth(); y = getMeasuredHeight() * 2 / 3; } floatingBtn.layout((int)x, (int)y, (int)x + floatingBtn.getMeasuredWidth(), (int)y + floatingBtn.getMeasuredHeight()); }</code></pre> <p>但是,如果你在 Activity2 改变了位置,怎么让 Activity1 “悬浮球”的位置也刷新呢?</p> <p>这里有两种方案:</p> <ol> <li>BaseActivity 的 onResume 调用 FloatingDragger 对象的某个方法</li> <li>FloatingDragger 内部实现</li> </ol> <p>方法1比较简单,这里不做演示。另外,显然方案2也更好一点,因为和 Activity 的耦合度更低,比较符合“封装”的思想。</p> <p>我们思考下, FloatingDragger 对所有“悬浮球”位置的改变感兴趣,似乎比较符合设计模式中的 <strong>观察者模式</strong> , FloatingDragger 是 <strong>观察者</strong> , <strong>被观察者</strong> 是一个单例 PositionObservable ,“悬浮球”位置发生变化后通过 PositionObservable 通知所有的 FloatingDragger 对象。</p> <p>被观察者:</p> <pre> <code class="language-java">public class PositionObservable extends Observable { public static PositionObservable sInstance; public static PositionObservable getInstance() { if (sInstance == null) { sInstance = new PositionObservable(); } return sInstance; } /** * 通知观察者FloatingDragger */ public void update() { setChanged(); notifyObservers(); } }</code></pre> <p>观察者:</p> <pre> <code class="language-java">public class FloatingDragger implements Observer { PositionObservable observable = PositionObservable.getInstance(); FloatingDraggedView floatingDraggedView; public FloatingDragger(Context context, @LayoutRes int layoutResID) { // 用户布局 View contentView = LayoutInflater.from(context).inflate(layoutResID, null); // 悬浮球按钮 View floatingView = LayoutInflater.from(context).inflate(R.layout.layout_floating_dragged, null); // ViewDragHelper的ViewGroup容器 floatingDraggedView = new FloatingDraggedView(context); floatingDraggedView.addView(contentView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); floatingDraggedView.addView(floatingView, new FrameLayout.LayoutParams(APKUtil.dip2px(context, 45), APKUtil.dip2px(context, 40))); // 添加观察者 observable.addObserver(this); } .... @Override public void update(Observable o, Object arg) { if (floatingDraggedView != null) { // 更新位置 floatingDraggedView.restorePosition(); } } public class FloatingDraggedView extends FrameLayout { ... public FloatingDraggedView(Context context) { super(context); init(); } void init() { dragHelper = ViewDragHelper.create(FloatingDraggedView.this, 1.0f, new ViewDragHelper.Callback() { @Override public void onViewDragStateChanged(int state) { super.onViewDragStateChanged(state); if (state == ViewDragHelper.STATE_SETTLING) { // 拖拽结束,通知观察者 observable.update(); } } ... } } ... } ... }</code></pre> <p>ViewDragHelper.Callback 的 onViewDragStateChanged 方法,在 View 被拖动的时候会回调三次,分别对应三个状态</p> <ul> <li>STATE_IDLE:空闲</li> <li>STATE_DRAGGING:正在拖拽</li> <li>STATE_SETTLING:拖拽结束,放置View</li> </ul> <p><strong>写在最后</strong></p> <p>2016年转眼就要过去了,回忆这一年,自己从一家外包公司,跳槽到一家创业公司。以前在外包公司职责是移动端负责人(数十人的移动团队),外包公司项目的周期非常短,压力非常大,移动端一年至少8-10个项目,自己也是全程参与Android端的代码开发,同时还要负责业务以及和后台的API对接工作,另外还要管理iOS团队(因为精力问题,这点做的不是太合格,需要检讨)。</p> <p>另外,经常还要和销售一起出去面对客户谈需求,不得不说外包公司虽然累了点,但是做为过来人,我还是要告诉大家, <strong>“公司是别人的,学到东西才是自己的”</strong> 。所以,刚毕业的小伙伴,或者正在找工作的同学,没有必要太“歧视”或看不上外包公司,毕竟学习技术还是要靠自己,再好的公司,你如果是一颗螺丝钉还不如在小公司多负责、多做点东西,这样才能在工作中成长,在学习中进步。</p> <p> </p> <p> </p> <p>来自:http://www.jianshu.com/p/d2c80e7e584e</p> <p> </p>