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