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