使用 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 "
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}
/** * @param rows 各行のデータ * @param modifier Modifier */ @Composable fun JAFRSHO12100DataGrid( rows: List<RowData>?, onButtonClick: (Int) -> Unit, modifier: Modifier = Modifier, ) { /** * 画面UIが以下の基本設計書の「画面レイアウト」シートをご参照ください。 * ※JAF_RS-24-基設-車業-画面131-01_画面設計書一覧表示用データグリッド.xlsx */ Column( modifier = modifier.fillMaxSize() ) { Box( modifier = Modifier .weight(1f) .border(1.dp, MaterialTheme.colorScheme.tableBorder) ) { Column { // 一覧表示(LazyColumnで表ヘッダーも含めて表示) val horizontalScrollState = rememberScrollState() val lazyListState = rememberLazyListState() var listSize by remember { mutableStateOf(Size.Zero) } val isVerticalScrollable by remember { derivedStateOf { val totalHeight = lazyListState.layoutInfo.totalItemsCount * (lazyListState.layoutInfo.visibleItemsInfo.firstOrNull()?.size ?: 0) totalHeight > listSize.height } } var scrollEndPadding = 0 val scrollBottomPadding = 0 if (isVerticalScrollable) { scrollEndPadding = 30 } // 表ヘッダー Column( modifier = Modifier .fillMaxWidth() .horizontalScroll(horizontalScrollState) .padding(end = scrollEndPadding.dp) ) { JAFRSHO12100Header( headerHeight = 54, headerStyle = MaterialTheme.typography.tableTitle.copy( fontSize = 16.sp, lineHeight = 16.sp * 1.2f, letterSpacing = 16.sp * 0.02f, ), ) } Box { // 一覧表示 LazyColumn( modifier = Modifier .fillMaxSize() .horizontalScroll(horizontalScrollState) .onSizeChanged { size -> listSize = Size(size.width.toFloat(), size.height.toFloat()) } .padding(bottom = scrollBottomPadding.dp, end = scrollEndPadding.dp), state = lazyListState, ) { // データ行 itemsIndexed(rows ?: emptyList()) { index, rowData -> // 事前に計算されたデータを使用 JAFRSHO12100Row( index, rowData = rowData, onButtonClick = { onButtonClick.invoke(index) }, contentStyle = MaterialTheme.typography.tableText.copy( fontSize = 18.sp, lineHeight = 18.sp * 1.2f, letterSpacing = 18.sp * 0.02f, ), ) } } // スクロールバーを表示する。 if (isVerticalScrollable) { Row( modifier = Modifier .align(Alignment.CenterEnd) .padding(bottom = scrollBottomPadding.dp) ) { JafVerticalScrollbar( modifier = Modifier .padding(end = 2.dp), adapter = rememberScrollbarAdapter(lazyListState), style = ScrollbarStyle( thickness = 10.dp, thumbColor = MaterialTheme.colorScheme.scrollBar, trackColor = Jaf3860BE, showArrow = true, trackSize = 30.dp, autoHide = false ) ) } } } } } } } /** * 垂直スクロールバー * @param modifier モディファイア * @param adapter スクロールバーのアダプター * @param style スクロールバーのスタイル * @param topArrowModifier スクロールバーの上矢印のモディファイア * @param bottomArrowModifier スクロールバーの下矢印のモディファイア */ @Composable fun JafVerticalScrollbar( modifier: Modifier = Modifier, adapter: ScrollbarAdapter, style: ScrollbarStyle = ScrollbarStyle(), topArrowModifier: Modifier = Modifier, bottomArrowModifier: Modifier = Modifier ) { var thumbSizeTmp = style.thumbSize if (thumbSizeTmp == 0.5f) { thumbSizeTmp = adapter.thumbSize } var containerSizeTmp = adapter.containerSize / 1200f * 800 if (style.showArrow) { containerSizeTmp = containerSizeTmp - style.arrowSize - style.arrowSize } // スクロールバーのアニメーション val scrollbarAlpha = remember { Animatable(0f) } var offsetTmp = 0f if (style.showArrow) { offsetTmp = style.arrowSize } // スクロールバーのオフセット val scrollbarOffset = (adapter.scrollOffset * containerSizeTmp + offsetTmp).dp LaunchedEffect(adapter.scrollOffset) { scrollbarAlpha.snapTo(1f) delay(1000) if (style.autoHide) { scrollbarAlpha.animateTo(0f, animationSpec = tween(500)) } } // コルーチンスコープ val coroutineScope = rememberCoroutineScope() // スクロールバーの表示 Box( modifier = modifier .width(max(style.thickness, style.trackSize)) .fillMaxHeight() .alpha(scrollbarAlpha.value) ) { // 背景 if (style.showTrack) { Box( modifier = Modifier .fillMaxHeight() .width(style.trackSize) .clip(style.trackShape) .background(style.trackColor) .align(Alignment.Center) ) } // 矢印 if (style.showArrow) { //  スクロールTopボタン JafImageButton( modifier = topArrowModifier .align(Alignment.TopCenter) .size(style.arrowSize.dp) .padding(3.dp), image = MaterialTheme.resources.scrollbar_arrow_top(), tint = Color.White, onClick = { coroutineScope.launch { if (adapter.scrollOffset > 0f) { adapter.scrollState.animateScrollBy(-100f) } } } ) //  スクロールBottomボタン JafImageButton( modifier = bottomArrowModifier .align(Alignment.BottomCenter) .size(style.arrowSize.dp) .padding(3.dp), image = MaterialTheme.resources.scrollbar_arrow_bottom(), tint = Color.White, onClick = { coroutineScope.launch { if (adapter.scrollOffset < 1f) { adapter.scrollState.animateScrollBy(100f) } } } ) } // スクロールバー Box( modifier = Modifier .align(Alignment.TopCenter) .offset(y = scrollbarOffset) .height((thumbSizeTmp * containerSizeTmp).dp) .width(style.thickness) .clip(style.thumbShape) .background(style.thumbColor) .pointerInput(Unit) { detectDragGestures( onDrag = { change, dragAmount -> coroutineScope.launch { adapter.scrollState.animateScrollBy(dragAmount.y * 100f) change.consume() } } ) } ) } } 以上代码中的JafVerticalScrollbar,垂直方向的滚动条的长度不会随数据的多少而变化
最新发布
10-29
Compose实现类似 GradientDrawable 的 centerColor 效果,可借助 `Brush` 来实现渐变效果。`Brush` 有不同类型,像 `LinearGradient`、`RadialGradient`、`SweepGradient` 等,能根据需求创建不同的渐变。 以下是使用 `LinearGradient` 实现包含中心颜色的线性渐变的示例代码: ```kotlin import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @Composable fun GradientWithCenterColor() { Canvas( modifier = Modifier.size(200.dp) ) { val startColor = Color.Red val centerColor = Color.Green val endColor = Color.Blue val gradient = Brush.linearGradient( colors = listOf(startColor, centerColor, endColor), start = Offset.Zero, end = Offset(size.width, size.height) ) drawRect(brush = gradient) } } ``` 在上述代码里,`Brush.linearGradient` 函数创建了一个线性渐变的 `Brush`,`colors` 参数定义了渐变的颜色,按顺序排列,这里把中心颜色 `centerColor` 放在中间。`start` 和 `end` 参数规定了渐变的起始和结束位置。 对于径向渐变,可使用 `Brush.radialGradient` 函数,示例如下: ```kotlin @Composable fun RadialGradientWithCenterColor() { Canvas( modifier = Modifier.size(200.dp) ) { val startColor = Color.Red val centerColor = Color.Green val endColor = Color.Blue val gradient = Brush.radialGradient( colors = listOf(startColor, centerColor, endColor), center = Offset(size.width / 2f, size.height / 2f), radius = size.width / 2f ) drawCircle(brush = gradient, radius = size.width / 2f) } } ``` 这里 `Brush.radialGradient` 函数创建了一个径向渐变的 `Brush`,同样把中心颜色 `centerColor` 放在中间。`center` 参数确定了渐变的中心位置,`radius` 参数确定了渐变的半径。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值