20、悬浮窗实现实战:从零实现一个可拖拽的悬浮球
悬浮球,这东西大家手机里应该都见过。不管是手机厂商自带的一键加速,还是各种工具类App的快捷操作,背后都是WindowManager在撑腰。
今天我们就手撸一个可拖拽的悬浮球。别小看这个功能,里面坑不少。我当年第一次做的时候,就踩了触摸事件分发的坑,折腾了一下午才搞定。
20.1 悬浮窗的核心原理
说白了,悬浮窗就是往系统窗口上添加一个View。这个View不属于你的Activity,它直接挂在WindowManager上。
你需要三个东西:
- WindowManager —— 系统窗口管理者
- WindowManager.LayoutParams —— 窗口参数配置
- 一个View —— 你想显示的内容
嗯,就这么简单。但权限问题是个坎儿。
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里重新检查权限状态。
onActivityResult 或 onResume 中重新检查权限,确保用户授权后能立即显示悬浮窗。
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 知识体系总览
下面这张图总结了悬浮窗实现的核心流程和关键节点:
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还没完全初始化。建议在 onWindowFocusChanged 或 onResume 中执行添加操作。
好了,一个可拖拽的悬浮球就完成了。代码量不大,但涉及的知识点不少:权限管理、WindowManager的使用、触摸事件处理、动画应用。把这些吃透了,以后做类似的功能就轻车熟路了。