Android杂谈(一):悬浮球

发布于:2025-06-27 ⋅ 阅读:(12) ⋅ 点赞:(0)

目录

  • 1. 概述

    • 1.1 什么是悬浮球(Floating Ball)

      • 1.1.1 悬浮球的定义

      • 1.1.2 悬浮球的基本概念

      • 1.1.3 悬浮球的常见作用

    • 1.2 悬浮球的应用场景与优势

      • 1.2.1 悬浮球的常见应用场景

      • 1.2.2 悬浮球带来的便利与优势悬浮球带来的便利与优势

      • 1.2.3 设计建议

    • 1.3 Android中悬浮球的实现方式简介

  • 2. 悬浮球基础实现

    • 2.1 创建悬浮球布局(XML设计)
    • 2.2 悬浮球的显示与隐藏控制
    • 2.3 悬浮球拖拽功能实现
    • 2.4 悬浮球位置保存与恢复
  • 3. 悬浮球交互设计

    • 3.1 点击事件处理
    • 3.2 长按与拖拽事件监听
    • 3.3 动画效果添加(出入动画、缩放等)
    • 3.4 多悬浮球管理及标签区分
  • 4. 权限管理与兼容性

    • 4.1 悬浮窗权限检测与申请流程
    • 4.2 不同Android版本权限差异处理
    • 4.3 权限拒绝后的用户体验优化
  • 5. 实战示例

    • 5.1 简单悬浮球Demo代码解析
1. 概述

1.1 什么是悬浮球(Floating Ball)

1.1.1 悬浮球的定义

悬浮球(Floating Ball),也可以成为浮动按钮或悬浮窗按钮,是一种浮在其他应用之上的小型交互控件。它通常以圆形图标展示,用户可以拖动它在屏幕任意位置浮动,并通过点击或展开菜单访问快捷操作。

1.1.2 悬浮球的基本概念
概念 描述
浮动视图 悬浮球本质上是通过 WindowManager 添加到系统窗口上的视图,类型通常是是 TYPE_APPLICATION_OVERLAY (Android 8.0+)。
触控交互 支持点击、拖动、长按等操作,通常结合 onThouchEvent() 或 setOnTouchListener() 实现。
权限要求 需要 SYSTEM_ALERT_WINDOW 权限(即“在其它应用上显示”的权限)。
生命周期 不依赖于特定的 Activity 生命周期,通常在 Service 中创建并通知。
吸附与回弹 为用户体验,常加上边缘吸附、回弹动画等行为。
功能扩展 可点击扩展功能菜单、快捷键、语音助手、悬浮播放器等。
1.2.3 悬浮球的常见作用
使用场景 作用
快捷功能入口 提供一键加速、截图、录屏、返回桌面等操作。
无障碍辅助 协助老年人或行动不便者操作手机。
应用内辅助工具 如游戏助手、视频弹窗、录屏工具等,增强应用功能。
消息提示或小窗功能 如微信聊天小窗、视频画中画等。
开发调试工具 提供日志查看、页面跳转等开发辅助功能。

1.2 悬浮球的应用场景与优势

1.2.1 悬浮球的常见应用场景
  • 辅助操作

    • 场景:老年人、身体不便用户使用设备时。

    • 功能:替代物理按键(返回、主屏、多任务等);放大内容、朗读、语音指令等。

    • 典型案例:系统的“辅助功能菜单”、苹果 Assistive Touch、MIUI 悬浮球。

  • 快捷操作入口

    • 场景:需要快速触达常用功能时。

    • 功能:一键截图、一键加速、锁屏、清理后台、录屏等;控制亮度、音量、Wi-Fi 等设置。

    • 典型案例:手机助手、腾讯手游助手、厂商定制 ROM(如华为、小米)。

  • 小窗功能 / 多任务并行

    • 场景:需要边操作边处理多任务。。

    • 功能:视频小窗播放;聊天窗口浮动;文件上传/下载提示。

    • 典型案例:微信聊天小窗、抖音悬浮播放、悬浮客服。

  • 游戏助手

    • 场景:手游过程中需要操作便利。

    • 功能:录屏、性能监控、一键加速;快速开启飞行、勿扰模式。

    • 典型案例:腾讯游戏助手、OPPO 游戏空间浮窗。

  • 开发与调试辅助

    • 场景:App 内调试或快速跳转功能。

    • 功能:快速查看日志(LogCat);页面跳转、组件注入、抓包操作。

    • 典型案例:内嵌开发者工具、自研调试浮窗。

1.2.2 悬浮球带来的便利与优势
优势 描述
跨界面常驻显示 悬浮球可浮在任何 Activity、任意 App 上,随时可用。
操作更快捷 避免频繁操作底部导航栏或顶部菜单,大幅提升效率。
用户体验增强 特别适合单手操作,提升可用性与便捷性。
支持高定制化 可以自定义外观、功能菜单、交互逻辑等。
降低学习成本 对老年用户、特殊人群提供简单直观的交互。
与服务结合 通常结合 Service 使用,能长期驻留、灵活启动。
1.2.3 设计建议
维度 建议
位置控制 支持自由拖动,并自动吸附屏幕边缘
视觉设计 简洁易识别,避免遮挡关键 UI 区域
权限兼容 Android 6.0+ 要动态申请悬浮窗权限,8.0+ 使用 TYPE_APPLICATION_OVERLAY
动效体验 增加弹出菜单、吸附回弹、放大缩小等动画效果
内存优化 避免内存泄漏,合理处理生命周期,尤其与 Service 配合时

1.3 Android中悬浮球的实现方式简介

在 Android 中实现悬浮球(Floating Ball)的方法主要有以下几种方式,依据使用场景和权限要求的不同可分为 系统级应用内级 实现方式:

实现方式 是否跨界面 是否需权限 稳定性 适用场景
WindowManager(全局) 系统辅助、全局浮窗
Activity 内部视图 App 内浮动工具
Dialog 弹窗模拟 临时提示或功能弹窗
Service + WindowManager 常驻服务型浮窗

2. 悬浮球基础实现

接下来,我们将通过 Service + WindowManager 的方式去实现一个全局的悬浮球

2.1 创建悬浮球布局(XML设计)

view_floating_ball.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="64dp"
    android:layout_height="64dp"
    android:background="@drawable/ball_background">

    <ImageView
        android:id="@+id/floating_button"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:src="@mipmap/ic_floating_ball"
        android:scaleType="centerInside" />
</FrameLayout>

vew_menu_button.xml

<?xml version="1.0" encoding="utf-8"?>
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="48dp"
    android:layout_height="48dp"
    android:layout_margin="8dp"
    android:src="@mipmap/ic_time"
    android:background="@drawable/menu_button_bg"
    android:padding="8dp" />

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_floating_ball"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="悬浮球"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

ball_background.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">
    <solid android:color="#4285F4" /> <!-- 蓝色,可自定义 -->
    <size android:width="64dp" android:height="64dp"/>
    <corners android:radius="32dp"/>
    <stroke android:width="1dp" android:color="#CCCCCC" />
    <padding android:left="4dp" android:top="4dp" android:right="4dp" android:bottom="4dp"/>
</shape>

menu_button_bg.xml

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 阴影效果 -->
    <item>
        <shape android:shape="oval">
            <solid android:color="#33000000"/> <!-- 半透明黑色 -->
        </shape>
    </item>
    <!-- 主体圆形按钮 -->
    <item android:top="2dp" android:left="2dp" android:right="2dp" android:bottom="2dp">
        <shape android:shape="oval">
            <solid android:color="#FFFFFF"/>
            <stroke android:width="1dp" android:color="#DDDDDD"/>
        </shape>
    </item>
</layer-list>

2.3 悬浮球的显示与隐藏控制

悬浮球的显示与隐藏,只演示最简单的开启与关闭,因为我们实现的方式是 Service + WindowManager 的方式,所以显示与隐藏最简的做法就是开关服务,这也是更结构化、推荐的做法,特别适合持久化后台运行的悬浮窗。

findViewById<Button>(R.id.btn_floating_ball).setOnClickListener {
            checkOverlayPermission {
                if (isShow) {
                    stopService(Intent(this, FloatingBallService::class.java))
                } else {
                    startService(Intent(this, FloatingBallService::class.java))
                }
                isShow = !isShow
            }
        }

当然我们也可以将

windowManager.addView(floatingBallView, layoutParams)
windowManager.removeView(floatingBallView)

添加与移除悬浮球布局的方法单独抽离成一个公共类,我们通过 Service 类去进行控制也行,方法有很多,例如用 handler 定时开关,LiveData等等都可以实现开关,具体看业务需求。

2.3 悬浮球拖拽功能实现

悬浮器拖拽功能的实现也是很简单,就是我们在监听处理触摸事件中,更新我们的悬浮球视图的位置即可

// 设置触摸监听,支持拖动悬浮球
        ballView.setOnTouchListener { _, event ->
            val screenWidth = Resources.getSystem().displayMetrics.widthPixels
            val screenHeight = Resources.getSystem().displayMetrics.heightPixels
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    // 手指按下时记录初始位置
                    initialX = layoutParams.x
                    initialY = layoutParams.y
                    initialTouchX = event.rawX
                    initialTouchY = event.rawY
                    true
                }

                MotionEvent.ACTION_MOVE -> {
                    // 手指移动时计算偏移量并更新位置
                    val dx = (event.rawX - initialTouchX).toInt()
                    val dy = (event.rawY - initialTouchY).toInt()

                    // 限制移动范围不超出屏幕
                    layoutParams.x = (initialX + dx).coerceIn(0, screenWidth - ballView.width).toInt()
                    layoutParams.y = (initialY + dy).coerceIn(0, screenHeight - ballView.height).toInt()

                    // 更新悬浮球位置
                    windowManager.updateViewLayout(ballView, layoutParams)

                    // 如果菜单已展开,则同步更新按钮位置
                    if (menuVisible) {
                        updateMenuPositions()
                    }
                    true
                }

                else -> false
            }
        }

2.4 悬浮球位置保存与恢复

我们可能在业务上,会遇到这种情况,产品需要悬浮球保存位置,在下次显示悬浮球的时候,位置还在上一次关闭的时候的位置,我们可以这样做

private val prefs by lazy {
        getSharedPreferences("floating_ball_prefs", MODE_PRIVATE)
    }
    private val PREF_X = "pos_x"
    private val PREF_Y = "pos_y"
.
.
.
.
// 要在设置视图坐标 layoutParams.x, layoutParams.y 之前进行获取
 val savedX = prefs.getInt(PREF_X, 300) // 默认 300, 200
 val savedY = prefs.getInt(PREF_Y, 200)
.
.
.
.
.
// 在 MotionEvent.UP 中保存坐标
 MotionEvent.ACTION_UP -> {
                    prefs.edit()
                        .putInt(PREF_X, layoutParams.x)
                        .putInt(PREF_Y, layoutParams.y)
                        .apply()
                    true
          }

这样我们关闭开启时就会保存并显示上一次悬浮球的位置了

3. 悬浮球交互设计

3.1 点击事件处理

这里我们讲一下悬浮球显示与悬浮球餐单现实以及餐单点击事件的处理

// 我们提供一个按钮,显示和隐藏悬浮球
findViewById<Button>(R.id.btn_floating_ball).setOnClickListener {
            checkOverlayPermission {
                if (isShow) {
                    stopService(Intent(this, FloatingBallService::class.java))
                } else {
                    startService(Intent(this, FloatingBallService::class.java))
                }
                isShow = !isShow
            }
        }

// 点击悬浮球展开或收起菜单
ballView.findViewById<ImageView>(R.id.floating_button).setOnClickListener {
    toggleMenu()
}   

// 悬浮球餐单列表点击事件
button.setOnClickListener {
     Toast.makeText(this, "点击了按钮: $i", Toast.LENGTH_SHORT).show()
  }     

3.2 长按与拖拽事件监听

在处理悬浮球点击事件和拖拽事件的时候,我们一般来说,都会遇到事件冲突,尤其是滑动和点击事件,为此我们需要主动去做判断

private var isClick = false
ballView.findViewById<ImageView>(R.id.floating_button).setOnTouchListener { _, event ->
            val screenWidth = Resources.getSystem().displayMetrics.widthPixels
            val screenHeight = Resources.getSystem().displayMetrics.heightPixels
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    // 手指按下时记录初始位置
                    initialX = layoutParams.x
                    initialY = layoutParams.y
                    initialTouchX = event.rawX
                    initialTouchY = event.rawY
                    isClick = true
                    true
                }

                MotionEvent.ACTION_MOVE -> {
                    // 手指移动时计算偏移量并更新位置
                    val dx = (event.rawX - initialTouchX).toInt()
                    val dy = (event.rawY - initialTouchY).toInt()

                    if (abs(dx) > 10 || abs(dy) > 10) { // 判断为拖拽
                        isClick = false
                        // 限制移动范围不超出屏幕
                        layoutParams.x = (initialX + dx).coerceIn(0, screenWidth - ballView.width).toInt()
                        layoutParams.y = (initialY + dy).coerceIn(0, screenHeight - ballView.height).toInt()

                        // 更新悬浮球位置
                        windowManager.updateViewLayout(ballView, layoutParams)

                        // 如果菜单已展开,则同步更新按钮位置
                        if (menuVisible) {
                            updateMenuPositions()
                        }
                    }
                    true
                }

                MotionEvent.ACTION_UP -> {
                    prefs.edit()
                        .putInt(PREF_X, layoutParams.x)
                        .putInt(PREF_Y, layoutParams.y)
                        .apply()
                    if (isClick) {
                        // 设置点击事件:点击悬浮球展开或收起菜单
                        toggleMenu()
                    }
                    true
                }
                else -> false
            }

        }

3.3 动画效果添加(出入动画、缩放等)

在通常情况下,我们普通的展开和收缩悬浮球菜单,这个过程是非常迅速的,也许可以满足大部分人,但是有些产品或者客户,就是需要丝滑的效果,那么我们可以处理这个加载过程,添加动画效果使其变得丝滑,我们修改 toggleMenu() 方法

// 切换菜单显示与隐藏
    private fun toggleMenu() {
        if (menuVisible) {
            // 当前已显示:移除所有菜单按钮
            menuButtons.forEachIndexed { i, btn ->
                btn.animate()
                    .translationX(0f).translationY(0f)   // 回到悬浮球中心
                    .scaleX(0f).scaleY(0f).alpha(0f)
                    .setDuration(animationDuration)
                    .setStartDelay(i * animationStagger) // 依次收回
                    .withEndAction {
                        windowManager.removeView(btn)
                        if (i == menuButtons.lastIndex)  // 最后一个移除后清空列表
                            menuButtons.clear()
                    }
                    .start()
                }
        } else {
            // 当前隐藏:计算按钮位置并展开
            val radius = ballView.width * 1.5f // 菜单按钮距离中心的半径
            val centerX = layoutParams.x       // 悬浮球中心 X(你可考虑加 width / 2)
            val centerY = layoutParams.y       // 悬浮球中心 Y

            val count = 6                      // 菜单按钮数量
            val angleRange = 360               // 按钮环绕角度范围
            val startAngle = 90                // 起始角度(从正上方顺时针)

            repeat(count) { i ->
                // 计算每个按钮的角度和偏移坐标
                val angle = Math.toRadians((startAngle + i * (angleRange / count)).toDouble())
                val offsetX = (radius * cos(angle)).toInt()
                val offsetY = (radius * sin(angle)).toInt()

                // 加载按钮布局
                val button = LayoutInflater.from(this).inflate(R.layout.view_menu_button, null)

                // 设置每个按钮的窗口参数
                val lp = WindowManager.LayoutParams(
                    75,
                    75,
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
                        WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
                    else
                        WindowManager.LayoutParams.TYPE_PRIORITY_PHONE,
                    WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
                    PixelFormat.TRANSLUCENT
                ).apply {
                    gravity = Gravity.TOP or Gravity.START
                    x = centerX + offsetX
                    y = centerY + offsetY
                }

                button.alpha = 0f
                windowManager.addView(button, lp)
                menuButtons.add(button)

                // 每个按钮的点击事件
                button.setOnClickListener {
                    Toast.makeText(this, "点击了按钮: $i", Toast.LENGTH_SHORT).show()
                }

                // 使用属性动画 + layout 更新
                val animX = ValueAnimator.ofInt(centerX, centerX + offsetX)
                val animY = ValueAnimator.ofInt(centerY, centerY + offsetY)

                animX.addUpdateListener { valueAnimator ->
                    lp.x = valueAnimator.animatedValue as Int
                    windowManager.updateViewLayout(button, lp)
                }
                animY.addUpdateListener { valueAnimator ->
                    lp.y = valueAnimator.animatedValue as Int
                    windowManager.updateViewLayout(button, lp)
                }

                // 渐显 + 同步动画
                button.animate()
                    .alpha(1f)
                    .setDuration(animationDuration)
                    .setStartDelay(i * animationStagger)
                    .start()

                animX.duration = animationDuration
                animY.duration = animationDuration
                animX.startDelay = i * animationStagger
                animY.startDelay = i * animationStagger
                animX.start()
                animY.start()

            }
        }

        // 切换菜单可见状态
        menuVisible = !menuVisible
    }

动画如何实现这里不在赘述,想要具体什么动画效果、优化这些需要读者自己去动手

3.4 多悬浮球菜单列表管理及标签区分

悬浮球菜单列管理是非常简单的,我们通过 repeat 方法去手动添加菜单,如此我们也知道对应 index 是什么标签以及我们可以有对应的事件处理

private val floatingBallIcons = mutableListOf<Int>(
        R.mipmap.ic_time, R.mipmap.ic_return, R.mipmap.ic_magnifier, R.mipmap.ic_screenshot,
        R.mipmap.ic_screen_recording, R.mipmap.ic_close
  )
.
.
.
.
.

 val iv = button.findViewById<ImageView>(R.id.iv_floating_ball_menu)
 iv.setImageResource(floatingBallIcons[i % floatingBallIcons.size])  // 防止越界

.
.
.

// 每个按钮的点击事件
                button.setOnClickListener {
                    when (i % floatingBallIcons.size) {
                        0 -> {
                            Toast.makeText(this, "点击了计时悬浮球", Toast.LENGTH_SHORT).show()

                        }
                        1 -> {
                            Toast.makeText(this, "点击了返回悬浮球", Toast.LENGTH_SHORT).show()

                        }
                        2 -> {
                            Toast.makeText(this, "点击了放大镜悬浮球", Toast.LENGTH_SHORT).show()

                        }
                        3 -> {
                            Toast.makeText(this, "点击了截图悬浮球", Toast.LENGTH_SHORT).show()

                        }
                        4 -> {
                            Toast.makeText(this, "点击了录制悬浮球", Toast.LENGTH_SHORT).show()

                        }
                        5 -> {
                            Toast.makeText(this, "点击了结束悬浮球", Toast.LENGTH_SHORT).show()

                        }
                    }
                }

4. 权限管理与兼容性

4.1 悬浮窗权限检测与申请流程

在 Android Kotlin 中,悬浮窗(系统窗口)权限通常指的是 “在其他应用上层显示” 权限(即 SYSTEM_ALERT_WINDOW 权限

  • AndroidManifest.xml 中要进行权限声明

    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
    
  • 权限申请方法(跳转界面)

    private val overlayPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                if (Settings.canDrawOverlays(this)) {
                    pendingRunnable?.run()
                } else {
                    Toast.makeText(this, "悬浮窗权限未授予", Toast.LENGTH_SHORT).show()
                }
            }
        }  
    private var pendingRunnable: Runnable? = null
    
    
    private fun checkOverlayPermission(runnable: Runnable) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                if (Settings.canDrawOverlays(this)) {
                    runnable.run()
                } else {
                    pendingRunnable = runnable
                    val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
                        Uri.parse("package:$packageName"))
                    overlayPermissionLauncher.launch(intent)
                }
            }
        }
    
  • 调用申请

    findViewById<Button>(R.id.btn_floating_ball).setOnClickListener {
                checkOverlayPermission {
                    if (isShow) {
                        stopService(Intent(this, FloatingBallService::class.java))
                    } else {
                        startService(Intent(this, FloatingBallService::class.java))
                    }
                    isShow = !isShow
                }
            }
    

4.2 不同Android版本权限差异处理

Android 版本 权限机制 默认行为 特殊限制
< 6.0 (API < 23) 无需动态申请 默认授予权限 可直接使用 TYPE_PHONE/TYPE_SYSTEM_ALERT
6.0 (API 23) 引入 SYSTEM_ALERT_WINDOW 特殊权限 需要手动授权 通过设置页面跳转授权
8.0 (API 26) 引入 TYPE_APPLICATION_OVERLAY 类型 禁用 TYPE_PHONE/TYPE_SYSTEM_ALERT 强制使用新的窗口类型
10 (API 29) 起 增强安全性和隐私限制 权限不再自动授予 禁止后台直接启动悬浮窗
11 (API 30) 起 后台启动限制更严格 后台无法直接显示悬浮窗 必须从前台或通过通知引导
12 (API 31) 起 不再允许 startActivity 弹出系统权限页面 startActivityForResult 有部分限制 推荐使用 ActivityResultLauncher 替代
  • 权限申请方式兼容

    fun requestOverlayPermission(activity: Activity, requestCode: Int) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val intent = Intent(
                Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
                Uri.parse("package:${activity.packageName}")
            )
            activity.startActivityForResult(intent, requestCode)
        }
    }
    
    
  • 悬浮窗类型选择兼容

    val layoutParams = WindowManager.LayoutParams().apply {
        type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
        } else {
            WindowManager.LayoutParams.TYPE_PHONE
        }
    }
    
    
  • 悬浮窗显示行为兼容(Android 10+)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        if (ActivityManager.isRunningInForeground()) {
            windowManager.addView(floatingView, layoutParams)
        } else {
            // 引导用户前台启动
        }
    }
    
    

4.3 权限拒绝后的用户体验优化

当用户拒绝悬浮窗权限时,如果处理不当,可能会导致功能中断、用户流失。因此,优化权限被拒绝时的用户体验尤为关键。下面从 交互设计、引导策略、技术实现 三方面讲解如何优化这类体验

4.3.1 基本原则
  • 先解释再申请:权限必须与功能强相关,提前告诉用户“为什么要授权”。

  • 非强依赖时提供替代方案:不给权限也能使用部分功能。

  • 避免频繁打扰:拒绝后不要立刻反复弹出权限请求。

4.3.2 用户体验优化策略
  • 权限说明弹窗(前置说明)

    目的:在跳转系统设置前,先弹出清晰的解释,增强授权意愿。

    AlertDialog.Builder(context)
        .setTitle("需要悬浮窗权限")
        .setMessage("我们需要开启悬浮窗权限来显示快捷操作球,不影响正常使用,您可以随时关闭。")
        .setPositiveButton("去开启") { _, _ ->
            requestOverlayPermission(activity, REQUEST_CODE)
        }
        .setNegativeButton("取消") { _, _ ->
            Toast.makeText(context, "您可在设置中手动开启悬浮窗权限", Toast.LENGTH_SHORT).show()
        }
        .show()
    
    
  • 拒绝后的友好提示(非暴力提醒)

    在权限被拒绝后,使用 Snackbar、Toast 或弱提示 UI,避免打扰用户操作。

    Snackbar.make(rootView, "悬浮窗权限未开启,快捷操作功能受限", Snackbar.LENGTH_LONG)
        .setAction("去设置") {
            openOverlaySettings()
        }.show()
    
    
  • 提供“稍后开启”或“不再提示”选项

    通过 SharedPreferences 记录用户是否明确拒绝;下次启动或延后 1 天再提示。

    if (!prefs.getBoolean("overlay_explained", false)) {
        showExplainDialog()
        prefs.edit().putBoolean("overlay_explained", true).apply()
    }
    
    
  • 功能降级或提供引导替代路径

    若悬浮窗权限未授予:

    • 显示通知栏快捷入口代替悬浮球;

    • 将功能入口放置在应用内部的明显区域;

    • 告诉用户“该功能需要权限后才能使用”。

5. 实战示例

5.1 简单悬浮球Demo代码解析

FloatingBallService

package com.example.levitatedsphereexample

import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.Service
import android.content.Intent
import android.content.res.Resources
import android.graphics.PixelFormat
import android.os.Build
import android.os.IBinder
import android.view.*
import android.view.animation.OvershootInterpolator
import android.widget.ImageView
import android.widget.Toast
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin

class FloatingBallService : Service() {

    private lateinit var windowManager: WindowManager  // 管理系统窗口的核心类
    private lateinit var ballView: View                // 悬浮球 View
    private lateinit var layoutParams: WindowManager.LayoutParams  // 悬浮球的位置和尺寸参数
    private var menuVisible = false                    // 是否展开菜单的标记
    private val menuButtons = mutableListOf<View>()    // 存储所有菜单按钮的列表
    private val floatingBallIcons = mutableListOf<Int>(
        R.mipmap.ic_time, R.mipmap.ic_return, R.mipmap.ic_magnifier, R.mipmap.ic_screenshot,
        R.mipmap.ic_screen_recording, R.mipmap.ic_close
    )

    private val prefs by lazy {
        getSharedPreferences("floating_ball_prefs", MODE_PRIVATE)
    }
    private val PREF_X = "pos_x"
    private val PREF_Y = "pos_y"

    private var isClick = false

    private val animationDuration = 250L          // 单个按钮动画时长
    private val animationStagger = 40L

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onCreate() {
        super.onCreate()

        windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
        createFloatingBall() // 创建并显示悬浮球
    }

    @SuppressLint("ClickableViewAccessibility")
    private fun createFloatingBall() {
        val savedX = prefs.getInt(PREF_X, 300) // 默认 300, 200
        val savedY = prefs.getInt(PREF_Y, 200)
        // 设置悬浮球的窗口参数:大小、类型、位置、透明度等
        layoutParams = WindowManager.LayoutParams(
            75,
            75,
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY  // Android 8.0+
            } else {
                WindowManager.LayoutParams.TYPE_PRIORITY_PHONE       // 低版本使用
            },
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,           // 不获取焦点
            PixelFormat.TRANSLUCENT                                  // 支持透明
        ).apply {
            gravity = Gravity.TOP or Gravity.START  // 初始定位参考左上角
            x = savedX
            y = savedY
        }

        // 加载悬浮球布局
        ballView = LayoutInflater.from(this).inflate(R.layout.view_floating_ball, null)

        // 记录拖动前的初始位置和手指坐标
        var initialX = 0
        var initialY = 0
        var initialTouchX = 0f
        var initialTouchY = 0f


        // 设置触摸监听,支持拖动悬浮球
        ballView.findViewById<ImageView>(R.id.floating_button).setOnTouchListener { _, event ->
            val screenWidth = Resources.getSystem().displayMetrics.widthPixels
            val screenHeight = Resources.getSystem().displayMetrics.heightPixels
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    // 手指按下时记录初始位置
                    initialX = layoutParams.x
                    initialY = layoutParams.y
                    initialTouchX = event.rawX
                    initialTouchY = event.rawY
                    isClick = true
                    true
                }

                MotionEvent.ACTION_MOVE -> {
                    // 手指移动时计算偏移量并更新位置
                    val dx = (event.rawX - initialTouchX).toInt()
                    val dy = (event.rawY - initialTouchY).toInt()

                    if (abs(dx) > 10 || abs(dy) > 10) { // 判断为拖拽
                        isClick = false
                        // 限制移动范围不超出屏幕
                        layoutParams.x = (initialX + dx).coerceIn(0, screenWidth - ballView.width).toInt()
                        layoutParams.y = (initialY + dy).coerceIn(0, screenHeight - ballView.height).toInt()

                        // 更新悬浮球位置
                        windowManager.updateViewLayout(ballView, layoutParams)

                        // 如果菜单已展开,则同步更新按钮位置
                        if (menuVisible) {
                            updateMenuPositions()
                        }
                    }
                    true
                }

                MotionEvent.ACTION_UP -> {
                    prefs.edit()
                        .putInt(PREF_X, layoutParams.x)
                        .putInt(PREF_Y, layoutParams.y)
                        .apply()
                    if (isClick) {
                        // 设置点击事件:点击悬浮球展开或收起菜单
                        toggleMenu()
                    }
                    true
                }
                else -> false
            }

        }


        // 将悬浮球添加到系统窗口
        windowManager.addView(ballView, layoutParams)
    }

    // 切换菜单显示与隐藏
    private fun toggleMenu() {
        if (menuVisible) {
            // 当前已显示:移除所有菜单按钮
            menuButtons.forEachIndexed { i, btn ->
                btn.animate()
                    .translationX(0f).translationY(0f)   // 回到悬浮球中心
                    .scaleX(0f).scaleY(0f).alpha(0f)
                    .setDuration(animationDuration)
                    .setStartDelay(i * animationStagger) // 依次收回
                    .withEndAction {
                        windowManager.removeView(btn)
                        if (i == menuButtons.lastIndex)  // 最后一个移除后清空列表
                            menuButtons.clear()
                    }
                    .start()
                }
        } else {
            // 当前隐藏:计算按钮位置并展开
            val radius = ballView.width * 1.5f // 菜单按钮距离中心的半径
            val centerX = layoutParams.x       // 悬浮球中心 X(你可考虑加 width / 2)
            val centerY = layoutParams.y       // 悬浮球中心 Y

            val count = 6                      // 菜单按钮数量
            val angleRange = 360               // 按钮环绕角度范围
            val startAngle = 90                // 起始角度(从正上方顺时针)

            repeat(count) { i ->
                // 计算每个按钮的角度和偏移坐标
                val angle = Math.toRadians((startAngle + i * (angleRange / count)).toDouble())
                val offsetX = (radius * cos(angle)).toInt()
                val offsetY = (radius * sin(angle)).toInt()

                // 加载按钮布局
                val button = LayoutInflater.from(this).inflate(R.layout.view_menu_button, null)

                val iv = button.findViewById<ImageView>(R.id.iv_floating_ball_menu)
                iv.setImageResource(floatingBallIcons[i % floatingBallIcons.size])  // 防止越界


                // 设置每个按钮的窗口参数
                val lp = WindowManager.LayoutParams(
                    75,
                    75,
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
                        WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
                    else
                        WindowManager.LayoutParams.TYPE_PRIORITY_PHONE,
                    WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
                    PixelFormat.TRANSLUCENT
                ).apply {
                    gravity = Gravity.TOP or Gravity.START
                    x = centerX + offsetX
                    y = centerY + offsetY
                }

                button.alpha = 0f
                windowManager.addView(button, lp)
                menuButtons.add(button)

                // 每个按钮的点击事件
                button.setOnClickListener {
                    when (i % floatingBallIcons.size) {
                        0 -> {
                            Toast.makeText(this, "点击了计时悬浮球", Toast.LENGTH_SHORT).show()

                        }
                        1 -> {
                            Toast.makeText(this, "点击了返回悬浮球", Toast.LENGTH_SHORT).show()

                        }
                        2 -> {
                            Toast.makeText(this, "点击了放大镜悬浮球", Toast.LENGTH_SHORT).show()

                        }
                        3 -> {
                            Toast.makeText(this, "点击了截图悬浮球", Toast.LENGTH_SHORT).show()

                        }
                        4 -> {
                            Toast.makeText(this, "点击了录制悬浮球", Toast.LENGTH_SHORT).show()

                        }
                        5 -> {
                            Toast.makeText(this, "点击了结束悬浮球", Toast.LENGTH_SHORT).show()

                        }
                    }
                }

                // 使用属性动画 + layout 更新
                val animX = ValueAnimator.ofInt(centerX, centerX + offsetX)
                val animY = ValueAnimator.ofInt(centerY, centerY + offsetY)

                animX.addUpdateListener { valueAnimator ->
                    lp.x = valueAnimator.animatedValue as Int
                    windowManager.updateViewLayout(button, lp)
                }
                animY.addUpdateListener { valueAnimator ->
                    lp.y = valueAnimator.animatedValue as Int
                    windowManager.updateViewLayout(button, lp)
                }

                // 渐显 + 同步动画
                button.animate()
                    .alpha(1f)
                    .setDuration(animationDuration)
                    .setStartDelay(i * animationStagger)
                    .start()

                animX.duration = animationDuration
                animY.duration = animationDuration
                animX.startDelay = i * animationStagger
                animY.startDelay = i * animationStagger
                animX.start()
                animY.start()

            }
        }

        // 切换菜单可见状态
        menuVisible = !menuVisible
    }

    // 更新已展开的菜单按钮位置(当悬浮球被拖动时调用)
    private fun updateMenuPositions() {
        val radius = ballView.width * 1.5f
        val centerX = layoutParams.x
        val centerY = layoutParams.y
        val count = menuButtons.size
        val angleRange = 360
        val startAngle = 90

        // 遍历每个按钮,重新计算并更新其位置
        menuButtons.forEachIndexed { i, view ->
            val angle = Math.toRadians((startAngle + i * (angleRange / count)).toDouble())
            val offsetX = (radius * cos(angle)).toInt()
            val offsetY = (radius * sin(angle)).toInt()

            val lp = view.layoutParams as WindowManager.LayoutParams
            lp.x = centerX + offsetX
            lp.y = centerY + offsetY
            windowManager.updateViewLayout(view, lp)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        // 移除悬浮球和所有菜单按钮
        windowManager.removeView(ballView)
        menuButtons.forEach { windowManager.removeView(it) }
        menuButtons.clear()
    }
}

MainActivity

package com.example.levitatedsphereexample


import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.Runnable

class MainActivity : AppCompatActivity() {

    private var isShow = false

    private val overlayPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            if (Settings.canDrawOverlays(this)) {
                pendingRunnable?.run()
            } else {
                Toast.makeText(this, "悬浮窗权限未授予", Toast.LENGTH_SHORT).show()
            }
        }
    }

    private var pendingRunnable: Runnable? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<Button>(R.id.btn_floating_ball).setOnClickListener {
            checkOverlayPermission {
                if (isShow) {
                    stopService(Intent(this, FloatingBallService::class.java))
                } else {
                    startService(Intent(this, FloatingBallService::class.java))
                }
                isShow = !isShow
            }
        }
    }

    private fun checkOverlayPermission(runnable: Runnable) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            if (Settings.canDrawOverlays(this)) {
                runnable.run()
            } else {
                pendingRunnable = runnable
                val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
                    Uri.parse("package:$packageName"))
                overlayPermissionLauncher.launch(intent)
            }
        }
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_floating_ball"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="悬浮球"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

view_floating_ball.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:background="@drawable/ball_background">

    <ImageView
        android:id="@+id/floating_button"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:src="@mipmap/ic_floating_ball"
        android:scaleType="centerInside" />
</FrameLayout>

view_menu_button.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="48dp"
    android:layout_height="48dp"
    android:layout_margin="8dp"
    android:background="@drawable/menu_button_bg"
    android:padding="8dp">

    <ImageView
        android:id="@+id/iv_floating_ball_menu"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</FrameLayout>

网站公告

今日签到

点亮在社区的每一天
去签到