Android 全局悬浮窗

Android 应用中添加全局悬浮窗(Floating View),需要正确处理 权限申请、窗口类型、布局参数和安全限制。


从 Android 6.0(API 23)开始,显示悬浮窗必须:

  • 动态申请 SYSTEM_ALERT_WINDOW 权限
  • 使用 WindowManager 添加视图
  • 设置正确的WindowManager.LayoutParams
  • 适配 Android 8.0+ 的 TYPE_APPLICATION_OVERLAY

添加权限声明(AndroidManifest.xml)

xml 复制代码
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

✅ 一、完整代码(可直接复制使用)

1. 悬浮窗管理类 DraggableFloatWindow.java

java 复制代码
package com.xxx.utils;

import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.PixelFormat;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.Toast;

public class DraggableFloatWindow {
    private static DraggableFloatWindow instance;
    private WindowManager windowManager;
    private View floatView;
    private Context context;
    private boolean isAdded = false;
    private int screenWidth, screenHeight;
    private int viewWidth = 300;
    private int viewHeight = 150;

    // 拖拽相关
    private int initialX, initialY;
    private float downX, downY;
    private boolean isDragging = false;

    private DraggableFloatWindow(Context context) {
        this.context = context.getApplicationContext();
        this.windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        initScreenSize();
    }

    public static synchronized DraggableFloatWindow getInstance(Context context) {
        if (instance == null) {
            instance = new DraggableFloatWindow(context);
        }
        return instance;
    }

    private void initScreenSize() {
        DisplayMetrics metrics = new DisplayMetrics();
        windowManager.getDefaultDisplay().getRealMetrics(metrics); // 获取真实屏幕尺寸(含状态栏)
        screenWidth = metrics.widthPixels;
        screenHeight = metrics.heightPixels;
    }

    public boolean checkPermission() {
        return Settings.canDrawOverlays(context);
    }

    public void requestPermission(Activity activity, int requestCode) {
        if (!checkPermission()) {
            Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
                    Uri.parse("package:" + context.getPackageName()));
            activity.startActivityForResult(intent, requestCode);
        }
    }

    private void createFloatView() {
        if (floatView == null) {
            floatView = LayoutInflater.from(context).inflate(R.layout.float_window_draggable, null);
            
            Button btn = floatView.findViewById(R.id.btn_float);
            btn.setOnClickListener(v -> {
                Toast.makeText(context, "悬浮窗被点击!", Toast.LENGTH_SHORT).show();
            });
            floatView.postDelayed(new Runnable() {
                @Override
                public void run() {
                    viewWidth = floatView.getWidth();
                    viewHeight = floatView.getHeight();
                }
            },500);
            setupDragListener();
        }
    }

    private void setupDragListener() {
        floatView.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN:
                        isDragging = false;
                        downX = event.getRawX();
                        downY = event.getRawY();
                        // 获取当前视图位置
                        WindowManager.LayoutParams params = (WindowManager.LayoutParams) floatView.getLayoutParams();
                        initialX = params.x;
                        initialY = params.y;
                        break;
                        
                    case MotionEvent.ACTION_MOVE:
                        isDragging = true;
                        float moveX = event.getRawX();
                        float moveY = event.getRawY();
                        int deltaX = (int) (moveX - downX);
                        int deltaY = (int) (moveY - downY);
                        
                        // 计算新位置
                        int newX = initialX + deltaX;
                        int newY = initialY + deltaY;
                        
                        // 应用边界限制
                        newX = applyXBoundary(newX);
                        newY = applyYBoundary(newY);
                        
                        // 更新视图位置
                        WindowManager.LayoutParams params = (WindowManager.LayoutParams) floatView.getLayoutParams();
                        params.x = newX;
                        params.y = newY;
                        windowManager.updateViewLayout(floatView, params);
                        break;
                        
                    case MotionEvent.ACTION_UP:
                        if (isDragging) {
                            // 拖拽结束时吸附到最近边缘
                            snapToEdge();
                        }
                        break;
                }
                // 如果是点击事件(非拖拽),不消费事件,让按钮点击生效
                return isDragging;
            }
        });
    }

    // X轴边界限制:确保视图完全在屏幕内
    private int applyXBoundary(int x) {
        // 左边界:x >= 0
        if (x < 0) return 0;
        // 右边界:x <= screenWidth - viewWidth
        if (x > screenWidth - viewWidth) return screenWidth - viewWidth;
        return x;
    }

    // Y轴边界限制:确保视图完全在屏幕内
    private int applyYBoundary(int y) {
        // 上边界:y >= 0
        if (y < 0) return 0;
        // 下边界:y <= screenHeight - viewHeight
        if (y > screenHeight - viewHeight) return screenHeight - viewHeight;
        return y;
    }

    // 吸附到最近的屏幕边缘
    private void snapToEdge() {
        WindowManager.LayoutParams params = (WindowManager.LayoutParams) floatView.getLayoutParams();
        int centerX = params.x + viewWidth / 2;
        
        if (centerX < screenWidth / 2) {
            // 吸附到左边
            params.x = 0;
        } else {
            // 吸附到右边
            params.x = screenWidth - viewWidth;
        }
        
        // Y轴保持原位置(也可选择吸附到顶部/底部)
        windowManager.updateViewLayout(floatView, params);
    }

    public void showFloatWindow() {
        if (!checkPermission()) {
            Toast.makeText(context, "请先开启悬浮窗权限", Toast.LENGTH_SHORT).show();
            return;
        }

        if (isAdded) return;
        createFloatView();

        try {
            WindowManager.LayoutParams params = new WindowManager.LayoutParams();
            
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
            } else {
                params.type = WindowManager.LayoutParams.TYPE_PHONE;
            }

            params.format = PixelFormat.TRANSLUCENT;
            params.gravity = Gravity.TOP | Gravity.START;
            params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE |
                          WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN |
                          WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; // 允许超出屏幕(但我们自己做边界限制)

            // 初始位置:右上角
            params.x = screenWidth - viewWidth;
            params.y = 100;
            params.width = viewWidth;
            params.height = viewHeight;

            windowManager.addView(floatView, params);
            isAdded = true;
        } catch (Exception e) {
            e.printStackTrace();
            Toast.makeText(context, "显示悬浮窗失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
        }
    }

    public void hideFloatWindow() {
        if (isAdded && floatView != null) {
            try {
                windowManager.removeViewImmediate(floatView);
                isAdded = false;
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public void destroy() {
        hideFloatWindow();
        floatView = null;
        instance = null;
    }
}

2. 悬浮窗布局 res/layout/float_window_draggable.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="300dp"
    android:layout_height="150dp"
    android:orientation="vertical"
    android:background="#80000000"
    android:padding="8dp">

    <Button
        android:id="@+id/btn_float"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="拖我!\n全局悬浮窗"
        android:textColor="#FFFFFF"
        android:background="#FF4081"
        android:gravity="center"
        android:textSize="16sp" />

</LinearLayout>

3. 在 Activity 中使用

java 复制代码
public class MainActivity extends AppCompatActivity {
    private static final int REQUEST_FLOAT_CODE = 1001;
    private DraggableFloatWindow floatWindow;

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

        floatWindow = DraggableFloatWindow.getInstance(this);

        findViewById(R.id.btn_show_float).setOnClickListener(v -> {
            if (floatWindow.checkPermission()) {
                floatWindow.showFloatWindow();
            } else {
                floatWindow.requestPermission(this, REQUEST_FLOAT_CODE);
            }
        });
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == REQUEST_FLOAT_CODE) {
            if (floatWindow.checkPermission()) {
                floatWindow.showFloatWindow();
                Toast.makeText(this, "权限已开启,悬浮窗已显示", Toast.LENGTH_SHORT).show();
            } else {
                Toast.makeText(this, "权限未开启,无法显示悬浮窗", Toast.LENGTH_SHORT).show();
            }
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 注意:如果需要常驻,不要在这里 hide
        // floatWindow.hideFloatWindow();
    }
}

🎯 二、核心功能说明

1. 边界检测逻辑

java 复制代码
// X轴:0 ≤ x ≤ (screenWidth - viewWidth)
// Y轴:0 ≤ y ≤ (screenHeight - viewHeight)
  • 确保悬浮窗完全可见,不会部分移出屏幕

2. 智能吸附

  • 拖拽结束后自动吸附到左边缘或右边缘
  • 用户体验更佳(类似微信视频通话小窗)

3. 点击 vs 拖拽区分

java 复制代码
return isDragging; // 只有拖拽时才消费事件
  • 短按 → 触发按钮点击
  • 长按拖动 → 触发拖拽

4. 屏幕尺寸获取

java 复制代码
windowManager.getDefaultDisplay().getRealMetrics(metrics);
  • 使用 getRealMetrics() 获取真实屏幕尺寸(包含状态栏区域)

⚠️ 三、关键注意事项

1. Android 10+ 限制

  • 即使有权限,某些操作(如覆盖状态栏)仍被禁止
  • FLAG_LAYOUT_NO_LIMITS 在高版本可能无效,我们自己做边界限制更可靠

2. 国产 ROM 适配

厂商 额外设置
小米 设置 → 特殊权限 → 悬浮窗 → 允许应用
华为 设置 → 应用 → 特殊访问权限 → 显示在其他应用上层
OPPO 设置 → 权限管理 → 特殊权限 → 显示在其他应用上层

3. 内存泄漏防护

  • 使用 ApplicationContext
  • 提供 destroy() 方法清理资源

4. 常驻需求

  • 如需应用退出后仍显示,应在 Foreground Service 中管理
  • 添加 startForeground() 防止被系统杀死

🧪 四、测试验证

测试项 预期结果
拖拽到左边缘 自动吸附到 x=0
拖拽到右边缘 自动吸附到 x=屏幕宽度-视图宽度
拖拽到顶部 y=0
拖拽到底部 y=屏幕高度-视图高度
短按悬浮窗 显示 Toast
长按拖动 可自由移动

💡 五、优化建议

1. 添加关闭按钮

xml 复制代码
<!-- 在布局中添加 -->
<ImageButton
    android:id="@+ id/btn_close"
    android:layout_width="24dp"
    android:layout_height="24dp"
    android:src="@drawable/ic_close"
    android:background="?attr/selectableItemBackgroundBorderless" />

2. 支持多方向吸附

java 复制代码
// 同时考虑 X 和 Y 轴
if (Math.abs(params.x) < Math.abs(screenWidth - viewWidth - params.x)) {
    params.x = 0; // 左
} else {
    params.x = screenWidth - viewWidth; // 右
}

if (params.y < screenHeight / 2) {
    params.y = 0; // 上
} else {
    params.y = screenHeight - viewHeight; // 下
}

3. 动画吸附效果

java 复制代码
ValueAnimator animator = ValueAnimator.ofInt(currentX, targetX);
animator.addUpdateListener(animation -> {
    params.x = (int) animation.getAnimatedValue();
    windowManager.updateViewLayout(floatView, params);
});
animator.setDuration(200).start();
相关推荐
Kapaseker2 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
黄林晴2 小时前
Android17 为什么重写 MessageQueue
android
阿巴斯甜1 天前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker1 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95271 天前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab2 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android
Jony_2 天前
Android 启动优化方案
android
阿巴斯甜2 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android