'Jetpack Compose Scrollbars
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:
Is it even possible to do that in Jetpack Compose yet? Or there is no support for Scrollbars?
Solution 1:[1]
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.
Solution 2:[2]
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.
Solution 3:[3]
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()),
)
}
}
}
}
Solution 4:[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.
Solution 5:[5]
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
.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 | Ryan M |
Solution 3 | |
Solution 4 | Jagarapu Sahruday |
Solution 5 |