Jetpack Compose 滚动条

Yan*_*ick 9 android android-jetpack-compose

有没有办法添加滚动条来添加LazyColumnScrollableColumn已弃用)。Javadoc 没有提到有关 Jetpack Compose 中滚动条的任何内容。

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

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

War*_*lax 22

我已经接受了@Dmitry 的答案并在此基础上构建:

  • 除了“旋钮”之外还添加了“轨道”
  • 概括了垂直和水平滚动条的解决方案
  • 提取多个参数以帮助自定义滚动条行为;包括断言其有效性的代码
  • 纠正了旋钮在滚动时改变其大小的问题,添加了在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()),
        )
      }
    }
  }
}

Run Code Online (Sandbox Code Playgroud)


Rya*_*ley 10

这在LazyColumn/ 中尚不可能LazyRow

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

  • 这正是 compose 不应该被视为稳定或 1.0 的原因,除非它达到了 XML 视图所具有的同等功能。这不是我第一次遇到永远存在于 XML 视图中的基本函数,只是为了发现“在 compose 中还不可能”。推广谷歌这样一个未完成的产品的方式太糟糕了。 (42认同)
  • 在我看来,缺少滚动条还不足以抵消使用 compose 的许多优点。我同意 XML 中存在的这些功能应该是最优先考虑的,而我仍在寻找 2023 年解决这个问题的方法这一事实并不是一个好兆头。尽管如此,compose 仍然使 UI 开发变得更快。 (10认同)
  • @TylerTurnbull 你不能与设计师合作。尝试告诉一个理性的人“抱歉,我正在使用的 UI 套件不支持滚动条”,然后看着他们的头爆炸。 (3认同)
  • 添加滚动条支持目前主要关注 Jetpack Compose 路线图 https://developer.android.com/jetpack/androidx/compose-roadmap (3认同)

Dmi*_*try 7

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

  @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
        )
      }
    }
  }
Run Code Online (Sandbox Code Playgroud)

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

  • 通过这个小改动,可以更顺利地完成此操作:````valscrollbarOffsetY = firstVisibleElementIndex * elementHeight + state.firstVisibleItemScrollOffset / 4````和````valscrollbarHeight = elementHeight * 4```` (2认同)

rws*_*wst 5

如果您来自库的JetBrains Compose Multiplatform端(无论如何,滚动条在桌面上更有意义),请查看 https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials/桌面_组件

它们提供了可与延迟滚动组件一起使用的VerticalScrollbar/可组合项。HorizontalScrollbar

import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState

fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "Scrollbars",
        state = rememberWindowState(width = 250.dp, height = 400.dp)
    ) {
        LazyScrollable()
    }
}

@Composable
fun LazyScrollable() {
    Box(
        modifier = Modifier.fillMaxSize()
            .background(color = Color(180, 180, 180))
            .padding(10.dp)
    ) {

        val state = rememberLazyListState()

        LazyColumn(Modifier.fillMaxSize().padding(end = 12.dp), state) {
            items(1000) { x ->
                TextBox("Item #$x")
                Spacer(modifier = Modifier.height(5.dp))
            }
        }
        VerticalScrollbar(
            modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
            adapter = rememberScrollbarAdapter(
                scrollState = state
            )
        )
    }
}

@Composable
fun TextBox(text: String = "Item") {
    Box(
        modifier = Modifier.height(32.dp)
            .fillMaxWidth()
            .background(color = Color(0, 0, 0, 20))
            .padding(start = 10.dp),
        contentAlignment = Alignment.CenterStart
    ) {
        Text(text = text)
    }
}
Run Code Online (Sandbox Code Playgroud)

更新:请注意,上述可组合项可能无法在AlertDialog. 相反,通过构建Scaffold.