Compose制作一个“IOS”效果的SwitchButton

3,409 阅读6分钟

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

本文一个定制样式的SwitchButton,使用Compose来写是非常容易的,下面先来看看我们对外提供如下方法:

@Composeable
fun IosSwitchButton(
    modifier: Modifier,
    checked: Boolean,
    width: Dp = 50.dp,
    height: Dp = 30.dp,
    // Thumb和Track的边缘间距
    gapBetweenThumbAndTrackEdge: Dp = 2.dp,
    checkedTrackColor: Color = Color(0xFF4D7DEE),
    uncheckedTrackColor: Color = Color(0xFFC7C7C7),
    onCheckedChange: ((Boolean) -> Unit)
)

我们先来实现点击切换,后面再来实现滑动切换,checked状态是需要外面(ViewModel)传过来,同样onCheckedChange回调的状态,需要同步更新到ViewModel中。

我们来简单的看看,只实现,点击切换按钮状态的效果代码:

// 定义按钮点击的状态记录
val switchONState = remember { mutableStateOf(checked) }
// Thumb的半径大小
val thumbRadius = height / 2 - gapBetweenThumbAndTrackEdge
// Thumb水平的位移
val thumbOffsetAnimX by animateFloatAsState(
    targetValue = if (checked)
       with(LocalDensity.current) { (width - thumbRadius - gapBetweenThumbAndTrackEdge).toPx() }
    else
       with(LocalDensity.current) { (thumbRadius + gapBetweenThumbAndTrackEdge).toPx() }
)
// Track颜色动画
val trackAnimColor by animateColorAsState(
    targetValue = if (checked) checkedTrackColor else uncheckedTrackColor
)

上面的准备工作做完,我们就需要用到Canvas 来绘制ThumbTrack,按钮的点击我们需要用ModifierpointerInput修饰符提供点按手势检测器:

Modifier.pointerInput(Unit) {
    detectTapGestures(
        onPress = { /* Called when the gesture starts */ },
        onDoubleTap = { /* Called on Double Tap */ },
        onLongPress = { /* Called on Long Press */ },
        onTap = { /* Called on Tap */ }
    )
}

看看我们的Canvas

Canvas(
   modifier = modifier
       .size(width = width, height = height)
       .pointerInput(Unit) {
           detectTapGestures(
               onTap = {
                   // 更新切换状态
                   switchONState.value = !switchONState.value
                   onCheckedChange.invoke(switchONState.value)
               }
           )
       }
) {
    // 这里绘制Track和Thumb
}

绘制Track,我们需要更新drawRoundRectcolor值,我们使用上面根据checked状态变更后的trackAnimColor颜色值:

drawRoundRect(
   color = animateTrackColor,
   // 圆角
   cornerRadius = CornerRadius(x = height.toPx(), y = height.toPx())
)

绘制Thumb,我们需要更新drawCircle里面的中心坐标X轴数值,我们使用状态变更后的动画值thumbOffsetAnimX

drawCircle(
    color = Color.White,
    // Thumb的半径
    radius = thumbRadius.toPx(),
    center = Offset(
        x = thumbOffsetAnimX,
        y = size.height / 2
    )
)

上面实现只有点击功能,效果如下:

2022-08-22 20_43_58.gif
只能点击

GIF录制的效果不太明显,实际上根据大家个人的需求,如果只是为了点击能切换,上面的十几行代码就足够了;


当然我们对效果还是有追求的,请继续往下看,我们来看,如何实现滑动切换,我们还是看一下最后实现可滑动,可点击的效果吧,方便我们下面讲解:

111111.gif
可滑动,可点击,动画连贯

一定要记得:『点赞❤️+关注❤️+收藏❤️』起来,划走了可就再也找不到了😅😅🙈🙈

既然要用到滑动,那么我们就需要使用到Modifierswipeable修饰符

允许我们通过锚点设置,实现组件呈现吸附效果的动画,常用于开关等动画,需要注意的是:swipeable不会为被修饰的组件提供任何默认动画,只能为组件提供手势偏移量等信息。可以根据偏移量结合其他修饰符定制动画。

我们这里需要同时实现“点击”和“滑动”,这里需要把这2个修饰符组合到一个扩展文件里面,我们创建一个IOSSwitchModifierExtensions.kt文件:

// IOSSwitchModifierExtensions.kt

@ExperimentalMaterialApi
internal fun Modifier.swipeTrack(
    anchors: Map<Float, Int>,
    swipeableState: SwipeableState<Int>,
    onClick: () -> Unit
) = composed {
    this.then(Modifier
        .pointerInput(Unit) {
            detectTapGestures(
                onTap = {
                    // 点击回调
                    onClick.invoke()
                }
            )
        }
        .swipeable(
            state = swipeableState,
            anchors = anchors,
            thresholds = { _, _ ->
                // 锚点间吸附效果的临界阈值
                FractionalThreshold(0.3F)
            },
            // 水平方向
            orientation = Orientation.Horizontal
        )
    )
}

我们下面会用到这个扩展方法,我们可以看到swipeable修饰符,需要SwipeableStateanchors

初始化swipeableState

val swipeableState = rememberSwipeableState(initialValue = 0, animationSpec = tween())

我们还需要初始化anchors设置在不同状态时对应的偏移量信息:

// Thumb的半径
val thumbRadius = (height / 2) - gapBetweenThumbAndTrackEdge
// 开始的锚点位置
val startAnchor = with(LocalDensity.current) {
   (thumbRadius + gapBetweenThumbAndTrackEdge).toPx()
}
// 结束的锚点位置
val endAnchor = with(LocalDensity.current) {
   (width - thumbRadius - gapBetweenThumbAndTrackEdge).toPx()
}
// 根据上面的注释,很明显了
val anchors = mapOf(startAnchor to 0, endAnchor to 1)

到这里,我们需要如何继续呢,我仍然是通过录制视频,然后通过一帧一帧的去分析IOS样式switch动画效果来做的。

我们先看最终效果图,然后继续往下拆解:

111111.gif

可以看到,拖动Thumb的时候,灰色的矩形区域是缩小的,然后蓝色部分是一个颜色渐变动画,同样的,点击也是需要做对应的工作。

大家先思考一下,点击和滑动怎么做到一样的?

我们发现Swipeable有个animateTo方法,那这不就好使了吗?对不对

// 代码来自androidx.compose.material.Swipeable.kt
// 通过动画将状态设置为targetValue
suspend fun animateTo(targetValue: T, anim: AnimationSpec<Float> = animationSpec)

来了一个点,第二个点,第三个点,都来了:

// 因为animateTo是挂起函数,我们需要在coroutineScope.launch里面执行
val scope = rememberCoroutineScope()

从上面看到这里的小伙伴,应该知道,我们上面定义了一个IOSSwitchModifierExtensions.kt文件,我们在swipeTrack的onClick方法里面执行animateTo,其实这个animateTo只是更新我们当前的checked状态而已。

Canvas(
    modifier = modifier
       .size(width = width, height = height)
       .swipeTrack(
           anchors = anchors,
           swipeableState = swipeableState,
           onClick = {
               scope.launch {
                  swipeableState.animateTo(if (!switchONState.value) 1 else 0)
               }
           }
       )
    ) {
    // 选中状态下的Track背景
    // 未选中状态下的Track背景
    // Thumb
}

接下来,应该怎么继续呢,我觉得大家可以先思考一下,再继续往下看。

刚刚上面,提到了有2个Track背景,一个背景是颜色渐变动画,一个是缩放动画。

Compose的Canvas怎么写scale呢? 别急,我们可以在源码和文档中找到Canvas#scale

不仅仅可以scale,还可以rotate、insert、translate等等。

还有一个问题,背景颜色渐变动画,我们要用animate*AsState来做吗? animate*AsState 函数是 Compose 中最简单的动画 API,用于为单个值添加动画效果。您只需提供结束值(或目标值),该 API 就会从当前值开始向指定值播放动画。

我们发现animate*AsState并不是我们想要的,我们想要的是
滑动的时候根据“当前滑动移动的距离”来更新Track背景色渐变

没有用Compose的时候,我们可以用初始化一个ArgbEvaluator,然后调用:

argbEvaluator.evaluate(fraction, startColor, stopColor)

在Compose中,我们应该怎么做呢? 我们发现Color.kt中的一个方法lerpandroidx.compose.ui.graphics.ColorKt#lerp

上面的疑惑全部解开,下面就看看我们剩下的实现吧:

// 未选中状态下的Track的scale大小(0F - 1F)
val unCheckedTrackScale = rememberSaveable { mutableStateOf(1F) }
// 选中状态下Track的背景渐变色
val checkedTrackLerpColor by remember {
    derivedStateOf {
        lerp(
           // 开始的颜色
           uncheckedTrackColor,
           // 结束的颜色
           checkedTrackColor,
           // 选中的Track颜色值,根据缩放值计算颜色【转换的渐变进度】
           min((1F - unCheckedTrackScale.value) * 2, 1F)
        )
    }
}

LaunchedEffect(swipeableState.offset.value) {
    val swipeOffset = swipeableState.offset.value
    // 未选中的Track缩放大小
    var trackScale: Float
    ((swipeOffset - startAnchor) / endAnchor).also {
         trackScale = if (it < 0F) 0F else it
    }
    // 未选中的Track缩放大小更新,上面👆👆的:选中的Track颜色值,是根据这个来算的
    unCheckedTrackScale.value = 1F - trackScale
    // 更新开关状态
    switchONState.value = swipeOffset >= endAnchor
    // 回调状态
    onCheckedChange.invoke(switchONState.value)
}

所以,我们的Canvas里面Track和Thumb最终颜色和缩放,是根据上面计算出来的值来更新的:

Canvas(
    modifier = modifier.size(...).swipeTrack(...)
) {
   // 选中状态下的背景
   drawRoundRect(
       //这种的不再使用:Color(ArgbEvaluator().evaluate(t, AndroidColor.RED, AndroidColor.BLUE) as Int)
       color = checkedTrackLerpColor,
       cornerRadius = CornerRadius(x = height.toPx(), y = height.toPx()),
   )
   // 未选中状态下的背景,随着滑动或者点击切换了状态,进行缩放
   scale(
       scaleX = unCheckedTrackScale.value,
       scaleY = unCheckedTrackScale.value,
       pivot = Offset(size.width * 1.0F / 2F + startAnchor, size.height * 1.0F / 2F)
    ) {
       drawRoundRect(
           color = uncheckedTrackColor,
           cornerRadius = CornerRadius(x = height.toPx(), y = height.toPx()),
       )
    }
    // Thumb
    drawCircle(
        color = Color.White,
        radius = thumbRadius.toPx(),
        center = Offset(swipeableState.offset.value, size.height / 2)
    )
}

经过上面的漫长分析和实现,最终效果如下:

111111.gif

源码地址ComposeIOSSwitchButton


往期文章推荐:
1.Jetpack Compose UI创建布局绘制流程+原理 —— 内含概念详解(满满干货)
2.正确实践Jetpack SplashScreen API —— 在所有Android系统上使用总结,内含原理分析
3.源码分析 | ThreadedRenderer空指针问题,顺便把Choreographer认识一下
4.源码分析 | 事件是怎么传递到Activity的?
5.Jetpack Compose - FloatingActionButton 展开/折叠 的多级悬浮菜单
6.Compose制作“抖音”、“快手”视频进度条Loading动画效果