Android Compose 实现悬浮按钮

Compose 实现悬浮按钮

前言

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

效果如下

image.png

方案

「可拖动」

自然而然想到使用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 的确让我享受到了自定义的乐趣。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容