Android 当前Activity内显示的浮窗

这是一个在当前Activity内显示的浮窗(不需要系统悬浮窗权限),支持拖动和左右贴边。

完整实现方案

1. FloatView 核心类

kotlin 复制代码
class FloatView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    // 贴边位置枚举
    enum class EdgePosition { LEFT, RIGHT, FREE }

    // 配置参数
    private var edgeMargin = 0 // 贴边时的边距
    private var topMargin = 0  // 顶部边距
    private var bottomMargin = 0 // 底部边距
    private var edgePosition = EdgePosition.FREE // 当前贴边状态
    private var isDraggable = true // 是否可拖动
    private var autoEdgeEnabled = true // 是否自动贴边

    // 触摸相关
    private var lastX = 0f
    private var lastY = 0f
    private var downX = 0f
    private var downY = 0f
    private var isDragging = false

    // 动画相关
    private var edgeAnimator: ValueAnimator? = null
    private val edgeAnimDuration = 300L // 贴边动画时长

    // 回调
    var onEdgeChangeListener: ((EdgePosition) -> Unit)? = null
    var onDragChangeListener: ((x: Int, y: Int) -> Unit)? = null

    init {
        edgeMargin = 16.dp
        topMargin = 100.dp
        bottomMargin = 100.dp
        setupView()
    }

    private fun setupView() {
        // 确保FloatView可以接收触摸事件
        isClickable = true
        isFocusable = true
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (!isDraggable) return super.onTouchEvent(event)

        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                lastX = event.rawX
                lastY = event.rawY
                downX = event.x
                downY = event.y
                isDragging = false
                // 取消正在进行的贴边动画
                edgeAnimator?.cancel()
            }

            MotionEvent.ACTION_MOVE -> {
                val deltaX = event.rawX - lastX
                val deltaY = event.rawY - lastY

                // 判断是否开始拖动(避免与点击冲突)
                if (!isDragging) {
                    val distance = Math.sqrt((deltaX * deltaX + deltaY * deltaY).toDouble())
                    if (distance > 5) { // 5px阈值
                        isDragging = true
                        parent.requestDisallowInterceptTouchEvent(true)
                    }
                }

                if (isDragging) {
                    // 移动View
                    x += deltaX
                    y += deltaY

                    // 边界限制
                    constrainPosition()

                    // 通知拖动变化
                    onDragChangeListener?.invoke(x.toInt(), y.toInt())

                    lastX = event.rawX
                    lastY = event.rawY
                }
            }

            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                parent.requestDisallowInterceptTouchEvent(false)

                if (isDragging) {
                    // 拖动结束,检查是否需要自动贴边
                    if (autoEdgeEnabled) {
                        snapToEdge()
                    }
                    isDragging = false
                } else {
                    // 点击事件
                    performClick()
                }
            }
        }
        return true
    }

    /**
     * 限制View在父容器范围内
     */
    private fun constrainPosition() {
        val parent = parent as? View ?: return
        val parentWidth = parent.width
        val parentHeight = parent.height

        // 左右边界
        if (x < 0) x = 0f
        if (x + width > parentWidth) x = (parentWidth - width).toFloat()

        // 上下边界(考虑状态栏等)
        if (y < topMargin) y = topMargin.toFloat()
        if (y + height > parentHeight - bottomMargin) {
            y = (parentHeight - height - bottomMargin).toFloat()
        }
    }

    /**
     * 自动贴边到最近的边缘
     */
    private fun snapToEdge() {
        val parent = parent as? View ?: return
        val parentWidth = parent.width
        val centerX = x + width / 2

        // 判断贴左边还是右边
        val targetX = if (centerX < parentWidth / 2) {
            edgeMargin.toFloat() // 贴左边
        } else {
            parentWidth - width - edgeMargin.toFloat() // 贴右边
        }

        // 如果已经在边缘附近,不需要动画
        if (Math.abs(x - targetX) < 10) {
            x = targetX
            updateEdgePosition(targetX)
            return
        }

        // 创建平移动画
        edgeAnimator?.cancel()
        edgeAnimator = ValueAnimator.ofFloat(x, targetX).apply {
            duration = edgeAnimDuration
            interpolator = DecelerateInterpolator()
            addUpdateListener { animation ->
                x = animation.animatedValue as Float
            }
            addListener(object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator) {
                    updateEdgePosition(targetX)
                }
            })
            start()
        }
    }

    /**
     * 更新贴边状态
     */
    private fun updateEdgePosition(targetX: Float) {
        val parent = parent as? View ?: return
        val parentWidth = parent.width

        edgePosition = when {
            targetX <= edgeMargin + 10 -> EdgePosition.LEFT
            targetX >= parentWidth - width - edgeMargin - 10 -> EdgePosition.RIGHT
            else -> EdgePosition.FREE
        }

        onEdgeChangeListener?.invoke(edgePosition)
    }

    /**
     * 手动设置贴边位置
     */
    fun snapToEdge(position: EdgePosition) {
        val parent = parent as? View ?: return
        val parentWidth = parent.width

        val targetX = when (position) {
            EdgePosition.LEFT -> edgeMargin.toFloat()
            EdgePosition.RIGHT -> parentWidth - width - edgeMargin.toFloat()
            EdgePosition.FREE -> x // 保持当前位置
        }

        edgeAnimator?.cancel()
        edgeAnimator = ValueAnimator.ofFloat(x, targetX).apply {
            duration = edgeAnimDuration
            interpolator = DecelerateInterpolator()
            addUpdateListener { animation ->
                x = animation.animatedValue as Float
            }
            addListener(object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator) {
                    updateEdgePosition(targetX)
                }
            })
            start()
        }
    }

    /**
     * 获取当前贴边位置
     */
    fun getEdgePosition(): EdgePosition = edgePosition

    // ============ 配置方法 ============

    fun setEdgeMargin(margin: Int) {
        edgeMargin = margin
    }

    fun setEdgeMarginDp(marginDp: Int) {
        edgeMargin = marginDp.dp
    }

    fun setVerticalMargin(top: Int, bottom: Int) {
        topMargin = top
        bottomMargin = bottom
    }

    fun setVerticalMarginDp(topDp: Int, bottomDp: Int) {
        topMargin = topDp.dp
        bottomMargin = bottomDp.dp
    }

    fun setDraggable(draggable: Boolean) {
        isDraggable = draggable
    }

    fun setAutoEdgeEnabled(enabled: Boolean) {
        autoEdgeEnabled = enabled
    }

    fun setEdgeAnimDuration(duration: Long) {
        edgeAnimDuration = duration
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        edgeAnimator?.cancel()
    }

    // dp转px扩展
    private val Int.dp: Int
        get() = (this * context.resources.displayMetrics.density + 0.5f).toInt()
}

2. 使用示例(Activity中)

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    private lateinit var floatView: FloatView
    private lateinit var rootLayout: FrameLayout

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

        rootLayout = findViewById(R.id.root_layout)
        setupFloatView()
    }

    private fun setupFloatView() {
        // 创建FloatView
        floatView = FloatView(this).apply {
            // 设置内容(可以是任何View)
            val contentView = LayoutInflater.from(this@MainActivity)
                .inflate(R.layout.float_view_content, this, false)
            addView(contentView)

            // 配置参数
            setEdgeMarginDp(16)
            setVerticalMarginDp(100, 100)
            setDraggable(true)
            setAutoEdgeEnabled(true)

            // 设置布局参数
            val params = FrameLayout.LayoutParams(
                FrameLayout.LayoutParams.WRAP_CONTENT,
                FrameLayout.LayoutParams.WRAP_CONTENT
            )
            layoutParams = params

            // 初始位置(右上角)
            post {
                val parentWidth = rootLayout.width
                x = (parentWidth - width - 16.dp).toFloat()
                y = 100.dp.toFloat()
            }

            // 监听贴边变化
            onEdgeChangeListener = { position ->
                when (position) {
                    FloatView.EdgePosition.LEFT -> {
                        // 贴左边时的处理,比如调整内容方向
                        contentView.findViewById<ImageView>(R.id.float_icon)
                            ?.rotation = 0f
                    }
                    FloatView.EdgePosition.RIGHT -> {
                        // 贴右边时的处理
                        contentView.findViewById<ImageView>(R.id.float_icon)
                            ?.rotation = 180f
                    }
                    FloatView.EdgePosition.FREE -> {
                        // 自由位置
                    }
                }
            }

            // 监听拖动变化
            onDragChangeListener = { x, y ->
                // 可以在这里做某些实时更新
            }

            // 设置点击事件
            setOnClickListener {
                Toast.makeText(this@MainActivity, "FloatView Clicked", Toast.LENGTH_SHORT).show()
            }
        }

        // 添加到根布局
        rootLayout.addView(floatView)
    }

    // 外部控制方法示例
    fun hideFloatView() {
        floatView.visibility = View.GONE
    }

    fun showFloatView() {
        floatView.visibility = View.VISIBLE
    }

    fun snapToLeft() {
        floatView.snapToEdge(FloatView.EdgePosition.LEFT)
    }

    fun snapToRight() {
        floatView.snapToEdge(FloatView.EdgePosition.RIGHT)
    }

    override fun onDestroy() {
        super.onDestroy()
        // 清理
        rootLayout.removeView(floatView)
    }

    private val Int.dp: Int
        get() = (this * resources.displayMetrics.density + 0.5f).toInt()
}

3. 布局文件

activity_main.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/root_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- 你的其他内容 -->
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="主界面内容"
        android:layout_gravity="center" />

    <!-- FloatView会通过代码添加,不需要在这里声明 -->

</FrameLayout>

float_view_content.xml(浮窗内容布局)

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:background="@drawable/float_view_bg"
    android:padding="12dp"
    android:elevation="8dp">

    <ImageView
        android:id="@+id/float_icon"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:src="@drawable/ic_float"
        android:contentDescription="Float Icon" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="拖动我"
        android:textSize="12sp"
        android:layout_marginTop="4dp"
        android:gravity="center" />

</LinearLayout>

float_view_bg.xml(背景drawable)

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="#FFFFFF" />
    <corners android:radius="20dp" />
    <stroke
        android:width="1dp"
        android:color="#E0E0E0" />
</shape>

4. 关键特性说明

特性 说明
拖动 支持自由拖动,有5px阈值避免与点击冲突
自动贴边 松手后自动吸附到最近的左右边缘
边界限制 不会拖出父容器范围,可设置上下边距
贴边动画 300ms减速动画,流畅自然
状态回调 监听贴边状态变化和拖动位置变化
无需权限 界面内浮窗,不需要SYSTEM_ALERT_WINDOW权限

这个实现方案是界面内浮窗 ,适用于当前Activity内的悬浮操作入口。如果需要全局系统悬浮窗 (跨应用显示),则需要使用WindowManagerSYSTEM_ALERT_WINDOW权限,实现方式会有所不同。

相关推荐
奔跑吧 android1 小时前
【车载audio】【AudioService 01】【Android 音频子系统分析:按键音(Sound Effects)开启与关闭机制深度解析】
android·音视频·audioflinger·audioservice·audiohal
刘 大 望1 小时前
使用AI IDE从0到1开发五子棋对战项目(vibe coding)
java·人工智能·spring boot·redis·ai·java-rabbitmq·ai编程
液态不合群1 小时前
AI赋能下的中国低代码市场:从工具革新到产业数字化核心引擎
java·人工智能·低代码·架构
零雲1 小时前
java面试:有了解过springboot的自动装配流程吗?
java·spring boot·面试
sanshizhang1 小时前
设计模式-责任链模式
java·设计模式·责任链模式
请叫我大虾1 小时前
数据结构与算法-分裂问题,将数字分成0或1,求l到r之间有多少个1.
java·算法·r语言
*.✧屠苏隐遥(ノ◕ヮ◕)ノ*.✧2 小时前
Day01 Junit 单元测试 & 反射
java·后端·junit·单元测试
逆境不可逃2 小时前
【从零入门23种设计模式16】行为型之迭代器模式
java·开发语言·数据结构·算法·设计模式·职场和发展·迭代器模式
JTCC2 小时前
Java 设计模式西游篇 - 第七回:责任链模式过难关 通关文牒层层批
java·设计模式·责任链模式