10、自定义View实战(二):实现可拖拽的悬浮球,掌握事件分发与窗口管理
各位同学,欢迎来到实战第二弹。上一章我们手撸了一个圆形进度条,算是把自定义 View 的绘制流程摸了个透。今天咱们换个口味,搞一个更“系统级”的东西——可拖拽的悬浮球。
你手机里肯定见过这类东西:微信的浮窗、各种加速球、游戏工具箱。它们能悬浮在任何应用之上,还能随手拖到屏幕边缘。这背后涉及的知识点,说白了就是两样:事件分发和窗口管理。
我个人习惯把这类组件叫做“系统级 View”,因为它不是活在某个 Activity 的布局里,而是直接挂在了 WindowManager 上。嗯,咱们今天就把它的底裤扒干净。
一、先搞清楚:悬浮球到底“浮”在哪里?
普通 View 是放在 Activity 的布局文件里的,由 Activity 的 Window 来管理。但悬浮球不一样,它需要跨应用显示。这时候就得请出 WindowManager 这位大佬了。
WindowManager 是一个系统服务,它负责管理屏幕上的窗口。你可以把它理解成一个“超级布局容器”,任何 View 都可以通过它直接添加到屏幕上,不受 Activity 生命周期的限制。
核心概念:悬浮球本质上是一个通过 WindowManager.addView() 添加到系统窗口层级的 View。
我刚开始做这个功能时,以为只要在 Service 里 new 一个 View 然后 addView 就行了。结果一跑,应用直接崩溃——BadTokenException。后来才明白,WindowManager 添加 View 需要指定 WindowManager.LayoutParams,而且不同类型窗口需要的权限不一样。
二、事件分发:为什么悬浮球能“穿透”点击?
悬浮球最神奇的地方在于:它挡住了后面的内容,但点击它时,后面的应用不会响应。这其实涉及事件分发的两个关键点:
- 触摸事件优先给悬浮球:因为悬浮球的窗口层级更高,系统会优先把事件分发给它。
- 事件不穿透:悬浮球消费了事件,就不会再传递给下层窗口。
但这里有个坑:如果你想让悬浮球在某些区域“透明”,让点击穿透到下层应用,怎么办?
我曾经踩过这个坑。产品经理说:“用户点击悬浮球边缘时,应该能点到后面的按钮。” 我一开始想通过 onTouchEvent 返回 false 来实现,结果发现事件确实穿透了,但悬浮球本身的拖动也失灵了。
正确的做法是:在 onTouchEvent 中判断触摸点是否在有效区域内。如果在有效区域,消费事件并处理拖动;如果不在,返回 false 让事件继续传递。
技巧:使用 event.getRawX() 和 event.getRawY() 获取屏幕绝对坐标,结合 View 的边界判断是否在有效区域内。
三、窗口管理:如何让悬浮球“粘”在屏幕边缘?
拖拽悬浮球到屏幕边缘时,它应该自动吸附到边缘,而不是停在半路。这个效果看起来很酷,实现起来其实就两步:
- 在
ACTION_UP时判断当前 X 坐标离哪边更近。 - 用属性动画平滑移动到目标位置。
但这里涉及一个关键问题:窗口位置的更新。你不能直接修改 View 的 translationX,因为悬浮球的位置是由 WindowManager.LayoutParams 的 x 和 y 决定的。你需要通过 windowManager.updateViewLayout() 来更新位置。
// 伪代码:更新悬浮球位置
WindowManager.LayoutParams params = (WindowManager.LayoutParams) floatView.getLayoutParams();
params.x = targetX;
params.y = targetY;
windowManager.updateViewLayout(floatView, params);
你想想看,每次拖动都要调用 updateViewLayout,性能会不会有问题?
嗯,这里有个优化点:不要在 ACTION_MOVE 中频繁调用 updateViewLayout。我建议在拖动过程中只更新 View 的 translationX/Y,只在 ACTION_UP 时更新 LayoutParams。这样既保证了拖动流畅性,又避免了频繁的系统调用。
四、实战代码:手写一个可拖拽悬浮球
好了,理论说完了,咱们直接上代码。我会把关键部分拆开讲,你跟着写一遍就能跑起来。
4.1 创建悬浮球 View
这个 View 本身很简单,就是一个圆形背景加一个文字。但注意,它需要继承自 View,而不是某个布局容器。
public class FloatBallView extends View {
private Paint circlePaint;
private Paint textPaint;
private String text = "球";
public FloatBallView(Context context) {
super(context);
init();
}
private void init() {
circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
circlePaint.setColor(0xFF2196F3);
circlePaint.setStyle(Paint.Style.FILL);
textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
textPaint.setColor(Color.WHITE);
textPaint.setTextSize(40);
textPaint.setTextAlign(Paint.Align.CENTER);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int cx = getWidth() / 2;
int cy = getHeight() / 2;
int radius = Math.min(cx, cy) - 10;
canvas.drawCircle(cx, cy, radius, circlePaint);
// 绘制文字,注意基线位置
Paint.FontMetrics fm = textPaint.getFontMetrics();
float baseline = cy - (fm.ascent + fm.descent) / 2;
canvas.drawText(text, cx, baseline, textPaint);
}
}
4.2 通过 WindowManager 添加到屏幕
这一步需要在 Service 或 Activity 中完成。我建议用 Service,因为悬浮球通常需要长期运行。
public class FloatBallService extends Service {
private WindowManager windowManager;
private FloatBallView floatView;
private WindowManager.LayoutParams params;
@Override
public void onCreate() {
super.onCreate();
windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
floatView = new FloatBallView(this);
params = new WindowManager.LayoutParams(
100, 100, // 宽高
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
);
params.gravity = Gravity.TOP | Gravity.START;
params.x = 100;
params.y = 200;
windowManager.addView(floatView, params);
}
}
注意:Android 6.0 及以上需要动态申请 SYSTEM_ALERT_WINDOW 权限。TYPE_APPLICATION_OVERLAY 是 API 26 引入的,替代了旧的 TYPE_PHONE。
4.3 实现拖拽逻辑
这是最核心的部分。我们需要重写 onTouchEvent,处理 ACTION_DOWN、ACTION_MOVE 和 ACTION_UP。
public class FloatBallView extends View {
private int lastX, lastY;
private boolean isDragging = false;
@Override
public boolean onTouchEvent(MotionEvent event) {
int rawX = (int) event.getRawX();
int rawY = (int) event.getRawY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
lastX = rawX;
lastY = rawY;
isDragging = false;
return true; // 消费事件
case MotionEvent.ACTION_MOVE:
int dx = rawX - lastX;
int dy = rawY - lastY;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
isDragging = true;
// 更新 View 的位置(通过 translation)
setTranslationX(getTranslationX() + dx);
setTranslationY(getTranslationY() + dy);
lastX = rawX;
lastY = rawY;
}
return true;
case MotionEvent.ACTION_UP:
if (isDragging) {
// 吸附到边缘
snapToEdge();
} else {
// 处理点击事件
performClick();
}
return true;
}
return super.onTouchEvent(event);
}
private void snapToEdge() {
// 获取屏幕宽度
DisplayMetrics metrics = getResources().getDisplayMetrics();
int screenWidth = metrics.widthPixels;
int currentX = (int) (getX() + getTranslationX());
int targetX = currentX < screenWidth / 2 ? 0 : screenWidth - getWidth();
// 使用属性动画平滑移动
animate().translationX(targetX - getX()).setDuration(200).start();
}
}
这里有个细节:为什么用 event.getRawX() 而不是 event.getX()?
因为悬浮球的位置是相对于屏幕的,而 getX() 是相对于 View 自身的。用 rawX 可以避免计算偏移量时的混乱。我在项目中吃过这个亏,当时拖拽时悬浮球总是跳来跳去,排查了半天才发现是坐标基准搞错了。
五、知识体系总览
为了让你更直观地理解本章的知识结构,我画了一张图:
六、避坑指南与最佳实践
做悬浮球功能,有几个坑是绕不开的。我把自己踩过的坑列出来,你遇到时能少走弯路。
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 悬浮球添加后不显示 | 未申请 SYSTEM_ALERT_WINDOW 权限 | 动态申请权限,或在设置中手动开启 |
| 拖拽时悬浮球闪烁 | 频繁调用 updateViewLayout | 拖动时用 translation,松手时更新 LayoutParams |
| 点击事件和拖拽冲突 | 未区分点击和拖拽 | 通过移动距离阈值判断(>5px 视为拖拽) |
| 悬浮球被系统导航栏遮挡 | 未考虑系统装饰区域 | 使用 getWindowVisibleDisplayFrame 获取可用区域 |
个人经验:我曾经在某个定制 ROM 上遇到悬浮球无法显示的问题,排查后发现是厂商修改了窗口类型限制。建议在 onCreate 中检查 canDrawOverlays(),如果返回 false 就引导用户去设置中开启。
七、总结
好了,这一章的内容就到这里。我们实现了可拖拽的悬浮球,掌握了事件分发在跨窗口场景下的应用,也学会了如何通过 WindowManager 管理系统级 View。
你想想看,悬浮球其实就是一个“有手感的窗口”。它的事件处理逻辑和普通 View 没有本质区别,只是它的“容器”变成了 WindowManager。理解了这一点,你就能举一反三,做出更多系统级 UI 组件。
嗯,下一章我们会继续深入自定义 View 的实战,到时候会涉及更复杂的触摸反馈和动画联动。今天的内容你先消化一下,有问题随时在群里交流。