我发现学Android这么多年,很多东西学过,但是很快又忘记了,想要的时候记不起来,又得重新踩坑。所以从今天开始,我打算开设一个专栏,专门记录自己Android APP开发、系统开发的心得,一方面供大家学习,一方面保证自己不忘记,可以及时回头看。工作两年真的发现好记性敌不过烂笔头,大家想持续提高水平,一定得经常记录工作所得,开发经验。我期望我的博客只有干货,没有废话,好理解。看过太多博客废话一堆,看完任然不知所云。
2023/7/29 补充:后续会持续更新 另一篇文章 悬浮球进阶 。项目上给用户使用的悬浮球肯定是比较复杂的,交付给用户之后会再更新一篇文章。
开始阅读之前,建议先跳到文末,下载完整源码,看一下整个项目的结构,然后再阅读文章,这样更好理解,最好的方法是,边阅读,边在源码中对应找到修改的位置。
我的博客每篇都是独立的、完整的。今天这篇 写Android 悬浮窗。
首先就是 悬浮窗是什么?有什么种类?
悬浮窗就是可以悬浮在其它View上的View,新人肯定不知道view是什么,简单理解就是 你的脸上带了一个眼镜,可以戴上也可以摘掉。
种类两种:
1、系统级 可以悬浮在任何应用上。
Tips:Android 8及以上需要动态申请权限。
2、应用内 只能在当前应用内悬浮。
Tips:应用内的不需要申请权限。
下面就是简单实现悬浮球的代码,以系统级 悬浮为例子。
第一步:
权限,Android做任何事之前都得看看有没有权限。
需要两个权限
静态声明一个:
Androidmanifest.xml里添加
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
这个权限用于:给用户弹窗授权。这么说很抽象。。。。。。 简单理解没有它用户无法给app授权。
代码里动态申请一个,申请的时候弹窗。
button1.setOnClickListener(new View.OnClickListener() {
//点击事件监听,OnClick事件监听是对OnTouch事件监听的封装,让app开发人员省略了对点击事件和滑动事件的区分
//如果是创建自定义的view,需要自己去做滑动和点击的区分。
@Override
public void onClick(View v) {//动态申请悬浮窗权限,只有需要一直悬浮的才需要申请,如果只需要悬浮在当前应用,则不需要申请权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
//当前的系统版本大于Android M版本
//这种写法经常用来判断要使用的api接口是否可以使用。
if (!Settings.canDrawOverlays(MainActivity.this)) {
//canDrawOverlays这个api只有Android M版本以上才有,这也是为什么上面要判断的原因。
//检查是否有悬浮窗权限
Log.d(TAG, "应用没有悬浮窗权限,打开授权页让用户授权");
//获取悬浮球权限的标准写法
Intent intent = new Intent();//Settings.ACTION_MANAGE_OVERLAY_PERMISSION
intent.setAction(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
//这个权限还是需要动态申请,静态声明是没用的。
intent.setData(Uri.parse("package:" + getPackageName()));
Log.d(TAG, "PackageNmae:" + getPackageName());
startActivityForResult(intent, 0);
//跳到权限授权页,由用户授权打开
}
}
}
});
OK,上面注释很多,初学者看不懂的注释跳过就好。
第二步:
创建 自定义view
先来简单理解一下,view是什么,
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
setContentView(R.layout.activity_main)就是把res/layout/activity_main.xml加载进Android系统,加载进去,xml就变成了一个view,就这么理解。
这里我再甩一张图帮你理解,最上面的viewgroup考虑为你自己手机开机之后的桌面。
OK,接下里看怎么创建自定义view的:
private WindowManager wm;
private View view;// 悬浮球view
WindowManager.LayoutParams params;// 控制悬浮球
/**
* 添加悬浮View
*
* @param
*/
public void createFloatView() {
if (view == null) {
view = LayoutInflater.from(context).inflate(R.layout.home_floatview, null);//(ViewGroup)this.view
//将xml文件装换成view对象,加入当前的viewgroup树,如果root==null,就是加入根viewGroup
//api讲解链接https://blog.csdn.net/lu202032/article/details/128430287
}
wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);//获取系统服务WindowManagerService
//WindowManager可以添加view到屏幕,也可以从屏幕删除view。它面向的对象一端是屏幕,另一端就是View
height = wm.getDefaultDisplay().getHeight();//获取屏幕宽和高的第一种方法,获取到的值以像素点px为单位
width = wm.getDefaultDisplay().getWidth();//我们这里具体下来,宽和高分别为1080px,2040px
Log.d(TAG, "屏幕高wm.getDefaultDisplay().getHeight() " + height+"px");
Log.d(TAG, "屏幕宽wm.getDefaultDisplay().getWidth() " + width+"px");
params = new WindowManager.LayoutParams();
params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
//添加 FLAG_NOT_TOUCH_MODAL 和 FLAG_NOT_FOCUSABLE 后,浮窗外的点击事件由浮窗外响应,但是浮窗内的点击事件,则浮窗给响应了。
//这个 | 下来的结果等于40 32|8=40
//干货讲解:https://blog.csdn.net/WillWolf_Wang/article/details/120778785
params.format = PixelFormat.TRANSLUCENT;//设置悬浮球半透明
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {//判断当前版本是否可以使用响应的API
params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;//新添加的view,设置为悬浮窗事件
} else {
params.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
}
params.width = WindowManager.LayoutParams.WRAP_CONTENT;//WRAP_CONTENT表示view的大小和自身内容大小相同
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.gravity = Gravity.TOP | Gravity.LEFT;//设置从哪里开始算view的位置,这里是从左上开始,左上角为坐标(0,0)。
int screenWidth = context.getResources().getDisplayMetrics().widthPixels;
//获取屏幕高宽的第二种方法,和第一种方法得到的值相等,单位也是px 像素点
int screenHeight = context.getResources().getDisplayMetrics().heightPixels;
// Log.d(TAG, "context.getResources().getDisplayMetrics().widthPixels " + screenWidth);
// Log.d(TAG, "context.getResources().getDisplayMetrics().heightPixels " + screenHeight);
params.x = screenWidth;//从左上角(0,0)开始,计算view的(x,y)坐标,(x,y)位于自定义view的右下角 对应gravity设置为 TOP LEFT
params.y = screenHeight - height / 3;//自由控制悬浮view的显示位置
// Log.d(TAG, "screenWidth " + params.x);
// Log.d(TAG, "screenHeight - height / 3 " + params.y);
view.setBackgroundColor(Color.TRANSPARENT);//无背景,背景透明
//view.setVisibility(View.VISIBLE);
//让自定义view可见,
// View.INVISIBLE不可见,但是它原来占用的位子还在。View.GONE不可见,并且不留痕迹,不占位置
同样的注释很多,挑看得明白得看。
代码不全?放心我后面会把所有的代码传到github上。
上面的代码功能就是通过windowmanager把自定义view添加到根viewgroup里面。
但是到此位置屏幕上都看不到你自己的悬浮球。
第三步:
添加自定义view
try {
wm.addView(view, params);
} catch (Exception e) {
e.printStackTrace();
Toast.makeText(this.context, "WindowManager 添加自定义View失败,详情查看打印堆栈", Toast.LENGTH_LONG).show();
}
没错就是addView(…),view是通过读xml引入的view对象,params是用来描述view相关属性的工具,params简单理解就是用来控制悬浮球在屏幕上显示位置的描述工具。
第四步:
添加触控事件
现在成功添加悬浮球到了桌面上,但是还不够,因为你无法移动它,点击也没有任何反应,这部分逻辑得自己写。
view.setOnTouchListener(new View.OnTouchListener() {//触屏事件监听
float lastX, lastY;
int oldOffsetX, oldOffsetY;
int tag = 0;// 悬浮球 所需成员变量
@Override
public boolean onTouch(View v, MotionEvent event) {//每一个触控事件 DOWN UP MOVE CANCEL都会重新走一遍onTouch
final int action = event.getAction();
Log.d(TAG, "event.getAction() " + action);
float x = event.getX();
//屏幕上的点击点,相对于自定义view的位置,获取的单位也是px
//这里由于gravity定义的是左上,所以是相对于左上(0,0)的坐标距离
float y = event.getY();
//这里有点难理解,建议看图理解 https://blog.csdn.net/yiyihuazi/article/details/82724557
//获取点击点的“相对位置”
if (tag == 0) {//tag 用来控制 oldOffsetX/Y 取得的值,一定是悬浮球最开始的坐标。
oldOffsetX = params.x;//悬浮球在屏幕上的老坐标。
oldOffsetY = params.y;
}
if (action == MotionEvent.ACTION_DOWN) {
Log.d(TAG, "action == MotionEvent.ACTION_DOWN action= " + action);
lastX = x;
lastY = y;
} else if (action == MotionEvent.ACTION_MOVE) {
Log.d(TAG, "action == MotionEvent.ACTION_MOVE action= " + action);
params.x += (int) (x - lastX) / 3; // 减小偏移量,防止过度抖动
params.y += (int) (y - lastY) / 3; // 减小偏移量,防止过度抖动
tag = 1;
wm.updateViewLayout(view, params);//更新自定义view位置
} else if (action == MotionEvent.ACTION_UP) {
Log.d(TAG, "action == MotionEvent.ACTION_UP action= " + action);
int newOffsetX = params.x;//悬浮球新的显示位置
int newOffsetY = params.y;
// 只要按钮移动位置不是很大,就认为是点击事件
if (Math.abs(oldOffsetX - newOffsetX) <= 20 && Math.abs(oldOffsetY - newOffsetY) <= 20) {
//20像素之内的移动默认为点击事件
Log.d(TAG,"Math.abs(oldOffsetX - newOffsetX)的值为:"+Math.abs(oldOffsetX - newOffsetX));
Log.d(TAG,"Math.abs(oldOffsetY - newOffsetY)的值为:"+Math.abs(oldOffsetY - newOffsetY));
//Math.abs取绝对值
//Math.abs规则详述 https://blog.csdn.net/weixin_49431999/article/details/121010819
if (MyClickListener != null) {
Log.d(TAG, "1 不等于空,走进点击事件");
MyClickListener.onClick(view);
}
} else {
if (params.x < width / 2) {//控制悬浮球始终靠着左右边框
//params.gravity的显示方向发生变化,这里如果还想保持原功能,也需要做一下修改适配
params.x = 0;
} else {
params.x = width;
}
wm.updateViewLayout(view, params);
tag = 0;
}
}
return true;
}
});
这部分代码功能就是给悬浮球添加触控事件,悬浮球移动是怎么移动的,移动的位置是怎么变化的,怎么把悬浮球位置限制在手机左右边框附近;点击悬浮球,会有什么响应事件。这里就是整个悬浮球控制的核心代码,很抽象,靠描述说不清楚,只能靠读者自己去把每一行代码看明白,耐心一点,慢慢看。里面控制悬浮球位置涉及px、dpi、dp,搞清楚他们之间的区别。
注:我的代码也是参考别人的代码,在完全理解的情况下修改的,非原创。行文中不严谨的地方,存粹是为了好理解,如果有大佬恰好看到,莫要怪罪。
Demo APP Github地址:https://github.com/xuhao120833/Hoverball/tree/main
2023/8/11补充:文章读起来还是有很大缺陷,希望后面在悬浮球进阶篇可以写得更好,更易懂,更完整。