Android Compose 实现悬浮按钮

Compose 实现悬浮按钮

前言

Compose 如火如荼,传统 View 糟糠被弃。写了一年多的 Compose 了,几乎忘掉了传统 View 怎么开发了,维护一些旧项目的时候那叫一个难受,宛如刚进入安卓开发领域的那个我。在这里分享一个「可拖动」、「松手吸边」、「可展开」、「自适应展开方向」的「伪」悬浮按钮。

效果如下

在这里插入图片描述

方案

「可拖动」

自然而然想到使用draggable这个 Modifier。但是很可惜,draggable只能实现一个方向(垂直或水平)上的拖动,不支持我们需求的任意拖动效果。所以只能使用更为底层的 Modifier —— pointerInput。所幸的是,在PointerInputScope中有一个detectDragGesture的扩展方法让我们方便地监听 Drag 事件。该扩展方法的签名如下:

suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = { },
    onDragEnd: () -> Unit = { },
    onDragCancel: () -> Unit = { },
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)

四个回调都顾名思义,不多解释了,由于实现的重点是onDrag,所以介绍一下它的两个参数:

  • change: PointerInputChange。在一次触摸事件中,每一个pointerId(理解为每一根手指)都对应着一个 PointerInputChange,里面包含了很多关于这次事件的信息,比如当前位置的 Offset、上一个位置的 Offset、触摸的类型等等。
  • dragAmount: Offset。提供了一个 shortcut 获取与上一个位置的 Offset 差值,因为这个数据是最常用的,它的本质也是通过 PointerInputChange 中的数据计算得出的。

「松手吸边」

根据上面所说的detectDragGesture可以很容易地想到,「松手」其实等于onDragEnd回调,同样地,如果拖动被取消,我们需要进行一个吸边处理。

而吸边处理最直观的逻辑就是判断当前位置的横坐标与整体布局的宽度之间的关系,如果少于整体宽度的一半则判断为往左边吸,反之往右边吸。整体思路如此,但实现起来还是有一些细节需要注意的,到了代码阶段再一一详述。

由于需要知道整体布局的具体宽度,所以父布局采用了BoxWithConstraints,以其maxWidth作为整体布局的宽度。

「可展开」

提供一个参数isExpand以控制是否要展开,根据这个参数并结合animateIntOffsetAsState以及animateFloatAsState应用到展开内容的位置与透明度,即可打造出带有动画的展开/收起效果的 UI。

因为展开的内容与悬浮按钮本身只是一个简单的相对关系,所以只需要计算出相对 Offset 再加上悬浮按钮的 Offset 就可以了。当然这个相对位置也需要自己去算一下,这里实现的只是其中一个关系,有另外需求的朋友可以自行探索。

不得不说,Compose 的动画写起来非常直观与方便,开发者只管给出状态(在这里而言,这个状态就是isExpand),动画自然就出现了。也得益于 Compose 天生带有组合的特性,可以随意替换展开内容,将「能力」与「展现」分开。就这个悬浮按钮而言,几乎所有的实现都是「功能」,具体的「展现」可以由调用者自行决定和修改。

「自适应展开方向」

自适应展开的方向从思路上其实与松手吸边的判断逻辑一样,只是在具体实现上,我将这个「方向」作为了一个系数直接应用到展开内容的 Offset 上,使用起来没有那么啰嗦。

为什么是「伪」悬浮按钮?

首先因为实现的方案选择了BoxWithConstaints+ 子控件 的形式,所以一切的悬浮、吸边等都只限于父布局内,只有这个父布局是全屏的时候才能表现得和「真」悬浮按钮相似的效果。

Talk is cheap, show me the code

代码如下,其中关键代码基本上都写上了注释:

@Composable
fun FloatButton(
    //控制是否展开的参数,又调用者提供和控制
    isExpand: Boolean = false,
    //展开的内容 1
    expandContent1: @Composable (() -> Unit)? = null,
    //展开的内容 2
    expandContent2: @Composable (() -> Unit)? = null,
    //展开的内容 3
    expandContent3: @Composable (() -> Unit)? = null,
    //按钮本体的点击事件
    onClick: () -> Unit
) =
    BoxWithConstraints(
        //利用一个 fillMaxSize 的 BoxWithConstraints 作为父布局以获取宽高
        Modifier.fillMaxSize()
    ) {
        val density = LocalDensity.current
        //为了比较好的 UI 效果,希望按钮四周加一个 margin
        val margin = 10.dp
        //按钮本体的 Size
        val buttonSize = 60.dp
        // 圆形
        val shape = RoundedCornerShape(50)
        //用于确定按钮的 X 坐标
        var x by remember {
            with(density) {
                //初始位置的 X 坐标
                val initX = margin.roundToPx().toFloat()
                mutableFloatStateOf(initX)
            }
        }
        //用于应用到按钮 Offset 的动画 state
        val goToSideX by animateFloatAsState(
            targetValue = x,
            animationSpec = spring(stiffness = Spring.StiffnessHigh),
            label = "goToSideX"
        )
        //用于确定按钮的 Y 坐标,由于 Y 方向不需要吸边动画,所以直接将该值应用到按钮的 Offset
        var y by remember {
            with(density) {
                //初始位置的 Y 坐标
                val initY = 20.dp.roundToPx().toFloat()
                mutableFloatStateOf(initY)
            }
        }
        //封装一个方法对象处理吸边逻辑
        val goToSide = {
            with(density) {
                //由于按钮的坐标在左上角处,所以实际上判断按钮 X 方向上的中心是否小于整体布局的宽的一半
                x = if (x < ((maxWidth - buttonSize).roundToPx() / 2f)) {
                    //小于则吸附到左边,即 X 坐标为 0
                    0f
                } else {
                    //大于则吸附到右边,即 X 坐标为整体布局的宽减掉按钮宽度和 margin
                    (maxWidth - buttonSize - margin * 2).roundToPx().toFloat()
                }
            }
        }
      
        //构建按钮的 Modifier
        val dragModifier = Modifier
            .padding(margin)
            .size(buttonSize)
            //将 X 坐标与 Y 坐标应用到 Offset 中
            .offset { IntOffset(goToSideX.roundToInt(), y.roundToInt()) }
            //触摸事件
            .pointerInput(Unit) {
                //监听 Drag 事件
                detectDragGestures(
                    onDrag = { _, amount ->
                        //将 X 与 Y 方向上的偏移量加到 x,y 上
                        x += amount.x
                        y += amount.y
                    },
                    //拖动结束以及拖动被取消时调用吸边逻辑
                    onDragCancel = goToSide,
                    onDragEnd = goToSide
                )
            }
            .shadow(5.dp, shape)
            .clip(shape)
            .background(Color(0x4F4F4F66))
            .clickable { onClick() }
        //按钮本体样式,随意修改成你需要的样子
        Box(modifier = dragModifier, contentAlignment = Alignment.Center) {
            Box(
                Modifier
                    .padding(3.dp)
                    .fillMaxSize()
                    .background(Color(0xFFD9D9D9), shape)
            )
            Box(
                Modifier
                    .padding(6.dp)
                    .fillMaxSize()
                    .background(Color.White, shape)
            )
            Icon(imageVector = Icons.Default.Settings, contentDescription = "")
        }//因为展开内容是可选项,所以当三个展开内容都为空时候无需展开逻辑
        if (expandContent1 != null || expandContent2 != null || expandContent3 != null) {
            val expandDirection = with(density) {
                //根据当前 X 坐标与整体布局的宽度的关系决定展开的方向
                //用 1 和 -1 作为 factor 参与 UI 运算
                if (x < ((maxWidth - buttonSize).roundToPx() / 2f)) 1 else -1
            }
            //展开的透明度,负责展开和收起的透明度动画
            val expandContentAlpha by animateFloatAsState(targetValue = if (isExpand) 1f else 0f)
            //内容 1 的位置变化,与按钮边界的相对 Offset
            val expandContent1Offset by animateIntOffsetAsState(
                targetValue =
                //展开状态
                if (isExpand) IntOffset(expandDirection * 100, 100)
                //收起状态
                else IntOffset.Zero
            )
            //同内容 1
            val expandContent2Offset by animateIntOffsetAsState(
                targetValue =
                if (isExpand) IntOffset(expandDirection * 100, 0)
                else IntOffset.Zero
            )
            //同内容 1
            val expandContent3Offset by animateIntOffsetAsState(
                targetValue =
                if (isExpand) IntOffset(expandDirection * 100, -100)
                else IntOffset.Zero
            )
            //根据展开方向计算展开内容相对的对象是按钮的左边界还是右边界
            val mainButtonBorderOffset = with(density) {
                val factor = if (x < ((maxWidth - buttonSize).roundToPx() / 2f)) 1f else 0f
                IntOffset(
                    //左边界的 X 坐标即为当前 X 坐标,右边界的 X 坐标为当前 X 坐标加上按钮宽度
                    x.roundToInt() + (buttonSize * factor).roundToPx(),
                    //Y 方向上固定为当前按钮 Y 方向上的中心
                    y.roundToInt() + (buttonSize / 2).roundToPx()
                )
            }
            if (expandContent1 != null)
                //展示内容 1
                Box(modifier = Modifier
                    //处理位置
                    .offset { expandContent1Offset + mainButtonBorderOffset }
                    //处理透明度
                    .alpha(expandContentAlpha)
                ) {
                    expandContent1()
                }if (expandContent2 != null)
                //同内容 1
                Box(modifier = Modifier
                    .offset { expandContent2Offset + mainButtonBorderOffset }
                    .alpha(expandContentAlpha)
                ) {
                    expandContent2()
                }if (expandContent3 != null)
                //同内容 1
                Box(modifier = Modifier
                    .offset { expandContent3Offset + mainButtonBorderOffset }
                    .alpha(expandContentAlpha)
                ) {
                    expandContent3()
                }
        }
    }

后记

写这篇文章的同时也是一个复盘的过程,让我对这个相对粗糙的成品有了一些新的看法。它毫无疑问能用,但是确实是足够好用吗?也许不尽然。以下是我认为还需要改进的地方:

  • 不采用BoxWithConstaints+ 子控件 的形式,让这个「伪」悬浮按钮变成真正的悬浮按钮,例如使用 Popup 实现。
  • 按钮本体的样式也可以由调用者去指定。
  • 展开内容的位置能根据内容实际大小自适应。
  • 展开和收起的动画能够由调用者指定
  • ……

无论如何,Compose 的确让我享受到了自定义的乐趣。

最后

如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。

如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
img
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。

欢迎大家一键三连支持,若需要文中资料,直接扫描文末CSDN官方认证微信卡片免费领取↓↓↓(文末还有ChatGPT机器人小福利哦,大家千万不要错过)

PS:群里还设有ChatGPT机器人,可以解答大家在工作上或者是技术上的问题
图片

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值