随着世俱杯的即将到来,我打算说一期“球”。我本想聊聊哪位球星能成为最佳射手,姆巴佩还是亚马尔?结果敲代码的手一滑,敲成了写个会飞的“球”——悬浮球!下面就让这个小球陪你一起飞(动)起来吧。
这不,你看,这个球不错吧。我们先分析一下这个球,它是由外部的8个扇形和内部的一个圆形组成,外环被分成了八等分。点击扇形或圆形区域,都可以触发相应的功能。我想这个功能对于工具类APP来说还是挺实用的吧!
组件化架构
我打算做成组件化架构,在A组件调用B组件悬浮球功能入口的实现。
package com.example.common.router
import com.alibaba.android.arouter.facade.template.IProvider
interface IViewRouter : IProvider {
fun showFloatingView()
fun closeFloatingView()
}
首先定义一个路由接口IViewRouter
继承自IProvider
。
package com.example.common.router.provider
import com.alibaba.android.arouter.facade.annotation.Autowired
import com.alibaba.android.arouter.launcher.ARouter
import com.example.common.ARouterPath
import com.example.common.router.IViewRouter
object ViewProvider {
@Autowired(name = ARouterPath.VIEW_SERVICE)
lateinit var viewRouter: IViewRouter
init {
ARouter.getInstance().inject(this)
}
fun showFloatingView() {
viewRouter.showFloatingView()
}
fun closeFloatingView() {
viewRouter.closeFloatingView()
}
}
然后写一个对象类ViewProvider去实现代理。在任意其它组件都可以从common模块中通过它调用其它组件的功能。
package com.example.dora.ui
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import com.alibaba.android.arouter.facade.annotation.Route
import com.example.common.ARouterPath
import com.example.common.router.provider.ViewProvider
import dora.BaseActivity
import com.example.dora.R
import com.example.dora.databinding.ActivityRouteBinding
import dora.util.IntentUtils
import dora.util.StatusBarUtils
@Route(path = ARouterPath.ACTIVITY_ROUTE)
class RouteActivity : BaseActivity<ActivityRouteBinding>() {
companion object {
const val REQUEST_OVERLAY_PERMISSION = 0
}
override fun getLayoutId(): Int {
return R.layout.activity_route
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK) {
if (requestCode == REQUEST_OVERLAY_PERMISSION) {
ViewProvider.showFloatingView()
}
}
}
override fun onGetExtras(action: String?, bundle: Bundle?, intent: Intent) {
mBinding.titleBar.title = IntentUtils.getStringExtra(intent, "title")
val themeColor = IntentUtils.getIntExtra(intent, "themeColor")
mBinding.titleBar.setBackgroundColor(themeColor)
StatusBarUtils.setStatusBar(this, themeColor)
}
override fun initData(savedInstanceState: Bundle?, binding: ActivityRouteBinding) {
binding.tvSummary.text = "在ARouter中调用其它组件的功能,可使用悬浮球作为其他组件功能的入口。"
binding.btnShowFloatingView.setOnClickListener {
if (!Settings.canDrawOverlays(this)) {
val intent = Intent(Settings. ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName"))
startActivityForResult(intent, REQUEST_OVERLAY_PERMISSION)
} else {
ViewProvider.showFloatingView()
}
}
binding.btnCloseFloatingView.setOnClickListener {
ViewProvider.closeFloatingView()
}
}
}
就比如我的调用处代码是这样的。那么,真正实现这个功能的代码在哪里呢?由于它是视图相关功能,肯定是在视图组件中了。
package com.example.dview
import android.content.Context
import android.content.Intent
import android.provider.Settings
import com.alibaba.android.arouter.facade.annotation.Route
import com.example.common.ARouterPath
import com.example.common.router.IViewRouter
import com.example.dview.ui.FloatingWindowService
@Route(path = ARouterPath.VIEW_SERVICE)
class ViewRouter : IViewRouter {
private lateinit var context: Context
override fun init(context: Context) {
this.context = context
}
override fun showFloatingView() {
if (Settings.canDrawOverlays(context)) {
context.startService(Intent(context, FloatingWindowService::class.java))
}
}
override fun closeFloatingView() {
context.stopService(Intent(context, FloatingWindowService::class.java))
}
}
这里我们要实现之前定义的IViewRouter接口的功能。
悬浮球功能实现
以上都是铺垫,好戏从现在正式开始。实现一个悬浮窗首先肯定是要申请悬浮窗权限的。
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
然后通过它来申请悬浮窗权限。
val intent = Intent(Settings. ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName"))
startActivityForResult(intent, REQUEST_OVERLAY_PERMISSION)
悬浮窗的界面怎么实现?这里可以使用Dora全家桶中的基础库github.com/dora4/dora。
package com.example.dview.ui
import com.example.dview.FloatingMenuView
import com.example.dview.R
import dora.BaseFloatingWindowService
import dora.util.DensityUtils
import dora.util.ScreenUtils
import dora.util.ToastUtils
class FloatingWindowService : BaseFloatingWindowService() {
override fun getLayoutId(): Int {
return R.layout.layout_floating_view
}
override fun getInitialPosition(): IntArray {
return intArrayOf(ScreenUtils.getScreenWidth() - DensityUtils.DP200,
ScreenUtils.getScreenHeight() - DensityUtils.DP200)
}
override fun initViews() {
val menuView = findViewById<FloatingMenuView>(R.id.floating_view)
menuView.onSectorClick = { index ->
ToastUtils.showShort("点击了扇形按钮 ${index+1}")
}
menuView.onCenterClick = {
ToastUtils.showShort("点击了中心按钮")
}
}
}
只需要继承BaseFloatingWindowService
,然后实现相应的方法就可以了,当然不要忘了在清单文件注册Service。
最后就是最最核心的代码,自定义一个FloatingMenuView
。
package com.example.dview
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import com.example.common.Colors
import kotlin.math.*
class FloatingMenuView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private var downX = 0f
private var downY = 0f
private var arcCircleGap = 10
private val labels = arrayOf("A", "B", "C", "D", "E", "F", "G", "H")
private var centerLabel = "Start"
private var isDragging = false
private var touchSlop = 10
private val paintArc = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Colors.OBSIDIAN_BLACK
}
private val paintCenter = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Colors.OBSIDIAN_BLACK
}
private val paintText = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textAlign = Paint.Align.CENTER
textSize = 40f
}
private val paintLine = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
color = Color.WHITE
strokeWidth = 4f
}
private var centerX = 0f
private var centerY = 0f
private var outerRadius = 0f
private var innerRadius = 0f
var onSectorClick: ((index: Int) -> Unit)? = null
var onCenterClick: (() -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
touchSlop = ViewConfiguration.get(context).scaledTouchSlop
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val defaultSizeDp = 200
val density = resources.displayMetrics.density
val defaultSizePx = (defaultSizeDp * density).toInt()
val width = resolveSize(defaultSizePx, widthMeasureSpec)
val height = resolveSize(defaultSizePx, heightMeasureSpec)
setMeasuredDimension(width, height)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX = w / 2f
centerY = h / 2f
outerRadius = min(centerX, centerY) - arcCircleGap
innerRadius = outerRadius / 2.5f
}
@SuppressLint("DrawAllocation")
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val rectOuter = RectF(
centerX - outerRadius,
centerY - outerRadius,
centerX + outerRadius,
centerY + outerRadius
)
val rectInner = RectF(
centerX - innerRadius,
centerY - innerRadius,
centerX + innerRadius,
centerY + innerRadius
)
// 画8个扇形块
for (i in labels.indices) {
val startAngle = i * 45f - 90f
canvas.drawArc(rectOuter, startAngle, 45f, true, paintArc)
// 用“挖空”的方式抠掉中心圆
paintArc.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
canvas.drawArc(rectInner, startAngle, 45f, true, paintArc)
paintArc.xfermode = null
// 画每个按钮的文字
val midAngle = Math.toRadians((startAngle + 22.5).toDouble())
val textRadius = (outerRadius + innerRadius) / 2
val textX = (centerX + textRadius * cos(midAngle)).toFloat()
val textY = (centerY + textRadius * sin(midAngle)).toFloat() - (paintText.descent() + paintText.ascent()) / 2
canvas.drawText(labels[i], textX, textY, paintText)
val lineAngle = Math.toRadians((startAngle + 45f).toDouble()) // 下一块的起始角度
val startX = centerX + innerRadius * cos(lineAngle).toFloat()
val startY = centerY + innerRadius * sin(lineAngle).toFloat()
val stopX = centerX + outerRadius * cos(lineAngle).toFloat()
val stopY = centerY + outerRadius * sin(lineAngle).toFloat()
paintLine.color = Color.WHITE
paintLine.strokeWidth = 4f
canvas.drawLine(startX, startY, stopX, stopY, paintLine)
}
// 最后画中心重置按钮
canvas.drawCircle(centerX, centerY, innerRadius - 10, paintCenter)
val centerTextY = centerY - (paintText.descent() + paintText.ascent()) / 2
canvas.drawText(centerLabel, centerX, centerTextY, paintText)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
isDragging = false
return true
}
MotionEvent.ACTION_MOVE -> {
val dx = event.x - downX
val dy = event.y - downY
if (!isDragging && hypot(dx, dy) > touchSlop) {
isDragging = true
}
return true
}
MotionEvent.ACTION_UP -> {
if (isDragging) {
isDragging = false
return true // 拖动后不触发点击
}
val upX = event.x
val upY = event.y
val dx = upX - centerX
val dy = upY - centerY
val dist = hypot(dx, dy)
if (dist <= innerRadius - arcCircleGap) {
onCenterClick?.invoke()
return true
} else if (dist <= outerRadius) {
val angle = (Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360) % 360
val fixedAngle = (angle + 90) % 360
val index = (fixedAngle / 45).toInt()
onSectorClick?.invoke(index)
return true
}
}
}
return super.onTouchEvent(event)
}
}
我们来详细分析一下。在BaseFloatingWindowService
的源码中我们可以看到,在这一层只是检测了拖动事件,并没有拦截下来,不让事件往里面传。这个很关键,要不然无论你定义什么控件,点击事件都不好使。
package dora;
import android.app.Service;
import android.content.Intent;
import android.graphics.PixelFormat;
import android.os.Build;
import android.os.IBinder;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.WindowManager;
import androidx.annotation.IdRes;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
/**
* public void requestFloatingPermission(Context context) {
* if (!Settings.canDrawOverlays(context)) {
* Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
* Uri.parse("package:"+context.getPackageName()));
* startActivityForResult(intent, REQUEST_OVERLAY_PERMISSION);
* }
* }
*
* public void start(Context context) {
* if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
* if (Settings.canDrawOverlays(context)) {
* startService(new Intent(context, FloatingWindowService.java));
* }
* }
* }
*
* public void stop(Context context) {
* context.stopService(new Intent(context, FloatingWindowService.java));
* }
*/
public abstract class BaseFloatingWindowService extends Service {
protected WindowManager mWindowManager;
protected View mFloatView;
private int mTouchSlop = 10;
private static final int INITIAL_PARAM_X = 0;
private static final int INITIAL_PARAM_Y = 0;
protected int[] getInitialPosition() {
return new int[] { INITIAL_PARAM_X, INITIAL_PARAM_Y };
}
protected abstract @LayoutRes int getLayoutId();
protected void initViews() {
}
@Override
public void onCreate() {
super.onCreate();
mFloatView = LayoutInflater.from(this).inflate(getLayoutId(), null);
initViews();
WindowManager.LayoutParams params = getLayoutParams();
params.gravity = Gravity.TOP | Gravity.START;
params.x = getInitialPosition()[0];
params.y = getInitialPosition()[1];
mWindowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
mWindowManager.addView(mFloatView, params);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CUPCAKE) {
mTouchSlop = ViewConfiguration.get(mFloatView.getContext()).getScaledTouchSlop();
}
enableDrag(mFloatView, params);
}
protected <T extends View> T findViewById(@IdRes int id) {
return mFloatView.findViewById(id);
}
private static WindowManager.LayoutParams getLayoutParams() {
int layoutFlag;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
layoutFlag = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
} else {
layoutFlag = WindowManager.LayoutParams.TYPE_PHONE;
}
return new WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
layoutFlag,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
);
}
private void enableDrag(@NonNull final View view, final WindowManager.LayoutParams params) {
view.setOnTouchListener(new View.OnTouchListener() {
int initialX;
int initialY;
float touchX;
float touchY;
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
initialX = params.x;
initialY = params.y;
touchX = event.getRawX();
touchY = event.getRawY();
case MotionEvent.ACTION_MOVE:
float dx = event.getRawX() - touchX;
float dy = event.getRawY() - touchY;
// Consider it a drag if the movement distance is large enough
// 简体中文:如果移动距离足够大,则认为是拖动
if (Math.hypot(dx, dy) > mTouchSlop) {
params.x = initialX + (int) dx;
params.y = initialY + (int) dy;
mWindowManager.updateViewLayout(view, params);
}
}
// Allow event to pass through to child views
// 简体中文:允许事件传递给子视图
return false;
}
});
}
@Override
public void onDestroy() {
super.onDestroy();
if (mFloatView != null) {
mWindowManager.removeView(mFloatView);
}
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
}
因为什么啊?因为只有走了ACTION_UP
事件,才会触发这一层级的点击事件。你在外面就把事件拦截并消费掉了,后面的ACTION_MOVE
和ACTION_UP
事件就只会一路走到你那里了,并不会继续传递。这样就导致了里面的控件收不到ACTION_UP
事件,也就不会触发点击事件了。而我们这个功能,点击功能菜单触发操作是我们的基本功能,不要搞没了。还有一个很重要的点就是如何让拖动的时候不触发点击事件?因为这样的话,悬浮球就过于敏感了,不是我们想要的效果。解决方案如下:在ACTION_MOVE
事件中,计算是否在移动。这里我们使用了Math.hypot(dx, dy) > touchSlop
,hypot
函数用来求两点间的距离。
数学公式咱们可不能忘了,勾股定理。只有移动的距离大于了touchSlop
,即当前设备被认为是滑动的最小距离的常量,我们才认为它是拖动。模拟点击事件我们通常在ACTION_UP
事件中模拟,如果是拖动事件,我们就不触发点击了。所以总结下点击事件触发的条件:1.ACTION_DOWN
和ACTION_UP
的偏移坐标不能大于阈值,2.一定要能收到ACTION_UP
事件。
写在最后
如果你觉得对你有用的话,在 github.com/dora4/dora_… 中有完整代码实现,另外还有很多其他实用的功能,不妨点个star慢慢品味。