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();
相关推荐
朝花不迟暮2 小时前
Go基础-闭包
android·开发语言·golang
好好研究2 小时前
Git - tag标签和Git图像化界面
git·gitee
风清云淡_A3 小时前
【Android36】android开发实战案列之RecyclerView组件的使用方法
android
we1less3 小时前
Android-HAL (四) AIDL
android
Android技术之家4 小时前
2026 Android开发五大趋势:AI原生、多端融合、生态重构
android·重构·ai-native
龚礼鹏4 小时前
图像显示框架七——createSurface的流程(基于Android 15源码分析)
android
聆风吟º4 小时前
【Spring Boot 报错已解决】Spring Boot项目启动报错 “Main method not found“ 的全面分析与解决方案
android·spring boot·后端
Rysxt_5 小时前
Kotlin前景深度分析:市场占有、技术优势与未来展望
android·开发语言·kotlin
莫白媛5 小时前
Android开发之Kotlin 在 Android 开发中的全面指南
android·开发语言·kotlin