使用 Compose 实现可见 ScrollBar
前言
众所周知,如果在一个 View 内放一个更大的 View 需要滚动才能正常使用。 Compose 虽然通过嵌套可以实现水平 + 垂直的滚动,但是是不支持显示滚动条的。
尤其是使用 Compose-Desktop 的时候,是使用鼠标作为输入,而常规的滚轮只能垂直滚动,水平滚动的话是不行的,因此这里使用 Compose 实现一个水平的滚动条。
实现平台:Compose-Desktop (Compose-Android 等 Multiplatform 也是一样的)
实现效果图
数据可以随意准备,本例数据是 dump 好的 Android View Hierarchy:一个 dump.xml 文件,用于扩充 View ,一张 screenshot.png 图片用于一般展示。
开始吧
1… 解析数据略过 2… 本例实现了一个水平滚动条。 有些地方为了美观没有换行~~ 滚动条宽度:
滚
动
条
宽
度
可
见
内
容
宽
度
=
可
见
内
容
宽
度
内
容
总
宽
度
\frac{滚动条宽度}{可见内容宽度} = \frac{可见内容宽度}{内容总宽度}
可见内容宽度滚动条宽度?=内容总宽度可见内容宽度? 定义一个 ScrollBox 方法,内一个 Box 用于包装并切割内部超长的 Box 内容
@Composable
private fun UniversalScrollBox(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val TAG = "UniversalScrollBox"
Box(modifier = modifier.clipToBounds()) {
val barWidth = remember { mutableStateOf(0) }
var dragOffset by remember { mutableStateOf(0F) }
var widthRatio by remember { mutableStateOf(1F) }
var width by remember { mutableStateOf(0) }
Box(modifier = Modifier.align(Alignment.TopStart)) {
Layout(measurePolicy = object : MeasurePolicy {
override fun MeasureScope.measure(measurables: List<Measurable>, constraints: Constraints): MeasureResult {
val placeables = measurables.map {
it.measure(constraints = constraints.copy(maxWidth = Int.MAX_VALUE))
}
width = constraints.maxWidth
val readWidth = placeables[0].width
if (readWidth > width) {
widthRatio = readWidth.toFloat() / width.toFloat()
barWidth.value = ((width * width).toFloat() / readWidth).toInt()
} else {
barWidth.value = 0
}
Log.d(TAG, "barWidth ${barWidth.value} dragOffset $dragOffset widthRatio $widthRatio offset ${(-dragOffset * widthRatio).roundToInt()} width $width readWidth $readWidth")
return layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach {
it.place((-dragOffset * widthRatio).roundToInt(), 0)
}
}
}
}, content = {
content()
}, modifier = Modifier.align(Alignment.TopStart).wrapContentWidth(unbounded = false, align = Alignment.Start).offset(0.dp, 0.dp))
}
if (barWidth.value > 0) {
Box(modifier = Modifier.fillMaxWidth().height(16.dp).align(Alignment.BottomStart).background(Color.Transparent)) {
Box(modifier = Modifier.align(Alignment.BottomStart).fillMaxHeight().offset { IntOffset(dragOffset.roundToInt(), 0) }.width(barWidth.value.dp).clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colors.secondary.copy(alpha = 0.5F))
.draggable(state = rememberDraggableState {
dragOffset += it
if (dragOffset < 0) {
dragOffset = 0F
} else if (dragOffset > width - barWidth.value) {
dragOffset = (width - barWidth.value).toFloat()
}
}, orientation = Orientation.Horizontal))
}
}
}
}
3… 使用,一些类的数据结构我就不放出来了
外层
Row(modifier = Modifier.fillMaxWidth().weight(1F).padding(top = 10.dp)) {
UniversalScrollBox(modifier = Modifier.fillMaxHeight().weight(1F).background(MaterialTheme.colors.onBackground.copy(alpha = 0.05F)).padding(4.dp)) {
ChooseViewNode(root = r, currentChosen = c, onChosen = {
chosenViewNode.value = it
}, onGoingChecking = {
goingCheckingNode.value = it
step.value = 2
})
}
Screenshot(modifier = Modifier.fillMaxHeight().weight(1F), bi, chosenViewNode = c)
}
内层
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ChooseViewNode(root: Hierarchy, currentChosen: Hierarchy, onChosen: (Hierarchy) -> Unit, onGoingChecking: (Node) -> Unit) {
Column(modifier = Modifier.wrapContentWidth()) {
val showChildren = remember { mutableStateOf(false) }
Row(modifier = Modifier.wrapContentWidth().background(if (root == currentChosen) {
MaterialTheme.colors.secondary
} else {
Color.Transparent
})) {
Icon(
imageVector = if (showChildren.value) {
Icons.Default.KeyboardArrowDown
} else {
if (LocalLayoutDirection.current == LayoutDirection.Ltr) Icons.Default.KeyboardArrowRight else Icons.Default.KeyboardArrowLeft
},
contentDescription = null,
modifier = if ((root.children?.size ?: 0) > 0) {
Modifier.clickable { showChildren.value = !showChildren.value }
} else {
Modifier.alpha(0.5F)
},
)
val showEmptyTextDialog = remember { mutableStateOf(false) }
ContextMenuArea(items = {
listOf(ContextMenuItem("Check using the node") {
if (root !is Node) {
return@ContextMenuItem
}
if (root.text.isEmpty()) {
showEmptyTextDialog.value = true
return@ContextMenuItem
}
onGoingChecking(root)
})
}, enabled = root is Node) {
Text(
text = if (root is Node) {
"${root.packagee} ${root.text.ifEmpty { root.clazz }}"
} else {
"Root"
},
maxLines = 1, overflow = TextOverflow.Visible, softWrap = false,
modifier = if ((root.children?.size ?: 0) > 0) {
Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = {
onChosen(root)
},
onDoubleTap = {
showChildren.value = !showChildren.value
}
)
}
} else {
Modifier.clickable {
onChosen(root)
}
})
}
if (showEmptyTextDialog.value) {
root as Node
WrongAlertDialog(
"This node has empty text, please check your selected node.",
"${root.packagee} ${root.text.ifEmpty { root.clazz }}",
null
) {
showEmptyTextDialog.value = false
}
}
}
if (showChildren.value) {
val children = root.children ?: return@Column
if (children.size == 0) {
return@Column
}
Column(modifier = Modifier.padding(start = 20.dp)) {
children.forEach {
ChooseViewNode(root = it, currentChosen = currentChosen, onChosen = onChosen, onGoingChecking = onGoingChecking)
}
}
}
}
}
@Composable
private fun Screenshot(modifier: Modifier, image: BufferedImage, chosenViewNode: Hierarchy) {
val TAG = "Screenshot"
Box(modifier = modifier) {
val ratio = remember { mutableStateOf(1F) }
Layout(modifier = Modifier.align(Alignment.Center), content = content@{
Image(painter = image.toPainter(), contentDescription = null, modifier = Modifier.fillMaxHeight())
if (chosenViewNode !is Node) {
return@content
}
val x = chosenViewNode.bounds.x
val y = chosenViewNode.bounds.y
val width = chosenViewNode.bounds.width
val height = chosenViewNode.bounds.height
Log.d(TAG, "x $x y $y width $width height $height")
Box(modifier = Modifier.size(width = width.dp / ratio.value, height = height.dp / ratio.value).offset(x = x.dp / ratio.value, y = y.dp / ratio.value).background(Color.Transparent).border(1.dp, Color.Red))
Log.d(TAG, "box x ${x / ratio.value} y ${y / ratio.value} width ${width / ratio.value} height ${height / ratio.value}")
}, measurePolicy = { measurables, constraints ->
val placeables = measurables.map {
it.measure(constraints = constraints)
}
ratio.value = image.height.toFloat() / placeables[0].height
Log.d(TAG, "ratio ${ratio.value} maxWidth ${constraints.maxWidth} minWidth ${constraints.minWidth}")
layout(placeables[0].width, placeables[0].height) {
placeables.forEach {
Log.d(TAG, "place height ${it.height} width ${it.width}")
it.place(0, 0)
}
}
})
}
}
|