注册

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

本文一个定制样式的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中的一个方法lerp
androidx.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


作者:Halifax
链接:https://juejin.cn/post/7134702107742961701
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册