From e554513392e4ed13c19760f3bb943ca76f85e4d0 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Mon, 30 Dec 2024 20:05:28 +0700 Subject: [PATCH] style: Scrollbar And add fast scroller component for later --- .../settings/SettingsCommonWidget.kt | 3 +- .../java/yokai/presentation/core/Scrollbar.kt | 249 ++++++++++ .../core/components/VerticalFastScroller.kt | 448 ++++++++++++++++++ 3 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 presentation/core/src/main/java/yokai/presentation/core/Scrollbar.kt create mode 100644 presentation/core/src/main/java/yokai/presentation/core/components/VerticalFastScroller.kt diff --git a/app/src/main/java/yokai/presentation/settings/SettingsCommonWidget.kt b/app/src/main/java/yokai/presentation/settings/SettingsCommonWidget.kt index 7fc0fdb185..d4a02c21ac 100644 --- a/app/src/main/java/yokai/presentation/settings/SettingsCommonWidget.kt +++ b/app/src/main/java/yokai/presentation/settings/SettingsCommonWidget.kt @@ -29,6 +29,7 @@ import yokai.presentation.component.Gap import yokai.presentation.component.preference.Preference import yokai.presentation.component.preference.PreferenceItem import yokai.presentation.component.preference.widget.PreferenceGroupHeader +import yokai.presentation.core.drawVerticalScrollbar import yokai.presentation.core.enterAlwaysCollapsedScrollBehavior @Composable @@ -106,7 +107,7 @@ fun PreferenceScreen( } LazyColumn( - modifier = modifier, + modifier = modifier.drawVerticalScrollbar(listState), contentPadding = contentPadding, state = listState ) { diff --git a/presentation/core/src/main/java/yokai/presentation/core/Scrollbar.kt b/presentation/core/src/main/java/yokai/presentation/core/Scrollbar.kt new file mode 100644 index 0000000000..89bbc112be --- /dev/null +++ b/presentation/core/src/main/java/yokai/presentation/core/Scrollbar.kt @@ -0,0 +1,249 @@ +package yokai.presentation.core + +import android.view.ViewConfiguration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +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.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.ui.util.fastSumBy +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import yokai.presentation.core.components.Scroller.STICKY_HEADER_KEY_PREFIX + +/** + * Draws horizontal scrollbar to a LazyList. + * + * Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list. + */ +fun Modifier.drawHorizontalScrollbar( + state: LazyListState, + reverseScrolling: Boolean = false, + // The amount of offset the scrollbar position towards the top of the layout + positionOffsetPx: Float = 0f, +): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling, positionOffsetPx) + +/** + * Draws vertical scrollbar to a LazyList. + * + * Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list. + */ +fun Modifier.drawVerticalScrollbar( + state: LazyListState, + reverseScrolling: Boolean = false, + // The amount of offset the scrollbar position towards the start of the layout + positionOffsetPx: Float = 0f, +): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling, positionOffsetPx) + +private fun Modifier.drawScrollbar( + state: LazyListState, + orientation: Orientation, + reverseScrolling: Boolean, + positionOffset: Float, +): Modifier = drawScrollbar( + orientation, + reverseScrolling, +) { reverseDirection, atEnd, thickness, color, alpha -> + val layoutInfo = state.layoutInfo + val viewportSize = if (orientation == Orientation.Horizontal) { + layoutInfo.viewportSize.width + } else { + layoutInfo.viewportSize.height + } - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding + val items = layoutInfo.visibleItemsInfo + val itemsSize = items.fastSumBy { it.size } + val showScrollbar = items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize + val estimatedItemSize = if (items.isEmpty()) 0f else itemsSize.toFloat() / items.size + val totalSize = estimatedItemSize * layoutInfo.totalItemsCount + val thumbSize = viewportSize / totalSize * viewportSize + val startOffset = if (items.isEmpty()) { + 0f + } else { + items + .fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true } + ?.run { + val startPadding = if (reverseDirection) { + layoutInfo.afterContentPadding + } else { + layoutInfo.beforeContentPadding + } + startPadding + ((estimatedItemSize * index - offset) / totalSize * viewportSize) + } ?: 0f + } + val drawScrollbar = onDrawScrollbar( + orientation, reverseDirection, atEnd, showScrollbar, + thickness, color, alpha, thumbSize, startOffset, positionOffset, + ) + drawContent() + drawScrollbar() +} + +private fun ContentDrawScope.onDrawScrollbar( + orientation: Orientation, + reverseDirection: Boolean, + atEnd: Boolean, + showScrollbar: Boolean, + thickness: Float, + color: Color, + alpha: () -> Float, + thumbSize: Float, + scrollOffset: Float, + positionOffset: Float, +): DrawScope.() -> Unit { + val topLeft = if (orientation == Orientation.Horizontal) { + Offset( + if (reverseDirection) size.width - scrollOffset - thumbSize else scrollOffset, + if (atEnd) size.height - positionOffset - thickness else positionOffset, + ) + } else { + Offset( + if (atEnd) size.width - positionOffset - thickness else positionOffset, + if (reverseDirection) size.height - scrollOffset - thumbSize else scrollOffset, + ) + } + val size = if (orientation == Orientation.Horizontal) { + Size(thumbSize, thickness) + } else { + Size(thickness, thumbSize) + } + + return { + if (showScrollbar) { + drawRect( + color = color, + topLeft = topLeft, + size = size, + alpha = alpha(), + ) + } + } +} + +private fun Modifier.drawScrollbar( + orientation: Orientation, + reverseScrolling: Boolean, + onDraw: ContentDrawScope.( + reverseDirection: Boolean, + atEnd: Boolean, + thickness: Float, + color: Color, + alpha: () -> Float, + ) -> Unit, +): Modifier = composed { + val scrolled = remember { + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + } + val nestedScrollConnection = remember(orientation, scrolled) { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + val delta = if (orientation == Orientation.Horizontal) consumed.x else consumed.y + if (delta != 0f) scrolled.tryEmit(Unit) + return Offset.Zero + } + } + } + + val alpha = remember { Animatable(0f) } + LaunchedEffect(scrolled, alpha) { + scrolled + .sample(100) + .collectLatest { + alpha.snapTo(1f) + alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec) + } + } + + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val reverseDirection = if (orientation == Orientation.Horizontal) { + if (isLtr) reverseScrolling else !reverseScrolling + } else { + reverseScrolling + } + val atEnd = if (orientation == Orientation.Vertical) isLtr else true + + val context = LocalContext.current + val thickness = remember { ViewConfiguration.get(context).scaledScrollBarSize.toFloat() } + val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.364f) + Modifier + .nestedScroll(nestedScrollConnection) + .drawWithContent { + onDraw(reverseDirection, atEnd, thickness, color, alpha::value) + } +} + +private val FadeOutAnimationSpec = tween( + durationMillis = ViewConfiguration.getScrollBarFadeDuration(), + delayMillis = ViewConfiguration.getScrollDefaultDelay(), +) + +@Preview(widthDp = 400, heightDp = 400, showBackground = true) +@Composable +fun LazyListScrollbarPreview() { + val state = rememberLazyListState() + LazyColumn( + modifier = Modifier.drawVerticalScrollbar(state), + state = state, + ) { + items(50) { + Text( + text = "Item ${it + 1}", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } +} + +@Preview(widthDp = 400, showBackground = true) +@Composable +fun LazyListHorizontalScrollbarPreview() { + val state = rememberLazyListState() + LazyRow( + modifier = Modifier.drawHorizontalScrollbar(state), + state = state, + ) { + items(50) { + Text( + text = (it + 1).toString(), + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + } + } +} diff --git a/presentation/core/src/main/java/yokai/presentation/core/components/VerticalFastScroller.kt b/presentation/core/src/main/java/yokai/presentation/core/components/VerticalFastScroller.kt new file mode 100644 index 0000000000..27221e5e64 --- /dev/null +++ b/presentation/core/src/main/java/yokai/presentation/core/components/VerticalFastScroller.kt @@ -0,0 +1,448 @@ +package yokai.presentation.core.components + +import android.view.ViewConfiguration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.systemGestureExclusion +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMaxBy +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import yokai.presentation.core.components.Scroller.STICKY_HEADER_KEY_PREFIX + +/** + * Draws vertical fast scroller to a lazy list + * + * Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list. + */ +@Composable +fun VerticalFastScroller( + listState: LazyListState, + modifier: Modifier = Modifier, + thumbAllowed: () -> Boolean = { true }, + thumbColor: Color = MaterialTheme.colorScheme.primary, + topContentPadding: Dp = Dp.Hairline, + bottomContentPadding: Dp = Dp.Hairline, + endContentPadding: Dp = Dp.Hairline, + content: @Composable () -> Unit, +) { + SubcomposeLayout(modifier = modifier) { constraints -> + val contentPlaceable = subcompose("content", content).map { it.measure(constraints) } + val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0 + val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0 + + val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val scrollerPlaceable = subcompose("scroller") { + val layoutInfo = listState.layoutInfo + val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount + if (!showScroller) return@subcompose + + val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() } + var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) } + + val dragInteractionSource = remember { MutableInteractionSource() } + val isThumbDragged by dragInteractionSource.collectIsDraggedAsState() + val scrolled = remember { + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + } + + val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() } + val heightPx = contentHeight.toFloat() - + thumbTopPadding - + thumbBottomPadding - + listState.layoutInfo.afterContentPadding + val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() } + val trackHeightPx = heightPx - thumbHeightPx + + // When thumb dragged + LaunchedEffect(thumbOffsetY) { + if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect + val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx + val scrollItem = layoutInfo.totalItemsCount * scrollRatio + val scrollItemRounded = scrollItem.roundToInt() + val scrollItemSize = layoutInfo.visibleItemsInfo.find { it.index == scrollItemRounded }?.size ?: 0 + val scrollItemOffset = scrollItemSize * (scrollItem - scrollItemRounded) + listState.scrollToItem(index = scrollItemRounded, scrollOffset = scrollItemOffset.roundToInt()) + scrolled.tryEmit(Unit) + } + + // When list scrolled + LaunchedEffect(listState.firstVisibleItemScrollOffset) { + if (listState.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect + val scrollOffset = computeScrollOffset(state = listState) + val scrollRange = computeScrollRange(state = listState) + val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx) + thumbOffsetY = trackHeightPx * proportion + thumbTopPadding + scrolled.tryEmit(Unit) + } + + // Thumb alpha + val alpha = remember { Animatable(0f) } + val isThumbVisible = alpha.value > 0f + LaunchedEffect(scrolled, alpha) { + scrolled + .sample(100) + .collectLatest { + if (thumbAllowed()) { + alpha.snapTo(1f) + alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec) + } else { + alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec) + } + } + } + + Box( + modifier = Modifier + .offset { IntOffset(0, thumbOffsetY.roundToInt()) } + .then( + // Recompose opts + if (isThumbVisible && !listState.isScrollInProgress) { + Modifier.draggable( + interactionSource = dragInteractionSource, + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + val newOffsetY = thumbOffsetY + delta + thumbOffsetY = newOffsetY.coerceIn( + thumbTopPadding, + thumbTopPadding + trackHeightPx, + ) + }, + ) + } else { + Modifier + }, + ) + .then( + // Exclude thumb from gesture area only when needed + if (isThumbVisible && !isThumbDragged && !listState.isScrollInProgress) { + Modifier.systemGestureExclusion() + } else { + Modifier + }, + ) + .height(ThumbLength) + .padding(horizontal = 8.dp) + .padding(end = endContentPadding) + .width(ThumbThickness) + .alpha(alpha.value) + .background(color = thumbColor, shape = ThumbShape), + ) + }.map { it.measure(scrollerConstraints) } + val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0 + + layout(contentWidth, contentHeight) { + contentPlaceable.fastForEach { + it.place(0, 0) + } + scrollerPlaceable.fastForEach { + it.placeRelative(contentWidth - scrollerWidth, 0) + } + } + } +} + +@Composable +private fun rememberColumnWidthSums( + columns: GridCells, + horizontalArrangement: Arrangement.Horizontal, + contentPadding: PaddingValues, +) = remember List>( + columns, + horizontalArrangement, + contentPadding, +) { + { constraints -> + require(constraints.maxWidth != Constraints.Infinity) { + "LazyVerticalGrid's width should be bound by parent" + } + val horizontalPadding = contentPadding.calculateStartPadding(LayoutDirection.Ltr) + + contentPadding.calculateEndPadding(LayoutDirection.Ltr) + val gridWidth = constraints.maxWidth - horizontalPadding.roundToPx() + with(columns) { + calculateCrossAxisCellSizes( + gridWidth, + horizontalArrangement.spacing.roundToPx(), + ).toMutableList().apply { + for (i in 1.. Boolean = { true }, + thumbColor: Color = MaterialTheme.colorScheme.primary, + topContentPadding: Dp = Dp.Hairline, + bottomContentPadding: Dp = Dp.Hairline, + endContentPadding: Dp = Dp.Hairline, + content: @Composable () -> Unit, +) { + val slotSizesSums = rememberColumnWidthSums( + columns = columns, + horizontalArrangement = arrangement, + contentPadding = contentPadding, + ) + + SubcomposeLayout(modifier = modifier) { constraints -> + val contentPlaceable = subcompose("content", content).map { it.measure(constraints) } + val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0 + val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0 + + val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val scrollerPlaceable = subcompose("scroller") { + val layoutInfo = state.layoutInfo + val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount + if (!showScroller) return@subcompose + val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() } + var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) } + + val dragInteractionSource = remember { MutableInteractionSource() } + val isThumbDragged by dragInteractionSource.collectIsDraggedAsState() + val scrolled = remember { + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + } + + val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() } + val heightPx = contentHeight.toFloat() - + thumbTopPadding - + thumbBottomPadding - + state.layoutInfo.afterContentPadding + val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() } + val trackHeightPx = heightPx - thumbHeightPx + + val columnCount = remember { slotSizesSums(constraints).size } + + // When thumb dragged + LaunchedEffect(thumbOffsetY) { + if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect + val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx + val scrollItem = layoutInfo.totalItemsCount * scrollRatio + // I can't think of anything else rn but this'll do + val scrollItemWhole = scrollItem.toInt() + val columnNum = ((scrollItemWhole + 1) % columnCount).takeIf { it != 0 } ?: columnCount + val scrollItemFraction = if (scrollItemWhole == 0) scrollItem else scrollItem % scrollItemWhole + val offsetPerItem = 1f / columnCount + val offsetRatio = (offsetPerItem * scrollItemFraction) + (offsetPerItem * (columnNum - 1)) + + // TODO: Sometimes item height is not available when scrolling up + val scrollItemSize = (1..columnCount).maxOf { num -> + val actualIndex = if (num != columnNum) { + scrollItemWhole + num - columnCount + } else { + scrollItemWhole + } + layoutInfo.visibleItemsInfo.find { it.index == actualIndex }?.size?.height ?: 0 + } + val scrollItemOffset = scrollItemSize * offsetRatio + + state.scrollToItem(index = scrollItemWhole, scrollOffset = scrollItemOffset.roundToInt()) + scrolled.tryEmit(Unit) + } + + // When list scrolled + LaunchedEffect(state.firstVisibleItemScrollOffset) { + if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect + val scrollOffset = computeScrollOffset(state = state) + val scrollRange = computeScrollRange(state = state) + val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx) + thumbOffsetY = trackHeightPx * proportion + thumbTopPadding + scrolled.tryEmit(Unit) + } + + // Thumb alpha + val alpha = remember { Animatable(0f) } + val isThumbVisible = alpha.value > 0f + LaunchedEffect(scrolled, alpha) { + scrolled + .sample(100) + .collectLatest { + if (thumbAllowed()) { + alpha.snapTo(1f) + alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec) + } else { + alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec) + } + } + } + + Box( + modifier = Modifier + .offset { IntOffset(0, thumbOffsetY.roundToInt()) } + .then( + // Recompose opts + if (isThumbVisible && !state.isScrollInProgress) { + Modifier.draggable( + interactionSource = dragInteractionSource, + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + val newOffsetY = thumbOffsetY + delta + thumbOffsetY = newOffsetY.coerceIn( + thumbTopPadding, + thumbTopPadding + trackHeightPx, + ) + }, + ) + } else { + Modifier + }, + ) + .then( + // Exclude thumb from gesture area only when needed + if (isThumbVisible && !isThumbDragged && !state.isScrollInProgress) { + Modifier.systemGestureExclusion() + } else { + Modifier + }, + ) + .height(ThumbLength) + .padding(end = endContentPadding) + .width(ThumbThickness) + .alpha(alpha.value) + .background(color = thumbColor, shape = ThumbShape), + ) + }.map { it.measure(scrollerConstraints) } + val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0 + + layout(contentWidth, contentHeight) { + contentPlaceable.fastForEach { + it.place(0, 0) + } + scrollerPlaceable.fastForEach { + it.placeRelative(contentWidth - scrollerWidth, 0) + } + } + } +} + +private fun computeScrollOffset(state: LazyGridState): Int { + if (state.layoutInfo.totalItemsCount == 0) return 0 + val visibleItems = state.layoutInfo.visibleItemsInfo + val startChild = visibleItems.first() + val endChild = visibleItems.last() + val minPosition = min(startChild.index, endChild.index) + val maxPosition = max(startChild.index, endChild.index) + val itemsBefore = minPosition.coerceAtLeast(0) + val startDecoratedTop = startChild.offset.y + val laidOutArea = abs((endChild.offset.y + endChild.size.height) - startDecoratedTop) + val itemRange = abs(minPosition - maxPosition) + 1 + val avgSizePerRow = laidOutArea.toFloat() / itemRange + return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt() +} + +private fun computeScrollRange(state: LazyGridState): Int { + if (state.layoutInfo.totalItemsCount == 0) return 0 + val visibleItems = state.layoutInfo.visibleItemsInfo + val startChild = visibleItems.first() + val endChild = visibleItems.last() + val laidOutArea = (endChild.offset.y + endChild.size.height) - startChild.offset.y + val laidOutRange = abs(startChild.index - endChild.index) + 1 + return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt() +} + +private fun computeScrollOffset(state: LazyListState): Int { + if (state.layoutInfo.totalItemsCount == 0) return 0 + val visibleItems = state.layoutInfo.visibleItemsInfo + val startChild = visibleItems + .fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!! + val endChild = visibleItems.last() + val minPosition = min(startChild.index, endChild.index) + val maxPosition = max(startChild.index, endChild.index) + val itemsBefore = minPosition.coerceAtLeast(0) + val startDecoratedTop = startChild.top + val laidOutArea = abs(endChild.bottom - startDecoratedTop) + val itemRange = abs(minPosition - maxPosition) + 1 + val avgSizePerRow = laidOutArea.toFloat() / itemRange + return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt() +} + +private fun computeScrollRange(state: LazyListState): Int { + if (state.layoutInfo.totalItemsCount == 0) return 0 + val visibleItems = state.layoutInfo.visibleItemsInfo + val startChild = visibleItems + .fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!! + val endChild = visibleItems.last() + val laidOutArea = endChild.bottom - startChild.top + val laidOutRange = abs(startChild.index - endChild.index) + 1 + return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt() +} + +object Scroller { + const val STICKY_HEADER_KEY_PREFIX = "sticky:" +} + +private val ThumbLength = 48.dp +private val ThumbThickness = 12.dp +private val ThumbShape = RoundedCornerShape(ThumbThickness / 2) +private val FadeOutAnimationSpec = tween( + durationMillis = ViewConfiguration.getScrollBarFadeDuration(), + delayMillis = 2000, +) +private val ImmediateFadeOutAnimationSpec = tween( + durationMillis = ViewConfiguration.getScrollBarFadeDuration(), +) + +private val LazyListItemInfo.top: Int + get() = offset + +private val LazyListItemInfo.bottom: Int + get() = offset + size