42

Is there any way to add Scrollbars to add LazyColumn (ScrollableColumn is deprecated). The Javadoc doesn't mention anything about Scrollbars in Jetpack Compose.

Just to clarify, this is the design I want to implement: 1

Is it even possible to do that in Jetpack Compose yet? Or there is no support for Scrollbars?

Yannick
  • 4,833
  • 8
  • 38
  • 63

10 Answers10

50

It's actually possible now (they've added more stuff into LazyListState) and it's pretty easy to do. This is a pretty primitive scrollbar (always visible/can't drag/etc) and it uses item indexes to figure out thumb position so it may not look good when scrolling in lists with only few items:

  @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
        )
      }
    }
  }

UPD: I've updated the code. I've figured out how to show/hide scrollbar when LazyColumn is being scrolled or not + added fade in/out animation. I also changed drawBehind() to drawWithContent() because the former draws behind the content so it may probably draw on top of the scrollbar in some cases which is most likely not desireable.

Dmitry
  • 2,326
  • 1
  • 13
  • 18
  • Interesting approach but on on my end in combination with the composed paging library the scrollbar is permanently changing height and the scrolling looks awful. Even with 200 items. – Nimmi Jun 20 '21 at 14:47
  • Yeah this is most likely because of `state.layoutInfo.visibleItemsInfo` being constantly changed (and this depends on how how much the items differ in height). I think if instead of using `visibleItemsInfo.size` as is you recalculate the mean amount of visible items it should become more smooth. RecyclerView is doing similar calculations internally to draw scrollbars. – Dmitry Jun 20 '21 at 15:22
  • 1
    I don't know if the drawBehind modifier isn't really optimized or if it has something to do with the LazyColumn but as soon as I add the scroll bar the whole list is more laggy than before. There are so many edges where they have to improve performance before compose is production ready. – Nimmi Jun 20 '21 at 16:05
  • Can't say anything about performance. It worked fine in the emulator but then again in my case the LazyColumn content consists of only two Text() functions. – Dmitry Jun 20 '21 at 21:19
  • Do you just pass this modifier to the LazyColumn? – Geert Berkers Jul 30 '21 at 11:15
  • 1
    Yes something like this: LazyVerticalGrid( state = state, cells = GridCells.Adaptive(300.dp), modifier = Modifier .fillMaxSize() .simpleVerticalScrollbar(state) ) { ... } – Dmitry Jul 30 '21 at 13:33
  • What is state here? I can't seem to figure it out since I'm using compose for the first time. Do we just create one and pass it to both methods? – Tamim Attafi Aug 16 '21 at 14:33
  • this.size.height gives height of composable not list. – Zakir Sheikh Nov 23 '21 at 02:57
  • I am using `val state = rememberLazyListState()` but `state.layoutInfo.totalItemsCount` is zero. Any idea? – Marat Feb 16 '22 at 14:18
  • Also, `state.isScrollInProgress` is always `false` even when it is scrolled – Marat Feb 16 '22 at 15:45
  • 1
    You need to use the same LazyListState you pass into LazyColumn. – Dmitry Feb 17 '22 at 10:20
  • I built on this answer and produced this:https://stackoverflow.com/a/71932181/184145 – Warlax Apr 19 '22 at 22:57
  • 2
    This can be done way smoother with this small change: ```val scrollbarOffsetY = firstVisibleElementIndex * elementHeight + state.firstVisibleItemScrollOffset / 4``` and ```val scrollbarHeight = elementHeight * 4``` – Miguel Sesma Oct 05 '22 at 10:44
  • Well, it doesn't work.. – c-an Apr 20 '23 at 03:05
23

This is not yet possible in LazyColumn/LazyRow.

It is planned to be added at some point, but there is not yet a specific planned release for it. I'll update this answer when it is possible.

Ryan M
  • 18,333
  • 31
  • 67
  • 74
  • 37
    This is exactly the reason why compose shouldn't be considered stable or 1.0 until it has reached the parity of features that XML Views have. This is not the first time I come across a basic function that exists in XML views forever, just to find out that "in compose it's not yet possible". Horrible way to promote such an unfinished product from Google. – Chapz Jan 05 '22 at 20:11
  • 8
    IMO, missing scroll bars is not nearly a big enough con to outweigh the many many pros of using compose. I agree that these features that existed in XML should be top priority, and the fact that I am still looking for ways to solve this in 2023 is not a great sign. Still though, compose makes UI development sooooooo much faster. – Tyler Turnbull Jan 15 '23 at 15:34
  • 2
    @TylerTurnbull you must not work with designers. Try telling a reasonable person "sorry, the UI kit I'm working with doesn't support scroll bars" and watch their heads explode. – Jeffrey Blattman Feb 04 '23 at 01:12
  • 3
    Adding scrollbar support is currently in focus on Jetpack Compose Roadmap https://developer.android.com/jetpack/androidx/compose-roadmap – AndrazP Mar 07 '23 at 16:48
  • @AndrazP Actually No. The term `Scrollbars` can be found in the `Backlog` not the `Focus` column of that document. – rwst Apr 30 '23 at 11:26
19

I've taken the answer by @Dmitry and built on top of it:

  • Added a "track" in addition to the "knob"
  • Generalized the solution for both vertical and horizontal scrollbars
  • Extracted multiple parameters to help customize the scrollbar behaviour; including code to assert their validity
  • Corrected the issue of the knob changing its size as you scroll, adding the ability to pass in a fixedKnobRatio parameter for cases where the items do not have a uniform size
  • Added documentation and comments
/**
 * 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()),
        )
      }
    }
  }
}

Warlax
  • 2,459
  • 5
  • 30
  • 41
4

This might be helpful: https://github.com/sahruday/Carousel a similar kind of approach with Compose as Composable function.

Works with both CarouselScrollState (a param added upon ScrollState) and LazyList.

If the height is varying or mixed items I wouldn’t suggest adding a scroll indicator.

2

If you are coming from the JetBrains Compose Multiplatform side of the library (and scroll bars make more sense on desktop, anyway), have a look at https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials/Desktop_Components

They provide VerticalScrollbar/HorizontalScrollbar composables that can be used with lazy scrollable components.

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)
    }
}

UPDATE: Note that the above composable may not work correctly inside an AlertDialog. Instead, create your own Dialog by constructing on a Scaffold.

rwst
  • 2,515
  • 2
  • 30
  • 36
0

Copy-paste the code below into a single Kotlin file.

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)
}

Then call the Composable function LazyColumnWithScrollbar. The parameters of this function are similar with LazyColumn.

LiTTle
  • 1,811
  • 1
  • 20
  • 37
  • It works but it's a little laggy and not accurate when you reach the end of the list. – sfmirtalebi Mar 06 '23 at 15:15
  • @sfmirtalebi probably I have made some changes that could be found here (https://github.com/BILLyTheLiTTle/LazyColumns). It is not a good solution because it has a lot of recompositions, but till an official Google release I think is a good workaround. – LiTTle Mar 07 '23 at 16:22
-1

Build LazyColumn With a Scrollbar Using Jetpack Compose

   @Composable
fun ExampleLazyColumnWithScrollbar(data: List<Int>) {
    val scrollbarSettings = remember {
        mutableStateOf(LazyColumnScrollbarSettings())
    }

    Column(modifier = Modifier.fillMaxSize()) {
        LazyColumnWithScrollbar(
            data = data,
            settings = scrollbarSettings.value,
            modifier = Modifier.height(500.dp)
        ) {
            items(data) {
                Card(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(5.dp)
                        .clickable { },
                    elevation = 10.dp
                ) {
                    Column {
                        Text(
                            text = it.toString(),
                            fontSize = 17.sp,
                            fontWeight = FontWeight.Bold,
                            fontStyle = FontStyle.Italic,
                            modifier = Modifier.padding(start = 10.dp)
                        )
                    }
                }
            }
        }

        Row() {
            Button(modifier = Modifier.fillMaxWidth(0.5F).padding(4.dp),
                contentPadding = PaddingValues(4.dp),
                onClick = {
                    scrollbarSettings.value = scrollbarSettings.value.copy(
                        thumbColor = Color.Green,
                        trailColor = Color.Transparent,
                        thumbWidth = LazyColumnScrollbarSettings.ThumbWidth.X_LARGE,
                        thumbHeight = LazyColumnScrollbarSettings.ThumbHeight.SMALL
                    )
                }
            ) {
                Text(text = "Green + Small + Thick")
            }

            Button(modifier = Modifier.fillMaxWidth(1F).padding(4.dp),
                contentPadding = PaddingValues(4.dp),
                onClick = {
                    scrollbarSettings.value = scrollbarSettings.value.copy(
                        thumbColor = Color.Red,
                        trailColor = Color.Yellow,
                        thumbWidth = LazyColumnScrollbarSettings.ThumbWidth.SMALL,
                        thumbHeight = LazyColumnScrollbarSettings.ThumbHeight.X_LARGE
                    )
                }
            ) {
                Text("Red + Yellow + XL + Thin")
            }
        }
        Button(modifier = Modifier.padding(4.dp).fillMaxWidth(),
            contentPadding = PaddingValues(4.dp),
            onClick = {
                scrollbarSettings.value = LazyColumnScrollbarSettings()
            }
        ) {
            Text("Default")
        }
    }
}
Codeplayon
  • 429
  • 1
  • 6
  • 16
-1

Here, is a simple Kotlin code snippet to create Horizontal ScrollBar using Android Jetpack Compose:

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp


@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DemoLazyRowWithHorizontalScrollbar() {
    val lazyListState = rememberLazyListState()
    LazyRow(
        state = lazyListState, modifier = Modifier
            .fillMaxSize()
            .simpleHorizontalScrollbar(lazyListState)
    ) {
        items(20) {
            Column {
                Spacer(Modifier.height(4.dp))
                Text(text = "Item A -$it")
                Spacer(Modifier.height(4.dp))
                Text(text = "Item B -$it")
                Spacer(Modifier.height(4.dp))
                Text(text = "Item C -$it")
                Spacer(Modifier.height(4.dp))
                Text(text = "Item D -$it")
                Spacer(Modifier.height(8.dp))
            }
        }
    }
}

@Composable
fun Modifier.simpleHorizontalScrollbar(
    state: LazyListState,
    height: Float = 12f,
    backgroundColor: Color = Color.DarkGray,
    color: Color = Color.LightGray
): Modifier {

    return drawWithContent {
        drawContent()

        val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index

        if (firstVisibleElementIndex != null) {

            val scrollableItems = state.layoutInfo.totalItemsCount - state.layoutInfo.visibleItemsInfo.size
            val scrollBarWidth = this.size.width / scrollableItems
            var offsetX = ((this.size.width - scrollBarWidth) * firstVisibleElementIndex) / scrollableItems

            drawRect(
                color = backgroundColor,
                topLeft = Offset(x = 0f, y = this.size.height),
                size = Size(this.size.width, height),
                alpha = 1f
            )

            drawRect(
                color = color,
                topLeft = Offset(x = offsetX, y = this.size.height),
                size = Size(scrollBarWidth, height),
                alpha = 1f
            )
        }
    }
}

Note: Scrollbars are not yet present directly in LazyRow/LazyColumn. But there is good news. Compose has provision to customzie the component in many ways.

*Hope this Helps!

Happy Coding :)*

Rahul Raina
  • 3,322
  • 25
  • 30
-1

This is a very late answer, but for information scroll bars are currently in the backlog of Google. But we can go for a workaround though which we can acheive the scroll visibility.

Create a function that would take a Composable as a parameter. This composable is the view that is scrollable. Implement this using the Android View. Inside this Android view, we will add a liner layout with orientation vertical (can be horizontal if required), this linear layout will include a scroll view, and the view added to this scroll view will be the Composable passed as the parameter of the function.

    @Composable
fun DrawScrollableView(content: @Composable () -> Unit, modifier: Modifier) {
    AndroidView(
        modifier = modifier,
        factory = {
            val scrollView = ScrollView(it)
            val layout = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
            scrollView.layoutParams = layout
            scrollView.isVerticalFadingEdgeEnabled = true
            scrollView.isScrollbarFadingEnabled = false
            scrollView.addView(ComposeView(it).apply {
                setContent {
                    content()
                }
            })
            val linearLayout = LinearLayout(it)
            linearLayout.orientation = LinearLayout.VERTICAL
            linearLayout.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
            linearLayout.addView(scrollView)
            linearLayout
        }
    )
}.   

Now call this function from where ever you require the scroll bar in the scrollable view.

For example, pass a Composable which cannot be contained in the screen.

DrawScrollableView(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight(),
        content = {
            Column {
                repeat(20) {
                    Row {
                        Text(text = "Jetpack scroll view")
                        Spacer(modifier = Modifier.size(20.dp))
                        Image(painter = painterResource(id = R.drawable.jetpack), contentDescription = null)
                    }
                }
            }
        }
    ).   

The modifier passed to the function is full width and full height, this can be altered as per the requirements.

Blue Razor
  • 1
  • 1
  • 4
-2

You can follow below code base for scrolling

For vertical Scroll

Composable
fun ScrollableColumn(
    modifier: Modifier = Modifier,
    scrollState: ScrollState = rememberScrollState(0f),
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalGravity: Alignment.Horizontal = Alignment.Start,
    reverseScrollDirection: Boolean = false,
    isScrollEnabled: Boolean = true,
    contentPadding: InnerPadding = InnerPadding(0.dp),
    children: @Composable ColumnScope.() -> Unit
)

For Horizontal Scrolling

    Composable
fun ScrollableRow(
    modifier: Modifier = Modifier,
    scrollState: ScrollState = rememberScrollState(0f),
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    verticalGravity: Alignment.Vertical = Alignment.Top,
    reverseScrollDirection: Boolean = false,
    isScrollEnabled: Boolean = true,
    contentPadding: InnerPadding = InnerPadding(0.dp),
    children: @Composable RowScope.() -> Unit
)
  • The question was about visual indication of scroll position at the edge of the screen, not about how to make a scrollable column or row. – Waldmann Jul 20 '22 at 02:12