Android 可拖动悬浮窗实现

最近公司的项目里,需要通过悬浮窗进行控制,悬浮窗根据手势进行拖动。当时同事给的建议用 ViewDragHelper 来实现(原谅没玩过这个东西,网上看了下教程挺牛逼的,算了,还是选择用手势监听做吧),首先先给大伙看下最终的项目实现效果(模拟器上可能会卡顿,实际的运行效果还是很流畅的)。当然,最后我也不会把公司项目代码分享给大家伙,这里就给大家讲解下实现的思路。


项目最终效果

看完效果图,希望你能有点感兴趣,然后就开始上代码啦~,首先通过 WindowManager 添加一个指示的 indicatorView(就是侧边红色的条),用来提示用户通过这边进行拖动悬浮窗,接着在手指在 indicatorView 按下的时候,添加一个空的 RelativeLayout,作为悬浮窗的 rootview,然后往 rootview 添加悬浮窗内容 contentView,通过 layout 方法,改变 contentView 的布局参数,也可以通过 LayoutParam 来设置,实现最终的效果。可能文字表达不够明确,贴一张手绘原理图
原理图

接下来就是代码一波流了,首先定义一个手势监听回调类,主要用来判断 indicatorView 的滑动的距离以及方向,然后悬浮窗可以根据 indicatorView 的滑动方向进行拖动

public abstract class OnFlingListener {
    // 手指按下
    public void onFingerDown() {
    }

    // 手指抬起
    public void onFingerUp(int slideDirection) {
    }

    // 手势上滑
    public void onScrollUp(float scrollY) {
    }

    // 手势下滑
    public void onScrollDown(float scrollY) {
    }

    // 手势左滑
    public void onScrollLeft(float scrollX) {
    }

    // 手势右滑
    public void onScrollRight(float scrollX) {
    }
}

定义完手势回调,就需要定义用来监听拖动手势的 indicatorView 啦,其主要作用是当焦点落到 indicatorView 的时候,通过用户的手势来拖动悬浮窗活动,这个可以根据自己的喜好进行编写

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="@drawable/floating_bar_outside">

    <TextView
        android:id="@+id/touch_view"
        android:layout_width="200dp"
        android:layout_height="5dp"
        android:background="@color/colorAccent"
        android:clickable="true"
        android:focusable="true" />
</LinearLayout>

方便起见,我这边用 TextView 来作为 indicatorView,做好准备工作就要开始编写实际的操作逻辑啦,首先定义一个内部手势类,用来实现操作逻辑

class FloatViewOnGestureListener extends GestureDetector.SimpleOnGestureListener {
        // 回调类
        private OnFlingListener mFlingListener;
        
        // ..... 省略部分无关代码

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            float x = e2.getX() - e1.getX();
            float y = e2.getY() - e1.getY();
            float x_abs = Math.abs(x);
            float y_abs = Math.abs(y);

            scrollX = e2.getX();
            scrollY = e2.getY();

            // x > y 且 x 滑动距离大于阀值,则是水平滑动,否则是垂直滑动
            if (x_abs >= y_abs && x_abs > X_SLOP) {
                // 如果 x 的滑动距离大于 0 则是右滑,否则为左滑
                if (x > 0 && mFlingListener != null) {
                    mOnFlingListener.onScrollRight(x_abs);
                } else if (x < 0 && mFlingListener != null) {
                    mOnFlingListener.onScrollLeft(x_abs);
                }
                // 用来记录抬手前的最后一下是左滑还是右滑,最后通过回调函数传回
                mDirection = (scrollX - lastScrollX) >= 0 ? DIRECTION_RIGHT : DIRECTION_LEFT;
            } else if (y_abs >= x_abs && y_abs > Y_SLOP) {
                // 如果 y 的滑动距离大于 0 则是下滑, 否则上滑
                if (y > 0 && mFlingListener != null) {
                    mOnFlingListener.onScrollDown(y_abs);
                } else if (y < 0 && mFlingListener != null) {
                    mOnFlingListener.onScrollUp(y_abs);
                }
                // 用来记录抬手前最后一下是上滑还是下滑
                mDirection = (scrollY - lastScrollY >= 0) ? DIRECTION_DOWN : DIRECTION_UP;
            }

            lastScrollX = scrollX;
            lastScrollY = scrollY;

            return super.onScroll(e1, e2, distanceX, distanceY);
        }
    }

这里通过两次手指的位置,来判断当前手指的滑动方向。通过比较 x 轴的移动距离和 y 的移动距离,判断是上下滑动还是左右滑动,然后通过滑动的距离是否大于 0 判断滑动的方向,因为当你的 indicatorView 在右侧的时候,如果初始滑动距离大于 0 的话,根本就是不可能的。最后还需要判断最后一下手指的滑动方向,如果和初始的方向相反,则需要将拖出来的悬浮窗自动回滚到初始状态。

接着就需要实现对的 indicatorView 做手势监听

mTouchView.setOnTouchListener(new OnTouchListener() {
    @Override
        public boolean onTouch(View v, MotionEvent event) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    // 按下时候震动提醒
                    mVibrator.vibrate(50);
                    mGestureDetector.onTouchEvent(event);
                    if (mOnFlingListener != null)
                        mOnFlingListener.onFingerDown();
                    break;

                case MotionEvent.ACTION_MOVE:
                    mGestureDetector.onTouchEvent(event);
                    break;

                case MotionEvent.ACTION_UP:
                    if (mOnFlingListener != null)
                        // 将最后的滑动方向通过回调传出
                        mOnFlingListener.onFingerUp(mDirection);
                        // 抬手的时候注意把 direction 回归初始状态
                    mDirection = NO_ACTION;
            }
            return true;
        }
    });

当手指按下的时候,做下震动,用于提示作用,然后根据不同的手势操作,做相应的回调,当抬手指的时候,记得需要将手势方向设置回初始值,OK,indicatorView 的内容大概就那么多,具体的操作,需要通过悬浮窗 FloatWindow 去实现。在实现逻辑之前,因为整体都在悬浮窗上实现,需要定义悬浮窗内容的一些必要属性,因为 indicatorView 和 rootView 的属性差不多,所以只列出 indicatorView 的属性列表,具体的可以看 demo

mParams = new WindowManager.LayoutParams();
mParams.packageName = FloatingApplication.getContext().getPackageName();
mParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
mParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
// 当悬浮窗显示的时候可以获取到焦点
mParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
    | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR
    | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;

// 需要适配 8.0,当 8.0 以上的版本
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    mParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
} else {
    mParams.type = WindowManager.LayoutParams.TYPE_PHONE;
}
mParams.format = PixelFormat.RGBA_8888;

flag 作用主要是让悬浮窗能够获取到焦点,能够消费点击等事件,还需要注意的是,在 8.0 之后的版本,悬浮窗 type 只能使用 TYPE_APPLICATION_OVERLAY 之前的 type 都过时无效了。定义完属性就需要定义 indicatorView , rootview 以及悬浮窗具体的内容 contentView

 public void initFloatView() {
        mFloatView = new FloatView(BaseApplication.getContext(), new OnFlingListener() {
            @Override
            public void onFingerDown() {
                // 添加 rootview,如果已经存在了,直接根据 params 进行更新布局就行,如果不存在就添加
                try {
                    mWindowManager.updateViewLayout(mContainer, mContainerParams);
                } catch (Exception e) {
                    e.printStackTrace();
                    mWindowManager.addView(mContainer, mContainerParams);
                    // 判断悬浮窗是否已经显示的标志位
                    isCenterShow = true;
                }
                // 根据布局获取悬浮窗 contentView
                mContentView = LayoutInflater.from(mContext).inflate(R.layout.content_view, null);
                // 悬浮窗 contentView 布局属性
                RelativeLayout.LayoutParams contentLp = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
                mContentView.setLayoutParams(contentLp);
                // 刚开始添加的时候设置不可见,因为设置了 LayoutParam 是 MATCH_PARENT,等拖动操作再显示即可
                mContentView.setVisibility(View.INVISIBLE);
                mContainer.addView(mContentView);
                // 根据滑动的方向,设置最开始的布局位置
                switch (mSlideType) {
                    // 从右往左滑动,悬浮窗内容全部位于屏幕的右侧,所以此时的 left, right 属性都是屏幕的宽度
                    case SLIDE_RIGHT_TO_LEFT:
                        mContentView.layout(mScreenWidth, 0, mScreenWidth, mScreenHeight);
                        break;
                    // ...省略其他方向的,原理类似,具体看 demo...
                }
                
                // 设置 contentView 空白处的点击事件,点击消失,根据具体的滑动方向做不同的动画效果
                mContentView.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        switch (mSlideType) {
                            case SLIDE_RIGHT_TO_LEFT:
                                rightInSmoothToRight();
                                break;
                            // ...省略其他方向的,原理类似,具体看 demo...
                        }
                    }
                });
            }

            @Override
            public void onFingerUp(int direction) {
                // 当手指抬起来的时候,根据最后一下手势,即 direction 返回的值,判断滑动的方向,选择 contentView 是否显示出来,还是回退不显示,然后做不同的动画
                switch (mSlideType) {
                     // 右 -> 左
                    case SLIDE_RIGHT_TO_LEFT:
                        if (direction == FloatView.DIRECTION_LEFT) {
                            // 右侧进入,滑到左侧展开悬浮窗内容的动画
                            rightInSmoothToLeft();
                        } else {
                            // 右侧进入,滑到右侧回到初始状态的动画
                            rightInSmoothToRight();
                        }
                        break;
                    // ...省略其他方向的,原理类似,具体看 demo...
                }
            }

            @Override
            public void onScrollLeft(float scrollX) {
                // 从右侧滑到左侧,根据手势滑动的距离,不断改变 left 的属性值
                if (mSlideType == SLIDE_RIGHT_TO_LEFT) {
                    mContentView.layout(mScreenWidth - (int) scrollX, 0, mScreenWidth, mScreenHeight);
                    mContentView.setVisibility(View.VISIBLE);
                }
            }
        });
    }

这里代码会比较多,适当的分析下,首先,当手指按下的时候,需要先把 rootview 放到 windowManager 中,因为是透明的,所以看上去就是桌面的内容,然后需要去取得 contentView 并放到 rootview 上,因为一开始不能显示 contentView,所以设置 contentView 的不可见,然后,根据 indicatorView 的位置,设置 contentView 的位置属性,例如 indicatorView 在屏幕的右侧,那么 contentView 也必须在屏幕的右侧,当拖动 indicatorView 的时候再慢慢的显示 contentView 就实现了拖动效果,所以 contentView 一开始 layout 的位置就是 (mScreenWidth, 0, mScreenWidth, mScreenHeight),其他的方向同理。然后根据手势的滑动方向和距离,通过动画不断去改变 contentView 的 layout 属性,并将 contentView 从不可见设置为可见,给用户的感觉就有将悬浮窗一点点拖出来的效果了。等到悬浮窗完全展示的时候,点击空白的地方,悬浮窗又需要从当前的位置回滚到初始的位置,其原理和拖出来的原理是一样的。通过如上代码可以发现,contentView 的 layout 属性变化都是通过动画来实现的,这边我采用属性动画,来不断改变滑动的距离来实现悬浮窗显示和隐藏的效果,也就是就是上面代码中的 rightInSmoothToLeftrightInSmoothToRight动画的实现,不多解释,直接上代码和注释

/**
 * 右侧滑进,滑到页面左侧,进入动画
 */
private void rightInSmoothToLeft() {
    int posX = mScreenWidth - mContentView.getWidth();
    // 通过属性动画做最后的效果,右侧滑进到左侧,contentView 的页面从右侧开始向左侧滑动显示,那么 right 始终保持是屏幕的宽度不变,改变的是 left 属性,
    //从屏幕宽的值一直改变到 0,那属性动画的间隔就出来了,时间设置整体的滑动为 300 ms,那么剩下的距离需要的滑动时间就是 300 * posX / mScreenWidth
    ValueAnimator slideLeftAnim = ValueAnimator.ofInt(posX, 0).setDuration(300 * posX / mScreenWidth);
    slideLeftAnim.setInterpolator(new AccelerateDecelerateInterpolator());
    slideLeftAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            // 根据变化的值,重新设置 contentView 的布局
            int pos = (int) animation.getAnimatedValue();
            mContentView.layout(pos, 0, mScreenWidth, mScreenHeight);
        }
    });

    slideLeftAnim.start();
}

 /**
  * 右侧滑进,滑到页面右侧,退出动画
  */
  private void rightInSmoothToRight() {
      int posX = mScreenWidth - mContentView.getWidth();
      // 同理,退出的动画就是 contentView 从当前的 left 的值一直变换到屏幕宽的值,也可以得到相应动画
      ValueAnimator slideRightAnim = ValueAnimator.ofInt(posX, mScreenWidth).setDuration(300 * (mScreenWidth - posX) / mScreenWidth);
      slideRightAnim.setInterpolator(new AccelerateDecelerateInterpolator());
      slideRightAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
          @Override
          public void onAnimationUpdate(ValueAnimator animation) {
             // 根据变化的值,重新设置 contentView 的布局
              int pos = (int) animation.getAnimatedValue();
              mContentView.layout(pos, 0, mScreenWidth, mScreenHeight);
          }
      });

      slideRightAnim.addListener(new AnimatorListenerAdapter() {
          @Override
          public void onAnimationEnd(Animator animation) {
              super.onAnimationEnd(animation);
              // 退出动画结束的时候,需要把 rootview 从 WindowManager 移除
              dismissContentView();
          }
      });
      slideRightAnim.start();
}

到现在为止,我们已经搞定所有的逻辑,就差将 indicatorView 显示到视图上就大功告成了,通过一个 show 方法将显示的逻辑放到外部的 Activity 或者 Service 调用

/**
 * 显示 indicatorView
 */
public void show() {
    try {
        mWindowManager.updateViewLayout(mFloatView, mParams);
    } catch (IllegalArgumentException e) {
        mWindowManager.addView(mFloatView, mParams);
    }
}

在调用 show 方法之前,如果版本大于 23 需要检测悬浮窗权限才行,检测的方法很简单

public static boolean hasOverlayPermission(Context context) {
        return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(context);
    }

如果没有同意悬浮窗权限,则需要引导用户打开,我这边通过一个 dialog 实现引导

private void overlayPermissionRequest() {
        mOverlayAskDialog = new AlertDialog.Builder(MainActivity.this)
                .setTitle("Overlay Permission Request")
                .setMessage("Need Overlay Permission")
                .setPositiveButton("Yes", new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                            Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName()));
                            startActivityForResult(intent, OVERLAY_PERMISSION_REQUEST);
                        }
                        mOverlayAskDialog.dismiss();
                    }
                })
                .setCancelable(false)
                .create();
        mOverlayAskDialog.show();
    }

当跳转权限页面的时候最好用 startActivityForResult 方法,当用户从权限设置页面回来的时候,通过 onActivityResult 方法再去检测一次是否真正同意了权限,如果还是未同意,那就再次引导用户去同意权限。这里附上 demo 的效果,虽然和实际项目的效果还是有差别,但是核心思想在这了

手势滑动悬浮框

最后双手捧上源码 悬浮窗源码,望大爷笑纳~

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

推荐阅读更多精彩内容