一、前言
偶然间想到小时候顺着那白色开关垂下来的灯绳,拉一下“咔哒”一声,再拉一下又是“咔哒”一声。当时年龄小感觉新奇总是把灯开了关又关了开的拉着玩,以至于好几次拉坏了开关灯绳。
今天我们在手机上做一个拉不坏的灯绳😄,怀念一下童年🤣。
二、材料准备
- 在App里面准备一个弹簧绳😄,我们想奢侈一下,躺在床上拉灯绳。
- 绳末尾底部的疙瘩,替换成:夜光球 ,毕竟现在2022年了,夜晚总不能黑灯瞎火摸着找灯绳吧,你们觉得呢?
- 准备一个82年的钨丝灯泡💡,拉菲也得配个82年的,才有氛围感。
三、安装82年的钨丝灯和灯绳
我们要知道材料所需要占用最大的宽度和最小宽度,可以用BoxWithConstraints做容器,里面放我们的灯泡和灯绳以及我们的“夜光球”。
我们知道BoxWidthConstraints可以获得屏幕上Composable的最小/最大可用宽度和高度,可以根据可用空间使用它来显示不同的内容。
1. 使用Image
来显示我们的钨丝灯:
Image(
alpha = state.wsdAlpha,
modifier = Modifier.align(Alignment.TopCenter).size(100.dp),
painter = painterResource(id = R.drawable.ic_wsd),
contentDescription = null
)
2. 穿好我们的弹簧灯绳,绘制一条线🧵,我们这里还需要用到Modifier.matchParentSize 填充可用空间
Box(
modifier = Modifier
.matchParentSize()
.ropeLine(ropeHandleState)
)
// 绘制我们的弹簧绳
fun Modifier.ropeLine(
state: RopeHandleState
): Modifier {
return drawBehind {
val bulbPosition = state.ropeHandleOffset
drawLine(
Color.DarkGray,
start = Offset(x= size.width * 0.8F,y = 0f),
end = bulbPosition,
ROPE_LINE_WIDTH.toPx()
)
}
}
这里我们使用Modifier的drawBehind修饰符,大家可以去看一下Compose性能优化篇,仅在绘制阶段读取“绳子”拉动的距离,因此,Compose 可以完全跳过组合阶段和布局阶段 , 当距离发生变化时,Compose 会直接进入绘制阶段。
3. 我们需要给“夜光球”增加2个功能,“点击开/关灯”,“借力拉绳”
点击功能:我们可以通过PointerInputScope#detectTapGestures来实现点击功能。
借力拉伸功能:就是我们的拖拽功能,我们可以通过PointerInputScope#detectDragGestures来实现拖拽的功能。
通过Modifier的offset修饰符,更新x轴和y轴的位置。
大家先看看我们的“夜光球”的全部代码,下面我们会介绍RopeHandleState
@Composable
fun LightButton(state: RopeHandleState) {
val coroutineScope = rememberCoroutineScope()
Box(Modifier
.size(BUTTON_RADIUS * 2)
.offset {
val position = state.ropeHandleOffset - Offset(BUTTON_RADIUS.toPx(), BUTTON_RADIUS.toPx())
IntOffset(position.x.roundToInt(), position.y.roundToInt())
}
.background(
// 夜光球的背景
brush = state.ropeHandleBackground,
shape = CircleShape
)
.shadow(16.dp, shape = CircleShape, clip = false)
.pointerInput(Unit) {
detectTapGestures(onTap = {
// 点击了开关按钮
state.toggle()
})
}
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { state.onDragStart() },
onDragEnd = { coroutineScope.launch { state.onDragEnd() } },
onDragCancel = { coroutineScope.launch { state.onDragEnd() } },
onDrag = { change, dragAmount ->
coroutineScope.launch { state.onDrag(change, dragAmount) }
}
)
}
)
}
四、绳柄状态类RopeHandleState
1. 我们需要把BoxWithConstraints的最大宽度和高度传进来,定义如下构造方法:
// RopeHandleState.kt
class RopeHandleState(size: Size) { ... }
2. 初始化默认绳柄尾巴的“夜光球”,默认位置
// RopeHandleState.kt
private val initStartX = size.width * 0.8F
private val initStartY = size.height * 0.5F
3. 定义一个state
记录当前开关状态
// RopeHandleState.kt
private var isOpen by mutableStateOf(false)
4. 记录是否已经开始拖拽,以及拖拽位置动画Animatable
// RopeHandleState.kt
private var isDragStart by mutableStateOf(false)
private val dragAnimatable = Animatable(Offset(0F, 0F), Offset.VectorConverter)
为什么要初始化isDragStart呢?
用过82年钨丝灯的应该知道,以前的拉绳开关,拉到一定距离自己就打开灯了,松手后,再拉到一定距离灯就关了。
5. 返回当前绳子拉动的x轴和y轴位置
val ropeHandleOffset by derivedStateOf {
dragAnimatable.value.exactPositionIn()
}
private fun Offset.exactPositionIn(): Offset {
// 拖动的距离 + 初始化位置
return this + Offset(initStartX, initStartY)
}
6. 定义onDragStart、onDrag、onDragEnd
fun onDragStart() {
isDragStart = true
}
suspend fun onDragEnd() {
isDragStart = false
dragAnimatable.animateTo(
targetValue = Offset.Zero,
animationSpec = spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow)
)
}
suspend fun onDrag(change: PointerInputChange, dragAmount: Offset) {
change.consume()
val targetValue = dragAnimatable.value + dragAmount
dragAnimatable.snapTo(targetValue)
if(isDragStart) {
if(targetValue.y >= 250F) {
isDragStart = false
// 更新开关状态,这里只修改灯泡的可见性,因为没有UI素材,一切从简!!
toggle()
}
}
}
到这里,我们就是使用Animatable写这篇文章的起因之一,我们可以看到Animatable的snapTo方法:
将当前值设置为目标值,没有任何动画。这个方法将取消任何正在进行的动画并将 Animatable.value 和 Animatable.targetValue 更新为 targetValue 后返回。
Animateable#animateTo我们这里指定了animationSpec为spring弹簧动画,因为onDragEnd
是松手后触发
执行的,所以targetValue
直接设置为:Offset.Zero
即可。
后续扩展:打开可以给它加个“手机震动
”、“播放一段音乐
”等等,大家自由发挥咯。
最后,看看我们的效果吧:
点击下载体验钨丝灯Demo: https://www.pgyer.com/0hpA
别忘了:点赞❤️+收藏❤️+评论❤️+关注❤️+分享❤️,😘😘😘