Compose回忆童年 - 手拉灯绳-开灯/关灯

2,392 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第6天,点击查看活动详情

一、前言

之前发布的一篇Compose制作一个“IOS”效果的SwitchButton

有位掘友评论:“手势配合Animatable也可以尝试一下”

QQ20220829-205040@2x.png

手机上我想修改之前回复的评论,没想到删除自己的评论,他的评论也没有了,准备回复的内容也没了,想着顺手写一篇和Animatable相关的文章吧。

回到本篇文章的另一个起源吧,想到小时候顺着那白色开关垂下来的灯绳,拉一下“咔哒”一声,再拉一下又是“咔哒”一声。当时年龄小感觉新奇总是把灯开了关又关了开的拉着玩,以至于好几次拉坏了开关灯绳。

今天我们在手机上做一个拉不坏的灯绳😄,怀念一下童年🤣。

82年钨丝灯.png

二、材料准备

  1. 在App里面准备一个弹簧绳😄,我们想奢侈一下,躺在床上拉灯绳。
  2. 绳末尾底部的疙瘩,替换成:夜光球 ,毕竟现在2022年了,夜晚总不能黑灯瞎火摸着找灯绳吧,你们觉得呢?
  3. 准备一个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()
        )
    }
}

这里我们使用ModifierdrawBehind修饰符,大家可以去看一下Compose性能优化篇,仅在绘制阶段读取“绳子”拉动的距离,因此,Compose 可以完全跳过组合阶段和布局阶段 , 当距离发生变化时,Compose 会直接进入绘制阶段。

3. 我们需要给“夜光球”增加2个功能,“点击开/关灯”,“借力拉绳

点击功能:我们可以通过PointerInputScope#detectTapGestures来实现点击功能。

借力拉伸功能:就是我们的拖拽功能,我们可以通过PointerInputScope#detectDragGestures来实现拖拽的功能。

通过Modifieroffset修饰符,更新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写这篇文章的起因之一,我们可以看到AnimatablesnapTo方法:

将当前值设置为目标值,没有任何动画。这个方法将取消任何正在进行的动画并将 Animatable.value 和 Animatable.targetValue 更新为 targetValue 后返回。

Animateable#animateTo我们这里指定了animationSpecspring弹簧动画,因为onDragEnd松手后触发执行的,所以targetValue直接设置为:Offset.Zero即可。

后续扩展:打开可以给它加个“手机震动”、“播放一段音乐”等等,大家自由发挥咯。

最后,看看我们的效果吧:

2022-08-29 22_12_44.gif

蒲公英下载体验钨丝灯Demo: www.pgyer.com/0hpA

别忘了:点赞❤️+收藏❤️+评论❤️+关注❤️+分享❤️,😘😘😘