滑块是一个用户界面元素,允许用户在指定范围内选择一个值。Jetpack Compose的Material 3库提供了一个标准的滑块实现,但也可以创建一个更动态和个性化的设计。
这里向你展示如何利用 Jetpack Compose 的灵活性特性来创建一个独特的滑块。
想不想试试这个更刺激的?
真是太好了,Jetpack Compose中的Material 3库可以很方便地进行扩展,以我们想要的任何方式来自定义滑块。
基础从使用 Material 3 库开始,让我们创建一个简单的滑动条吧。
var value by remember { mutableFloatStateOf(0.5f) } // 定义一个可变的浮点数状态并使用它来初始化一个滑块
Slider(
value = value,
onValueChange = { value = it },
)
这段代码实现了一个采用 Material 3 设计的默认滑块。然而,我们可以通过修改 thumb
和 track
参数来调整滑块的按钮和轨道的外观,以进行自定义。
滑块还可以接受两个额外的可选参数,我们可以利用这些参数让它看起来如我们所愿。这两个参数分别是 thumb
和 track
,它们可以接受任何可组合项并在相应位置进行渲染。
比如说,我们可以将滑条的圆头替换为菱形的,如下:
滑動條(
value = value,
onValueChange = { 值 = it },
thumb = {
Box(
Modifier
.size(48.dp)
.padding(4.dp)
.background(Color.White, 圆角形状(20.dp))
)
}
)
然后,我们可以如下修改轨迹:
Slider(
value = value,
onValueChange = { value = it },
track = { sliderState ->
val fraction by remember {
derivedStateOf {
(sliderState.value - sliderState.valueRange.start) /
(sliderState.valueRange.endInclusive - sliderState.valueRange.start)
}
}
Box(Modifier.fillMaxWidth()) {
Box(
Modifier
.fillMaxWidth(fraction)
.height(6.dp)
.background(Color.Yellow, CircleShape)
)
Box(
Modifier
.fillMaxWidth(1f - fraction)
.height(1.dp)
.background(Color.White, CircleShape)
)
}
}
)
对于滑块,我们首先需要滑块的活动部分所占的比例。我们可以使用我们之前创建的 value
变量,但是这种方法在一个范围不是 0f..1f
的更复杂的滑块中就不起作用了。
因此,我们需要考虑到滑块的起始和结束位置,以确保在所有情况下都能准确计算比例。
接下来,我们可以在任何UI元素中使用这个fraction
来显示此信息。在这里,我只是使用了两个盒子,并在它们的fillMaxWidth
修饰符中提供了fraction
。第一个盒子更厚,是黄色的;第二个盒子较薄,是白色的。它们分别代表活跃区和非活跃区。
现在我们已经讲完了基础知识,我们来看看一个更复杂的例子。
线条滑块让我们创建一个可组合来容纳我们的 LineSlider
,以便更方便重用。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LineSlider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
steps: Int = 0,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
thumbDisplay: (Float) -> String = { "" },
) {
// 代码实现放在这里
}
之前我们已经见过前两个,value
和 onValueChange
。它们是必须的,因为它们对工作正常的 Slider
至关重要。
在 Modifier
之后的,我们有 steps
,它指定了滑块上的捕捉点数量。0 表示滑块平滑无捕捉点。我们稍后会用这个值来为滑块添加刻度。
valueRange
改变了用户通过滑块可以选取的值的范围。
上述参数稍后会传入到 Material 3 的 Slider
中。但是,我们将使用 thumbDisplay
在滑块的拇指上显示文本。我们将其作为当前值的函数传递,这样调用 LineSlider
的人就可以根据自己的喜好格式化文本了。
现在我们来创建滑块吧。
val animatedValue by animateFloatAsState(
targetValue = value,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy
), label = "animatedValue"
)
Slider(
value = animatedValue,
onValueChange = onValueChange,
modifier = modifier,
valueRange = valueRange,
steps = steps,
)
我们将所有参数作为参数传递,但不包括 value
。为此,我们使用 animateFloatAsState()
函数来创建一个动画。这将为用户提供一个平滑的动画效果,无论是点击滑块还是从一个与当前滑块位置有一定距离的地方开始滑动。
事实上,如果你现在运行一下,你会发现,默认的滑动条当你在上面滑动时,拇指会有一些动画。
对于拇指的位置,我们希望有一个圆,在用户拖动滑条时,该圆会抬升到一定高度,当用户释放滑条时会回落。在圆内,我们会显示由 thumbDisplay
定义的文本内容。
val 交互状态 = 记忆 { 可变交互源() }
val 正在拖动 by 交互状态.collectIsDraggedAsState()
val 密度 = LocalDensity.current
val 偏移高度 by 动画FloatAsState(
目标值 = with(密度) { if (正在拖动) 36.dp.toPx() else 0.dp.toPx() },
动画规格 = 弹簧(
刚度 = 刚度中低,
阻尼比 = 低弹跳阻尼比,
标签 = "偏移动画"
)
)
滑块(
...
交互源 = 交互状态,
)
为了知道用户是否正在拖动滑块,我们需要创建一个 MutableInteractionSource
对象,并将它传给 Slider
。接着我们可以用它来获取一个表示用户是否正在拖动滑块的布尔状态。
我们可以最终使用 isDragging
来将一个浮点数动画到特定高度。我们将会用这个 offsetHeight
值来提升拇指。
哎,说到拇指,咱们得聊聊这个拇指了。
之前我提到过,我们将拇指作为可组合函数传入Slider
。但实际上,我在这个例子中并没有这样做。Material 3 中的滑块会根据滑块的位置来定位thumb
参数中的内容。但是,当steps
大于0时,这种定位方式会忽略我们在上一节中创建的水平动画。
在后面的章节中,我们会添加一条平滑的动画曲线。这可能不是其他滑块设计中的问题,但对于我们的设计,如果我们手指抖动,偏离了正确的位置,这个滑块看起来会很不流畅。
因此,我们将这样做,并传递一个空的 lambda 函数给 thumb
。
滑块(
...
拇指 = {}, # 指定滑块的拇指部分
)
这个拇指键码需要放到 track
里面。
Slider(
...
track = { sliderState ->
// 计算滑块的进度
val fraction by remember {
derivedStateOf {
(animatedValue - sliderState.valueRange.start) / (sliderState.valueRange.endInclusive - sliderState.valueRange.start)
}
}
...
}
)
在 track
内部,我们首先计算的是滑块活动部分的比例。
track = { sliderState ->
var 宽度: Int by remember { mutableIntStateOf(0) }
Box(
Modifier
.clearAndSetSemantics { }
.height(thumbSize)
.fillMaxWidth()
.onSizeChanged { 宽度 = it.width },
) {
// 此处放置 Thumb 和 track 的 UI 代码
}
}
接下来,我们将创建一个填满整个空间的Box
,并获取其宽度(以像素为单位)的信息。需要注意的是,由于失去了Slider
自动计算滑块位置的便利性,因此我们需要自己计算出滑块的位置,使用整个宽度来完成。
clearAndSetSemantics { }
用来阻止屏幕阅读器高亮此部分。如果不使用这行代码,任何启用了 Talkback 功能的用户都可以选择滑块内的文本。相反,让Slider
自己处理可访问性。
Box(
Modifier
.zIndex(10f)
.align(Alignment.CenterStart)
.offset {
IntOffset(
x = lerp(
start = -(thumbSize / 2).toPx(),
end = width - (thumbSize / 2).toPx(),
t = fraction
).roundToInt(),
y = -offsetHeight.roundToInt(),
)
}
在这里我们定义拇指的位置。我们希望它在其他元素之上,所以给它一个较大的zIndex
。然后将它对齐到CenterStart
。
现在我们要用到 width
和 fraction
了。我们将根据 fraction
在 x 轴上移动 Box
。
在 y 轴方向,我们将使用之前的 offsetHeight
,来根据用户拖动往上移或往下移拇指的位置。
Box(
Modifier
...
.size(thumbSize) // 设置大小为 thumbSize
.padding(10.dp) // 添加10.dp的内边距
.shadow(
elevation = 10.dp, // 设置阴影的高为10.dp
shape = CircleShape, // 形状为圆形
)
.background(
color = Color(0xFFECB91F), // 设置背景颜色为 #ECB91F (十六进制颜色代码)
shape = CircleShape // 形状为圆形
),
contentAlignment = Alignment.Center, // 居中对齐内容
) {
Text(
thumbDisplay(animatedValue), // 显示动画值的文本
style = MaterialTheme.typography.labelSmall, // 使用小标签样式
color = Color.Black // 设置文本颜色为黑色
)
}
最后,我们来设定拇指的外观。我们将让它呈现圆形,并涂成黄色。
在里面,我们会显示文字。
我们的轨道将是一条从滑块一端延伸到另一端的线。但当用户在滑块上拖动时,这条线会变成曲线,曲线的最高点会跟随抬起的拇指。如果 steps
大于 0,则轨道上会出现一些刻度。
在大拇指下面,我们将在那里添加追踪代码。
val 画线颜色 = MaterialTheme.colorScheme.onSurface
val 是Ltr = LocalLayoutDirection.current == LayoutDirection.Ltr
Box(
Modifier
.align(Alignment.Center)
.fillMaxWidth()
.drawWithCache {
onDrawBehind {
scale(
scaleY = 1f,
scaleX = if (是Ltr) 1f else -1f
) {
绘制滑块路径(
分数 = 分数,
偏移高度 = 偏移高度,
颜色 = 画线颜色,
步数 = 滑块状态.步数
)
}
}
}
)
// 画线颜色表示滑块路径的颜色
// 是Ltr表示当前文本方向是否为从左到右
// Box用于定义一个布局容器,该容器中的内容将根据指定的修饰符进行布局
// Modifier用于定义布局修饰符,这里包括对齐和填充最大宽度
// drawWithCache表示绘制时使用缓存以提高性能
// scale用于缩放绘制内容
// 绘制滑块路径函数绘制滑块的路径,包括分数、偏移高度、颜色和步数参数
注意:在上述翻译中,我将变量名和函数名翻译为了中文描述,以便让中文开发者更易于理解。同时,我添加了中文注释来解释每段代码的含义。
在这段代码里,我们正在准备传递给 drawSliderPath()
的必需参数,这将用于绘制我们的滑块轨道。
但在我们绘制之前,如果 isLtr
为 false,我们会使用 scale()
函数将其翻转。这是为了适应从右到左的文字方向,在这种情况下,滑块会从右向左滑动。
Material 3 的滑块很好地支持 RTL,但是我们需要为轨道做一些自定义绘制,所以我们需要自己来处理这里的问题。
现在我们来看看 “drawSliderPath()” 的定义。
fun DrawScope.drawSliderPath(
fraction: Float,
offsetHeight: Float,
color: Color,
steps: Int,
) {
val 路径 = Path()
val activeWidth = 尺寸.width * 比例
val midPointHeight = 尺寸.height / 2
val curveHeight = midPointHeight - 偏移高度
val beyondBounds = 尺寸.width * 2
val ramp = 72.dp.toPx()
// 绘制滑块路径的辅助函数,其中fraction表示宽度的比例,offsetHeight表示偏移高度,color表示路径的颜色,steps表示步数。
// activeWidth表示根据比例计算的活动宽度,midPointHeight表示路径的中间高度,curveHeight表示曲线的高度,beyondBounds表示超出的边界宽度,ramp表示坡度或斜率。
...
}
创建了path
之后,我们将会设置一些有用的测量指标。
// 超出右边界很远的点
path.moveTo(
x = beyondBounds,
y = midPointHeight
)
// 线到曲线前的基础位置
path.lineTo(
x = activeWidth + ramp,
y = midPointHeight
)
// 平滑过渡到曲线的最高点
path.cubicTo(
x1 = activeWidth + (ramp / 2),
y1 = midPointHeight,
x2 = activeWidth + (ramp / 2),
y2 = curveHeight,
x3 = activeWidth,
y3 = curveHeight,
)
// 平滑过渡到曲线另一侧的基础位置
path.cubicTo(
x1 = activeWidth - (ramp / 2),
y1 = curveHeight,
x2 = activeWidth - (ramp / 2),
y2 = midPointHeight,
x3 = activeWidth - ramp,
y3 = midPointHeight
)
// 线延伸到左边很远的地方
path.lineTo(
x = -beyondBounds,
y = midPointHeight
)
在这里,我们定义了轨道路径的方向,从右到左。路径超出轨道边界。稍后,我们将对其进行裁剪,当用户拖动至轨道边缘时,会形成一个漂亮的曲线。
不幸的是,我们将用来裁剪路径的PathOperation
在处理开放路径时不能正常工作。如果我们现在关闭路径,我们会丢失细长的曲线,反而会变成一条像蟒蛇吞食大象后的形态一样。
因此我们必须手动关闭路径本身。
val variation = .1f
// 连接到远在左侧边缘外的点的线
path.lineTo(
x = -beyondBounds,
y = midPointHeight + variation
)
// 连接到曲线起点之前的‘基础’位置的线
path.lineTo(
x = activeWidth - ramp,
y = midPointHeight + variation
)
// 平滑曲线至曲线的顶端
path.cubicTo(
x1 = activeWidth - (ramp / 2),
y1 = midPointHeight + variation,
x2 = activeWidth - (ramp / 2),
y2 = curveHeight + variation,
x3 = activeWidth,
y3 = curveHeight + variation,
)
// 平滑曲线至另一侧的‘基础’位置
path.cubicTo(
x1 = activeWidth + (ramp / 2),
y1 = curveHeight + variation,
x2 = activeWidth + (ramp / 2),
y2 = midPointHeight + variation,
x3 = activeWidth + ramp,
y3 = midPointHeight + variation,
)
// 连接到远在右侧边缘外的点的线
path.lineTo(
x = beyondBounds,
y = midPointHeight + variation
)
这里是一条方向相反的线,并且在y轴上有一些变化。
这是我想到的最好办法。但我对它不太满意。欢迎大家在评论区提出改进意见。
接下来,我们把路径简化一下。
// 定义一个路径,排除特定区域
val exclude = Path().apply {
// 添加一个矩形,超出边界
addRect(Rect(-beyondBounds, -beyondBounds, 0f, beyondBounds))
// 添加另一个矩形,超出边界
addRect(Rect(size.width, -beyondBounds, beyondBounds, beyondBounds))
}
// 定义一个裁剪路径
val trimmedPath = Path()
// 通过排除路径操作来裁剪原始路径
trimmedPath.op(path, exclude, PathOperation.Difference)
注释:beyondBounds
是一个自定义的边界值,用于定义超出界限的具体数值。Path
, Rect
, PathOperation
是编程语言(如 Kotlin 或 Java)中的技术术语。
我们首先定义要排除的路径,即UI元素左边或右边任何超出边界的点。使用这个exclude
路径,我们就可以创建一个新的路径,使用PathOperation.Difference
来创建新的路径。
在画轨道之前,我们会在路径上画刻度。
val pathMeasure = PathMeasure()
pathMeasure.setPath(trimmedPath, false)
val graduations = 步数 + 1
for (i in 0..graduations) {
val pos = pathMeasure.getPosition(
(i / graduations.toFloat()) * pathMeasure.length / 2
)
val height = 10f
when (i) {
0, graduations -> 画圆(
颜色 = color,
半径 = 10f,
中心 = pos
)
else -> 画线(
线宽 = if (pos.x < activeWidth) 4f else 2f,
颜色 = color,
开始 = pos + Offset(0f, height),
结束 = pos + Offset(0f, -height),
)
}
}
为了绘制这些标记,我们需要知道路径长度,以便均匀分布这些标记。标记的数量会比steps
多一个,以确保从轨道的起点到终点都有一个标记。
然后我们根据每两个刻度之间的距离的一半来计算每个刻度在路径上的位置。(注意,路径的总长度是原来的两倍。)
用这个位置,我们可以在两端画一个圆圈,并沿着轨道画线。由于位置还包括路径的 y 位置,无论画什么都会随着曲线上下移动。
最后我们画出轨道本身。
fun DrawScope.drawSliderPath(...) {
...
clipRect(
left = -beyondBounds,
top = -beyondBounds,
bottom = beyondBounds,
right = activeWidth,
) {
drawTrimmedPath(trimmedPath, color)
}
clipRect(
left = activeWidth,
top = -beyondBounds,
bottom = beyondBounds,
right = beyondBounds,
) {
drawTrimmedPath(trimmedPath, color.copy(alpha = .2f))
}
}
fun DrawScope.drawTrimmedPath(path: Path, color: Color) {
drawPath(
path = path,
color = color,
style = Stroke(
width = 10f,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
),
)
}
我们将它画两次(激活和非激活),然后根据 activeWidth
裁剪。激活部分显示提供的颜色,而未激活部分则以 20% 的透明度显示同样的颜色。
就这样,我们有了这个独特的滑块,可以在你的应用中使用。随意修改代码,看看能否做出一些奇怪的变体。如果你有任何问题或意见,下面留言即可。点击这里获取源代码:here。
感谢您的阅读。 🙌🙏✌
别忘了给我点掌声 👏 并关注我更多,了解更多有关 Android 开发和 Kotlin 编程的实用文章。
如果你在 Android 或 Kotlin 方面有任何问题,我随时很乐意帮忙。
关注我,比如微博、微信、抖音等。