哆啦说“球”

122 阅读6分钟

随着世俱杯的即将到来,我打算说一期“球”。我本想聊聊哪位球星能成为最佳射手,姆巴佩还是亚马尔?结果敲代码的手一滑,敲成了写个会飞的“球”——悬浮球!下面就让这个小球陪你一起飞(动)起来吧。

ball.gif

这不,你看,这个球不错吧。我们先分析一下这个球,它是由外部的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_MOVEACTION_UP事件就只会一路走到你那里了,并不会继续传递。这样就导致了里面的控件收不到ACTION_UP事件,也就不会触发点击事件了。而我们这个功能,点击功能菜单触发操作是我们的基本功能,不要搞没了。还有一个很重要的点就是如何让拖动的时候不触发点击事件?因为这样的话,悬浮球就过于敏感了,不是我们想要的效果。解决方案如下:在ACTION_MOVE事件中,计算是否在移动。这里我们使用了Math.hypot(dx, dy) > touchSlophypot函数用来求两点间的距离。

截屏2025-05-24 14.15.19.png

数学公式咱们可不能忘了,勾股定理。只有移动的距离大于了touchSlop,即当前设备被认为是滑动的最小距离的常量,我们才认为它是拖动。模拟点击事件我们通常在ACTION_UP事件中模拟,如果是拖动事件,我们就不触发点击了。所以总结下点击事件触发的条件:1.ACTION_DOWNACTION_UP的偏移坐标不能大于阈值,2.一定要能收到ACTION_UP事件。

写在最后

如果你觉得对你有用的话,在 github.com/dora4/dora_… 中有完整代码实现,另外还有很多其他实用的功能,不妨点个star慢慢品味。