使用 Compose 实现可见 ScrollBar

使用 Compose 实现可见 ScrollBar

前言

众所周知,如果在一个 View 内放一个更大的 View 需要滚动才能正常使用。

实现平台:Compose-Desktop (Compose-Android 等 Multiplatform 也是一样的)

实现效果图

效果图

开始吧

定义一个 UniversalScrollBox 方法,内一个 Box 用于包装所有子 Composable 内容。本例内容数据是 10 * 10 个 Text 组件。
滚动条宽度: 滚 动 条 宽 度 可 见 内 容 宽 度 = 可 见 内 容 宽 度 内 容 总 宽 度 \frac{滚动条宽度}{可见内容宽度} = \frac{可见内容宽度}{内容总宽度} =
滚动条高度:同宽度理。

private const val TAG = "UniversalScrollBox"

private fun TwoFloats(width: Float, height: Float) = androidx.compose.ui.geometry.Size(width = width, height = height)

inline val Int.ddp: Dp get() = Dp(value = this.toFloat())
inline val Float.ddp: Dp get() = Dp(value = this)

/**
 * 注意 content 中一定要有东西,没有做判空保护,会数组越界
 */
@Composable
internal fun UniversalScrollBox(
    modifier: Modifier = Modifier,
    scrollBarStroke: Int = 16,
    scrollBarColor: Color = MaterialTheme.colors.secondary.copy(alpha = 0.5F),
    content: @Composable () -> Unit
) {
    Box(
        modifier = modifier
    ) {
        var outerSize by remember { mutableStateOf(IntSize(width = 0, height = 0)) } // 外层宽高,即内容可见的宽度
        var sizeRatio by remember { mutableStateOf(TwoFloats(width = 0F, height = 0F)) } // 内外大小比
        var barSize by remember { mutableStateOf(TwoFloats(width = 0F, height = 0F)) } // 水平滚动条宽度、垂直滚动条高度,$\frac{滚动条宽度}{可见内容宽度} = \frac{可见内容宽度}{内容总宽度}$
        var dragOffset by remember { mutableStateOf(TwoFloats(width = 0F, height = 0F)) } // 滚动条在水平、垂直方向拖动的距离
        /**
         * 水平滚动条会遮挡垂直方向内容,因此,当水平滚动条存在时,需要设置垂直方向 padding,垂直滚动条同理。
         * 当 content 变化,导致首次出现滚动条时,padding 随之发生变化,绘制时 outerSize 变化,从而可能使得另一个滚动条因此出现,继续导致 padding 变化,outerSize 变化,滚动条宽度变化。
         * 但是这几次变化之后, padding 不会再改变,因而滚动条不会继续变化,变化终止。虽然第一次变化的时候,可以预测后续变化,但是懒得计算了。
         */
        Layout(
            modifier = Modifier.fillMaxSize()
                .padding(bottom = if (barSize.width > 0) 16.ddp else 0.ddp, end = if (barSize.height > 0) 16.ddp else 0.ddp)
                .clipToBounds()
                .align(Alignment.TopStart)
                .wrapContentSize(unbounded = false),
            content = content,
            measurePolicy = object : MeasurePolicy {
                override fun MeasureScope.measure(measurables: List<Measurable>, constraints: Constraints): MeasureResult {
                    // 外层 modifier 使用 unbounded false,可以通过 constraints.maxWidth maxHeight 算出外层的最大大小,注意这个 Size 是 padding 之后的
                    outerSize = IntSize(width = constraints.maxWidth, height = constraints.maxHeight)
                    Log.d(TAG, "outerSize $outerSize")
                    val placeables = measurables.map { measurable ->
                        // 实际计算 子 Node 的时候,使用 maxWidth = Int.MAX_VALUE,可以得到 子 Node 的真实宽度
                        measurable.measure(constraints = constraints.copy(maxHeight = Int.MAX_VALUE, maxWidth = Int.MAX_VALUE))
                    }
                    val innerSize = IntSize(width = placeables[0].width, height = placeables[0].height)

                    // 计算是否需要水平、垂直滚动条
                    val needWidth = innerSize.width > outerSize.width
                    val needHeight = innerSize.height > outerSize.height
                    sizeRatio = TwoFloats(
                        width = if (needWidth) innerSize.width.toFloat() / outerSize.width.toFloat() else 0F,
                        height = if (needHeight) innerSize.height.toFloat() / outerSize.height.toFloat() else 0F,
                    )
                    barSize = TwoFloats(
                        width = if (needWidth) (outerSize.width * outerSize.width).toFloat() / innerSize.width else 0F,
                        height = if (needHeight) (outerSize.height * outerSize.height).toFloat() / innerSize.height else 0F
                    )
                    Log.d(TAG, "bar(width: ${barSize.width}, height ${barSize.height}) ratio(width: ${sizeRatio.width}, height ${sizeRatio.height})")
                    return layout(width = outerSize.width, height = outerSize.height) {
                        placeables.forEach { it.place(x = (-dragOffset.width * sizeRatio.width).toInt(), y = (-dragOffset.height * sizeRatio.height).toInt(), zIndex = 0F) }
                    }
                }
            }
        )
        if (barSize.width > 0) {
            Box(
                modifier = Modifier.fillMaxWidth()
                    .height(scrollBarStroke.ddp)
                    .align(Alignment.BottomStart)
                    .background(Color.Transparent)
            ) {
                Box(
                    modifier = Modifier.align(Alignment.BottomStart)
                        .fillMaxHeight()
                        .offset { IntOffset(dragOffset.width.roundToInt(), 0) }
                        .width(barSize.width.ddp)
                        .clip(RoundedCornerShape(4.ddp)) // 注意先 clip 再 background
                        .background(scrollBarColor) // 注意先 offset 再 background
                        .draggable(state = rememberDraggableState {
                            var widthOffset = dragOffset.width
                            widthOffset += it
                            // 限制拖动不要超过两端
                            // 注意水平、垂直滚动条会互相影响,当两方同时存在时,应该控制大小,不要相交在 BottomEnd
                            if (widthOffset < 0) {
                                widthOffset = 0F
                            } else if (widthOffset > outerSize.width - barSize.width) {
                                widthOffset = (outerSize.width - barSize.width)
                            }
                            dragOffset = TwoFloats(
                                width = widthOffset,
                                height = dragOffset.height
                            )

                        }, orientation = Orientation.Horizontal)
                )
            }
        }
        if (barSize.height > 0) {
            Box(
                modifier = Modifier.fillMaxHeight()
                    .width(scrollBarStroke.ddp)
                    .align(Alignment.TopEnd)
                    .background(Color.Transparent)
            ) {
                Box(
                    modifier = Modifier.align(Alignment.TopEnd)
                        .fillMaxWidth()
                        .offset { IntOffset(0, dragOffset.height.roundToInt()) }
                        .height(barSize.height.ddp)
                        .clip(RoundedCornerShape(4.ddp))
                        .background(scrollBarColor)
                        .draggable(state = rememberDraggableState {
                            var heightOffset = dragOffset.height
                            heightOffset += it
                            if (heightOffset < 0) {
                                heightOffset = 0F
                            } else if (heightOffset > outerSize.height - barSize.height) {
                                heightOffset = (outerSize.height - barSize.height)
                            }
                            dragOffset = TwoFloats(
                                width = dragOffset.width,
                                height = heightOffset
                            )

                        }, orientation = Orientation.Vertical)
                )
            }
        }
    }
}

3… 使用

fun main(args: Array<String>) = application {
    YCRWindow(
        onCloseRequest = {
            exitApplication()
        },
        title = "Test",
        state = rememberWindowState(width = 150.ddp, height = 150.ddp, position = WindowPosition.Aligned(Alignment.Center)),
        icon = loadSvgPainter("love.svg")
    ) {
        Box(
            modifier = Modifier.size(100.ddp, 100.ddp)
        ) {
            UniversalScrollBox(
                modifier = Modifier.fillMaxSize()
            ) {
                Row(modifier = Modifier.wrapContentSize()) {
                    repeat(10) {
                        Column(modifier = Modifier.wrapContentSize()) {
                            repeat(10) {
                                Text(
                                    text = " 123 "
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值