20、悬浮窗实现实战:从零实现一个可拖拽的悬浮球

悬浮球,这东西大家手机里应该都见过。不管是手机厂商自带的一键加速,还是各种工具类App的快捷操作,背后都是WindowManager在撑腰。

今天我们就手撸一个可拖拽的悬浮球。别小看这个功能,里面坑不少。我当年第一次做的时候,就踩了触摸事件分发的坑,折腾了一下午才搞定。

20.1 悬浮窗的核心原理

说白了,悬浮窗就是往系统窗口上添加一个View。这个View不属于你的Activity,它直接挂在WindowManager上。

你需要三个东西:

  • WindowManager —— 系统窗口管理者
  • WindowManager.LayoutParams —— 窗口参数配置
  • 一个View —— 你想显示的内容

嗯,就这么简单。但权限问题是个坎儿。

⚠️ 权限警告:Android 6.0+ 需要动态申请 SYSTEM_ALERT_WINDOW 权限。部分国产ROM还有自己的白名单机制,申请了也不一定能弹出来。

20.2 权限申请与检查

先检查有没有悬浮窗权限:

public boolean hasOverlayPermission(Context context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        return Settings.canDrawOverlays(context);
    }
    return true;
}

如果没有,跳转到设置页面让用户手动开启:

if (!hasOverlayPermission(this)) {
    Intent intent = new Intent(
        Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
        Uri.parse("package:" + getPackageName())
    );
    startActivity(intent);
}

我曾经在一个项目里忘了处理权限回调,结果用户开了权限回来,悬浮球还是没出来。排查了半天才发现是没在onResume里重新检查权限状态。

💡 小技巧:在 onActivityResultonResume 中重新检查权限,确保用户授权后能立即显示悬浮窗。

20.3 创建悬浮球View

悬浮球本身就是一个普通的ImageView,但我们要让它能拖拽,所以得处理触摸事件。

public class FloatBallView extends View {

    private WindowManager windowManager;
    private WindowManager.LayoutParams params;
    private float lastX, lastY;
    private boolean isDragging = false;

    public FloatBallView(Context context, WindowManager wm, WindowManager.LayoutParams lp) {
        super(context);
        this.windowManager = wm;
        this.params = lp;
        setBackgroundResource(R.drawable.float_ball_bg); // 一个圆形背景
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float x = event.getRawX();
        float y = event.getRawY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                isDragging = false;
                break;

            case MotionEvent.ACTION_MOVE:
                float dx = x - lastX;
                float dy = y - lastY;
                if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
                    isDragging = true;
                }
                params.x += (int) dx;
                params.y += (int) dy;
                windowManager.updateViewLayout(this, params);
                lastX = x;
                lastY = y;
                break;

            case MotionEvent.ACTION_UP:
                if (!isDragging) {
                    performClick(); // 处理点击事件
                }
                // 吸附到边缘(可选)
                snapToEdge();
                break;
        }
        return true;
    }
}

这里有个细节:getRawX()getRawY() 获取的是屏幕绝对坐标,而 getX() 是相对坐标。用绝对坐标才能正确计算偏移量。

20.4 添加到WindowManager

准备好了View和参数,接下来就是添加到系统窗口:

private void showFloatBall() {
    if (floatBall != null) return;

    WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);

    WindowManager.LayoutParams params = new WindowManager.LayoutParams(
        WindowManager.LayoutParams.WRAP_CONTENT,
        WindowManager.LayoutParams.WRAP_CONTENT,
        // 注意:不同API级别type不同
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ?
            WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY :
            WindowManager.LayoutParams.TYPE_PHONE,
        WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
        PixelFormat.TRANSLUCENT
    );

    params.gravity = Gravity.TOP | Gravity.START;
    params.x = 0;
    params.y = 200;

    floatBall = new FloatBallView(this, wm, params);
    wm.addView(floatBall, params);
}
🔑 关键点TYPE_APPLICATION_OVERLAY 是Android 8.0引入的,替代了废弃的 TYPE_PHONE。使用新type可以避免一些兼容性问题。

20.5 拖拽逻辑的坑与优化

你以为拖拽就这么简单?天真了。实际项目中会遇到几个问题:

  • 触摸事件冲突:如果悬浮球上还有子View,事件会被拦截。解决方案是用 onInterceptTouchEvent 控制。
  • 边界越界:拖到屏幕外面去了怎么办?需要在 ACTION_MOVE 里做边界限制。
  • 性能问题:频繁调用 updateViewLayout 会导致UI卡顿。可以加一个阈值,比如移动超过10像素才更新。

我印象最深的一次,是在一个视频播放器里加悬浮球。结果悬浮球和播放器的手势冲突了,用户一滑动进度条,悬浮球也跟着跑。最后用了 requestDisallowInterceptTouchEvent 才解决。

20.6 吸附到边缘的效果

很多悬浮球都有自动吸附到屏幕边缘的效果。实现起来也不复杂:

private void snapToEdge() {
    DisplayMetrics metrics = new DisplayMetrics();
    windowManager.getDefaultDisplay().getMetrics(metrics);
    int screenWidth = metrics.widthPixels;

    // 判断离哪边近
    int centerX = params.x + floatBall.getWidth() / 2;
    int targetX = centerX < screenWidth / 2 ? 0 : screenWidth - floatBall.getWidth();

    // 用动画平滑移动
    ValueAnimator animator = ValueAnimator.ofInt(params.x, targetX);
    animator.addUpdateListener(animation -> {
        params.x = (int) animation.getAnimatedValue();
        windowManager.updateViewLayout(floatBall, params);
    });
    animator.setDuration(200);
    animator.start();
}

嗯,这里用了属性动画,比直接设置坐标要平滑得多。用户体验会好很多。

20.7 知识体系总览

下面这张图总结了悬浮窗实现的核心流程和关键节点:

悬浮窗实现核心流程 1. 权限检查与申请 2. 创建View + LayoutParams 3. addView 添加到WM 4. 触摸事件处理(拖拽/点击) 边界限制 吸附边缘 注意:Android 8.0+ 必须使用 TYPE_APPLICATION_OVERLAY

20.8 完整的使用示例

最后,把上面所有代码串起来,在Activity中调用:

public class MainActivity extends AppCompatActivity {

    private static final int REQUEST_OVERLAY = 1001;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if (hasOverlayPermission(this)) {
            showFloatBall();
        } else {
            requestOverlayPermission();
        }
    }

    @Override
    protected void onResume() {
        super.onResume();
        // 用户从设置页面返回后,重新检查权限
        if (hasOverlayPermission(this) && floatBall == null) {
            showFloatBall();
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == REQUEST_OVERLAY) {
            if (hasOverlayPermission(this)) {
                showFloatBall();
            } else {
                Toast.makeText(this, "需要悬浮窗权限才能使用", Toast.LENGTH_LONG).show();
            }
        }
    }
}
📌 避坑指南:我曾经在 onCreate 里直接调用 addView,结果在某些手机上崩溃了。原因是Activity还没完全初始化。建议在 onWindowFocusChangedonResume 中执行添加操作。

好了,一个可拖拽的悬浮球就完成了。代码量不大,但涉及的知识点不少:权限管理、WindowManager的使用、触摸事件处理、动画应用。把这些吃透了,以后做类似的功能就轻车熟路了。


公众号:蓝海资料掘金营,微信deep3321