ViewDragHelper实战:APP内“悬浮球”

本文的理论知识是基于:Android自定义ViewGroup神器-ViewDragHelper,如果你对ViewDragHelper的使用不熟悉,请先阅读这篇文章。

前言

“悬浮球”最初是iPhone手机上的一个虚拟按键,它会悬浮于所有APP之上,手指随意拖动,松开后会自动贴边显示。现在满大街都是iPhone手机,相信大家都用过或者看过这个效果,这里就不上图了~

当前,很多Android手机也都有了这个功能,并且很多第三方APP也实现了此功能,比如某垃圾清理软件。可能大家立马就会想到,这个不就是使用WindowManager实现的悬浮窗,然后在onTouch事件里面根据手指的移动来改变位置吗?

确实,如果你的“悬浮球”是在桌面,实现方案的确如此(也只能如此)。但是,本文需要实现的是应用内“悬浮球”,即:退出应用不需要显示,并且我们不希望使用android.permission.SYSTEM_ALERT_WINDOW这个权限,要知道Android M 6.0此权限属于危险权限,需要动态申请授权后才能使用,且使用WindowManager实现悬浮窗 “必须”(此处有引号~)使用此权限。

上文的“必须”加引号的原因:WindowManager特定情况是可以无权限显示悬浮框的,但这不是本文讨论的范畴,感兴趣的同学可以阅读这篇文章:Android无需权限显示悬浮窗, 兼谈逆向分析app。总结来说,无权限的坑还是很多~

效果图

下面的效果图,是一款线上App新版即将发布的功能。

demo

可以看到,“悬浮球”在App内所有界面都“独立”显示,每个界面都支持拖动并自动贴边,且所有界面的“悬浮球”位置都保持一致。

实现步骤

我们将“悬浮球”实现步骤分解为以下几步:

  1. 屏幕范围内任意位置拖动
  2. 释放后自动贴边
  3. 解决UI刷新,恢复到原始位置的问题
  4. 提供统一入口给所有Activity
  5. 所有Activity保持“实时”位置一致

下面,我们就每个步骤进行分别讲解:

一、屏幕范围内任意位置拖动

我们在Android自定义ViewGroup神器-ViewDragHelper一文中已经做过详细的讲解,通过重写ViewDragHelper.Callback的以下方法实现:

  1. tryCaptureView判断View是否是我们要拖动的

    @Override
    public boolean tryCaptureView(View child, int pointerId) {    
       return child == floatingBtn;
    }
    
  2. clampViewPositionHorizontalclampViewPositionVertical,返回水平和垂直方向可移动的范围

    @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;
    }
    
  3. 如果可拖动的View是可点击的(Button or 其他),getViewHorizontalDragRangegetViewVerticalDragRange需要返回水平和垂直可移动的范围

    @Override
    public int getViewVerticalDragRange(View child) {
       return getMeasuredHeight() - child.getMeasuredHeight();
    }
    
    @Override
    public int getViewHorizontalDragRange(View child) {
       return getMeasuredWidth() - child.getMeasuredWidth();
    }
    

二、释放后自动贴边

需要监听手指“释放”被拖拽View的事件,可以重写ViewDragHelper.CallbackonViewReleased方法。

我们观察下,自动贴边是根据当前View所在的区域,决定贴在哪一个方向。这个是和产品的需求有关,以下代码仅供参考:

@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();
   }
}

根据你的产品的需求(上面模仿了iPhone的悬浮球),计算好最终的xy,然后使用ViewDragHelpersmoothSlideViewTo方法,将View移动到指定位置。

三、解决UI刷新,恢复到原始位置的问题

这个问题在做Demo的时候并没有遇到,但当集成到项目中的时候,就出现了这个问题,如下图:

move

首页点击某个Item展开(ExpandableListView)或者切换底部Tab(Fragment显示与隐藏),“悬浮球”会恢复到原始的位置,我们来分析下为什么?

我们先来简单分析下ViewDragHelper的部分源码实现。

smoothSlideViewTo这个方法切入,该方法内部的实现如下:

smoothSlideViewTo

545行,forceSettleCapturedViewAt方法

forceSettleCapturedViewAt

600行,使用Scroller来实现View的位置滑动,熟悉Scroller的同学应该都知道,需要在自定义ViewGroupcomputeScroll方法做处理

@Override
public void computeScroll() {    
   if (dragHelper.continueSettling(true)) {        
       invalidate();    
   }
}

关键代码在if语句的continueSettling方法:

continueSettling

733、736行,使用offsetLeftAndRightoffsetTopAndBottom来设置View的位置,这个方法与ViewsetXsetY方法有异曲同工之效。

通过这种方式,的确是真实改变了Viewxy坐标。但是,当UI刷新后,我们自定义的ViewGrouponMeasureonLayout等方法会被调用,我们都知道onLayout方法直接决定了子View的位置。

但是onLayout方法是不会根据子Viewxy来排列它的位置,而是根据LayoutParams来决定,关键源码如下:

@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);
        }
    }
}

所以,我们的解决方案很简单,就是重写ViewGrouponLayout方法,设置被拖拽View的位置:

@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());
}

mLastXmLastY是用来记录“悬浮球”最后的位置,需要在ViewDragHelper.CallbackonViewPositionChanged方法中处理

@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();
}

只要“悬浮球”的位置发生变化,就会回调这个方法。

四、提供统一入口给所有Activity

基本所有项目都会有一个BaseActivity(如果没有,只能呵呵了~),重写setContentView方法,统一接入我们的“悬浮球”:

public class BaseActivity extends AppCompatActivity{
  ...
  @Override
  public void setContentView(int layoutResID)
  {
      super.setContentView(new FloatingDragger(this, layoutResID).getView());
  }
  ...
}

这样,所有Activity的代码可以保持不变,只要继承自BaseActivity,就会拥有“悬浮球”功能,所有业务全部封装在FloatingDragger这个类中。

五、所有Activity保持“实时”位置一致

FloatingDragger这个类,实际上是在Activity原有的布局layoutResID之上添加了一个View,也就是我们的“悬浮球”,所以每个Activity都拥有一个不同的FloatingDragger对象。

我们可以实时保存“悬浮球”的位置,这样每次重新打开APP,“悬浮球”总会在上次的位置。如果进入下一个Activity2,它的位置也总是和上一个Activity1一致。这个实现比较简单,将上文的mLastXmLastY存储到配置文件

@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);
}

然后位置从配置文件读取

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());
}

但是,如果你在Activity2改变了位置,怎么让Activity1“悬浮球”的位置也刷新呢?

这里有两种方案:

  1. BaseActivityonResume调用FloatingDragger对象的某个方法
  2. FloatingDragger内部实现

方法1比较简单,这里不做演示。另外,显然方案2也更好一点,因为和Activity的耦合度更低,比较符合“封装”的思想。

我们思考下,FloatingDragger对所有“悬浮球”位置的改变感兴趣,似乎比较符合设计模式中的观察者模式FloatingDragger观察者被观察者是一个单例PositionObservable,“悬浮球”位置发生变化后通过PositionObservable通知所有的FloatingDragger对象。

被观察者:

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();
    }
}

观察者:

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();
                    }
                }
                ...
            }
        }
        ...
   }
   ...
}

ViewDragHelper.CallbackonViewDragStateChanged方法,在View被拖动的时候会回调三次,分别对应三个状态

  • STATE_IDLE:空闲
  • STATE_DRAGGING:正在拖拽
  • STATE_SETTLING:拖拽结束,放置View

至此,我们已经实现了功能比较完善的“悬浮球”,感谢大家耐心看到最后,本文的源码在这里:FloatingOval

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

推荐阅读更多精彩内容