Jetpack Compose 滚动条

Jetpack Compose Scrollbars

有没有办法添加滚动条来添加LazyColumnScrollableColumn已弃用)。 Javadoc 没有提及 Jetpack Compose 中的滚动条。

澄清一下,这是我要实现的设计:

甚至可以在 Jetpack Compose 中做到这一点吗?或者不支持滚动条?

这在 LazyColumn/LazyRow 中还不可能。

计划在某个时候添加,但还没有具体的计划发布。我会在可能的时候更新这个答案。

现在实际上是可能的(他们已经在 LazyListState 中添加了更多的东西)而且很容易做到。这是一个非常原始的滚动条(总是 visible/can 而不是 drag/etc),它使用项目索引来确定拇指位置,因此在只有少数项目的列表中滚动时它可能看起来不太好:

  @Composable
  fun Modifier.simpleVerticalScrollbar(
    state: LazyListState,
    width: Dp = 8.dp
  ): Modifier {
    val targetAlpha = if (state.isScrollInProgress) 1f else 0f
    val duration = if (state.isScrollInProgress) 150 else 500

    val alpha by animateFloatAsState(
      targetValue = targetAlpha,
      animationSpec = tween(durationMillis = duration)
    )

    return drawWithContent {
      drawContent()

      val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index
      val needDrawScrollbar = state.isScrollInProgress || alpha > 0.0f

      // Draw scrollbar if scrolling or if the animation is still running and lazy column has content
      if (needDrawScrollbar && firstVisibleElementIndex != null) {
        val elementHeight = this.size.height / state.layoutInfo.totalItemsCount
        val scrollbarOffsetY = firstVisibleElementIndex * elementHeight
        val scrollbarHeight = state.layoutInfo.visibleItemsInfo.size * elementHeight

        drawRect(
          color = Color.Red,
          topLeft = Offset(this.size.width - width.toPx(), scrollbarOffsetY),
          size = Size(width.toPx(), scrollbarHeight),
          alpha = alpha
        )
      }
    }
  }

更新: 我已经更新了代码。我已经弄清楚如何在滚动或不滚动 LazyColumn 时 show/hide 滚动条 + 添加淡入淡出 in/out 动画。我还将 drawBehind() 更改为 drawWithContent(),因为前者绘制在内容后面,因此在某些情况下它可能绘制在滚动条的顶部,这很可能是不可取的。

这可能会有所帮助:https://github.com/sahruday/Carousel 一种类似的方法,将 Compose 作为可组合函数。

适用于 CarouselScrollState(在 ScrollState 上添加的参数)和 LazyList

如果高度变化或混合项目,我不建议添加滚动指示器。

我采用了 并在此基础上构建:

  • 除了“旋钮”之外还添加了一个“轨道”
  • 通用化垂直和水平滚动条的解决方案
  • 提取了多个参数以帮助自定义滚动条行为;包括断言其有效性的代码
  • 更正了旋钮在滚动时改变其大小的问题,添加了在项目没有统一大小的情况下传递 fixedKnobRatio 参数的能力
  • 添加了文档和评论
/**
 * Renders a scrollbar.
 *
 * <ul> <li> A scrollbar is composed of two components: a track and a knob. The knob moves across
 * the track <li> The scrollbar appears automatically when the user starts scrolling and disappears
 * after the scrolling is finished </ul>
 *
 * @param state The [LazyListState] that has been passed into the lazy list or lazy row
 * @param horizontal If `true`, this will be a horizontally-scrolling (left and right) scroll bar,
 * if `false`, it will be vertically-scrolling (up and down)
 * @param alignEnd If `true`, the scrollbar will appear at the "end" of the scrollable composable it
 * is decorating (at the right-hand side in left-to-right locales or left-hand side in right-to-left
 * locales, for the vertical scrollbars -or- the bottom for horizontal scrollbars). If `false`, the
 * scrollbar will appear at the "start" of the scrollable composable it is decorating (at the
 * left-hand side in left-to-right locales or right-hand side in right-to-left locales, for the
 * vertical scrollbars -or- the top for horizontal scrollbars)
 * @param thickness How thick/wide the track and knob should be
 * @param fixedKnobRatio If not `null`, the knob will always have this size, proportional to the
 * size of the track. You should consider doing this if the size of the items in the scrollable
 * composable is not uniform, to avoid the knob from oscillating in size as you scroll through the
 * list
 * @param knobCornerRadius The corner radius for the knob
 * @param trackCornerRadius The corner radius for the track
 * @param knobColor The color of the knob
 * @param trackColor The color of the track. Make it [Color.Transparent] to hide it
 * @param padding Edge padding to "squeeze" the scrollbar start/end in so it's not flush with the
 * contents of the scrollable composable it is decorating
 * @param visibleAlpha The alpha when the scrollbar is fully faded-in
 * @param hiddenAlpha The alpha when the scrollbar is fully faded-out. Use a non-`0` number to keep
 * the scrollbar from ever fading out completely
 * @param fadeInAnimationDurationMs The duration of the fade-in animation when the scrollbar appears
 * once the user starts scrolling
 * @param fadeOutAnimationDurationMs The duration of the fade-out animation when the scrollbar
 * disappears after the user is finished scrolling
 * @param fadeOutAnimationDelayMs Amount of time to wait after the user is finished scrolling before
 * the scrollbar begins its fade-out animation
 */
@Composable
fun Modifier.scrollbar(
    state: LazyListState,
    horizontal: Boolean,
    alignEnd: Boolean = true,
    thickness: Dp = 4.dp,
    fixedKnobRatio: Float? = null,
    knobCornerRadius: Dp = 4.dp,
    trackCornerRadius: Dp = 2.dp,
    knobColor: Color = Color.Black,
    trackColor: Color = Color.White,
    padding: Dp = 0.dp,
    visibleAlpha: Float = 1f,
    hiddenAlpha: Float = 0f,
    fadeInAnimationDurationMs: Int = 150,
    fadeOutAnimationDurationMs: Int = 500,
    fadeOutAnimationDelayMs: Int = 1000,
): Modifier {
  check(thickness > 0.dp) { "Thickness must be a positive integer." }
  check(fixedKnobRatio == null || fixedKnobRatio < 1f) {
    "A fixed knob ratio must be smaller than 1."
  }
  check(knobCornerRadius >= 0.dp) { "Knob corner radius must be greater than or equal to 0." }
  check(trackCornerRadius >= 0.dp) { "Track corner radius must be greater than or equal to 0." }
  check(hiddenAlpha <= visibleAlpha) { "Hidden alpha cannot be greater than visible alpha." }
  check(fadeInAnimationDurationMs >= 0) {
    "Fade in animation duration must be greater than or equal to 0."
  }
  check(fadeOutAnimationDurationMs >= 0) {
    "Fade out animation duration must be greater than or equal to 0."
  }
  check(fadeOutAnimationDelayMs >= 0) {
    "Fade out animation delay must be greater than or equal to 0."
  }

  val targetAlpha =
      if (state.isScrollInProgress) {
        visibleAlpha
      } else {
        hiddenAlpha
      }
  val animationDurationMs =
      if (state.isScrollInProgress) {
        fadeInAnimationDurationMs
      } else {
        fadeOutAnimationDurationMs
      }
  val animationDelayMs =
      if (state.isScrollInProgress) {
        0
      } else {
        fadeOutAnimationDelayMs
      }

  val alpha by
      animateFloatAsState(
          targetValue = targetAlpha,
          animationSpec =
              tween(delayMillis = animationDelayMs, durationMillis = animationDurationMs))

  return drawWithContent {
    drawContent()

    state.layoutInfo.visibleItemsInfo.firstOrNull()?.let { firstVisibleItem ->
      if (state.isScrollInProgress || alpha > 0f) {
        // Size of the viewport, the entire size of the scrollable composable we are decorating with
        // this scrollbar.
        val viewportSize =
            if (horizontal) {
              size.width
            } else {
              size.height
            } - padding.toPx() * 2

        // The size of the first visible item. We use this to estimate how many items can fit in the
        // viewport. Of course, this works perfectly when all items have the same size. When they
        // don't, the scrollbar knob size will grow and shrink as we scroll.
        val firstItemSize = firstVisibleItem.size

        // The *estimated* size of the entire scrollable composable, as if it's all on screen at
        // once. It is estimated because it's possible that the size of the first visible item does
        // not represent the size of other items. This will cause the scrollbar knob size to grow
        // and shrink as we scroll, if the item sizes are not uniform.
        val estimatedFullListSize = firstItemSize * state.layoutInfo.totalItemsCount

        // The difference in position between the first pixels visible in our viewport as we scroll
        // and the top of the fully-populated scrollable composable, if it were to show all the
        // items at once. At first, the value is 0 since we start all the way to the top (or start
        // edge). As we scroll down (or towards the end), this number will grow.
        val viewportOffsetInFullListSpace =
            state.firstVisibleItemIndex * firstItemSize + state.firstVisibleItemScrollOffset

        // Where we should render the knob in our composable.
        val knobPosition =
            (viewportSize / estimatedFullListSize) * viewportOffsetInFullListSpace + padding.toPx()
        // How large should the knob be.
        val knobSize =
            fixedKnobRatio?.let { it * viewportSize }
                ?: (viewportSize * viewportSize) / estimatedFullListSize

        // Draw the track
        drawRoundRect(
            color = trackColor,
            topLeft =
                when {
                  // When the scrollbar is horizontal and aligned to the bottom:
                  horizontal && alignEnd -> Offset(padding.toPx(), size.height - thickness.toPx())
                  // When the scrollbar is horizontal and aligned to the top:
                  horizontal && !alignEnd -> Offset(padding.toPx(), 0f)
                  // When the scrollbar is vertical and aligned to the end:
                  alignEnd -> Offset(size.width - thickness.toPx(), padding.toPx())
                  // When the scrollbar is vertical and aligned to the start:
                  else -> Offset(0f, padding.toPx())
                },
            size =
                if (horizontal) {
                  Size(size.width - padding.toPx() * 2, thickness.toPx())
                } else {
                  Size(thickness.toPx(), size.height - padding.toPx() * 2)
                },
            alpha = alpha,
            cornerRadius = CornerRadius(x = trackCornerRadius.toPx(), y = trackCornerRadius.toPx()),
        )

        // Draw the knob
        drawRoundRect(
            color = knobColor,
            topLeft =
                when {
                  // When the scrollbar is horizontal and aligned to the bottom:
                  horizontal && alignEnd -> Offset(knobPosition, size.height - thickness.toPx())
                  // When the scrollbar is horizontal and aligned to the top:
                  horizontal && !alignEnd -> Offset(knobPosition, 0f)
                  // When the scrollbar is vertical and aligned to the end:
                  alignEnd -> Offset(size.width - thickness.toPx(), knobPosition)
                  // When the scrollbar is vertical and aligned to the start:
                  else -> Offset(0f, knobPosition)
                },
            size =
                if (horizontal) {
                  Size(knobSize, thickness.toPx())
                } else {
                  Size(thickness.toPx(), knobSize)
                },
            alpha = alpha,
            cornerRadius = CornerRadius(x = knobCornerRadius.toPx(), y = knobCornerRadius.toPx()),
        )
      }
    }
  }
}

Copy-paste 将以下代码放入单个 Kotlin 文件中。

import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch


@ExperimentalAnimationApi
@ExperimentalMaterialApi
@ExperimentalComposeUiApi
@ExperimentalFoundationApi
@RequiresApi(Build.VERSION_CODES.N)
@Composable
fun <T> LazyColumnWithScrollbar(
    data: List<T>,
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
//                            reverseLayout: Boolean = false,
//                            verticalArrangement: Arrangement.Vertical =
//                                if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
    content: LazyListScope.() -> Unit
) {
    val coroutineContext = rememberCoroutineScope()
    val animationCoroutineContext = rememberCoroutineScope()

    val offsetY = remember { mutableStateOf(0f) }
    val isUserScrollingLazyColumn = remember {
        mutableStateOf(true)
    }
    val heightInPixels = remember {
        mutableStateOf(0F)
    }
    val firstVisibleItem = remember {
        mutableStateOf(0)
    }
    val isScrollbarVisible = remember {
        mutableStateOf(false)
    }

    BoxWithConstraints(modifier = modifier) {
        LazyColumn(state = state,
            contentPadding = contentPadding,
//            reverseLayout = reverseLayout,
//        verticalArrangement = verticalArrangement,
            horizontalAlignment = horizontalAlignment,
            flingBehavior = flingBehavior,
            modifier = Modifier.pointerInput(Unit) {
                detectTapGestures(onPress = {
                    isUserScrollingLazyColumn.value = true
                    heightInPixels.value = maxHeight.toPx()
                },
                    onTap = {
                        isUserScrollingLazyColumn.value = true
                        heightInPixels.value = maxHeight.toPx()
                    })
            }
        ) {
            if (!state.isScrollInProgress) {
                isUserScrollingLazyColumn.value = true
                hideScrollbar(animationCoroutineContext, isScrollbarVisible)

                if (state.layoutInfo.visibleItemsInfo.isNotEmpty()) {
                    firstVisibleItem.value = state.layoutInfo.visibleItemsInfo.first().index
                }
            } else if (state.isScrollInProgress && isUserScrollingLazyColumn.value) {
                showScrollbar(animationCoroutineContext, isScrollbarVisible)

                if (heightInPixels.value != 0F) {

                    if (firstVisibleItem.value > state.layoutInfo.visibleItemsInfo.first().index || // Scroll to upper start of list
                        state.layoutInfo.visibleItemsInfo.first().index == 0 // Reached the upper start of list
                    ) {
                        if (state.layoutInfo.visibleItemsInfo.first().index == 0) {
                            offsetY.value = 0F
                        } else {
                            offsetY.value = calculateScrollbarOffsetY(state, data.size, heightInPixels)
                        }
                    } else { // scroll to bottom end of list or reach the bottom end of the list
                        if (state.layoutInfo.visibleItemsInfo.last().index == data.lastIndex) {
                            offsetY.value = heightInPixels.value - heightInPixels.value / 3F
                        } else {
                            offsetY.value = calculateScrollbarOffsetY(state, data.size, heightInPixels)
                        }
                    }

                }
            }
            content()
        }
        if (state.layoutInfo.visibleItemsInfo.size < data.size) {
            AnimatedVisibility(
                visible = isScrollbarVisible.value,
                enter = fadeIn(
                    animationSpec = tween(
                        durationMillis = 200,
                        easing = LinearEasing
                    )
                ),
                exit = fadeOut(
                    animationSpec = tween(
                        delayMillis = 1000,
                        durationMillis = 1000,
                        easing = LinearEasing
                    )
                ),
                modifier = Modifier.align(Alignment.CenterEnd)
            ) {
                Canvas(modifier = Modifier
                    .width(15.dp)
                    .height(maxHeight)
                    .align(Alignment.CenterEnd)
                    .background(Color.Transparent)
                    .pointerInput(Unit) {
                        heightInPixels.value = maxHeight.toPx()
                        detectDragGestures { change, dragAmount ->
                            change.consumeAllChanges()

                            showScrollbar(animationCoroutineContext, isScrollbarVisible)

                            isUserScrollingLazyColumn.value = false
                            if (dragAmount.y > 0) { // drag slider down
                                if (offsetY.value >= (maxHeight.toPx() - maxHeight.toPx() / 3F)) { // Bottom End
                                    offsetY.value = maxHeight.toPx() - maxHeight.toPx() / 3F
                                    coroutineContext.launch {
                                        state.scrollToItem(data.lastIndex)
                                    }
                                } else {
                                    offsetY.value = offsetY.value + dragAmount.y
                                }
                            } else { // drag slider up
                                if (offsetY.value <= 0f) { // Top Start
                                    offsetY.value = 0F
                                    coroutineContext.launch {
                                        state.scrollToItem(0)
                                    }
                                } else {
                                    offsetY.value = offsetY.value + dragAmount.y
                                }
                            }
                            val yMaxValue = maxHeight.toPx() - maxHeight.toPx() / 3F
                            val yPercentage = (100 * offsetY.value) / yMaxValue

                            /* The items which could be rendered should not be taken under account
                            otherwise you are going to show the last rendered items before
                            the scrollbar reaches the bottom.
                            Change the renderedItemsNumberPerScroll = 0 and scroll to the bottom
                            and you will understand.
                             */
                            val renderedItemsNumberPerScroll =
                                state.layoutInfo.visibleItemsInfo.size - 2
                            val index =
                                (((data.lastIndex - renderedItemsNumberPerScroll) * yPercentage) / 100).toInt()

                            coroutineContext.launch {
                                if (index > 0) {
                                    state.scrollToItem(index)
                                }
                            }
                        }
                    }
                ) {
                    drawRoundRect(
                        topLeft = Offset(0f, offsetY.value),
                        color = Color.DarkGray,
                        size = Size(size.width / 2F, maxHeight.toPx() / 3F),
                        cornerRadius = CornerRadius(20F, 20F)
                    )
                }
            }
        }
    }
}

private fun hideScrollbar(coroutineScope: CoroutineScope, state: MutableState<Boolean>) {
    coroutineScope.launch {
        state.value = false
    }
}

private fun showScrollbar(coroutineScope: CoroutineScope, state: MutableState<Boolean>) {
    coroutineScope.launch {
        state.value = true
    }
}

/* The items which are already shown on screen should not be taken
for calculations because they are already on screen!
You have to calculate the items remaining off screen as the 100%
of the data and match this percentage with the distance travelled
by the scrollbar.
*/
private fun calculateScrollbarOffsetY(
    state: LazyListState, dataSize: Int,
    height: MutableState<Float>
): Float {
    val renderedItemsNumberPerScroll =
        state.layoutInfo.visibleItemsInfo.size - 2
    val itemsToScroll = dataSize - renderedItemsNumberPerScroll
    val index = state.layoutInfo.visibleItemsInfo.first().index
    val indexPercentage = ((100 * index) / itemsToScroll)

    val yMaxValue = height.value - height.value / 3F

    return ((yMaxValue * indexPercentage) / 100)
}

然后调用 Composable 函数 LazyColumnWithScrollbar。该函数的参数与LazyColumn.

类似