From ca9f4966d6a1cebc8c874e12fc739d94553d34b1 Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Fri, 19 Dec 2025 01:50:51 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=96=B0=E5=A2=9E]=20=E4=B8=BA=E9=83=A8?= =?UTF-8?q?=E5=88=86=E7=94=B1Compose=E9=87=8D=E5=86=99=E7=9A=84=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E5=A2=9E=E5=8A=A0=E5=BF=AB=E9=80=9F=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/ui/book/bookmark/AllBookmarkScreen.kt | 6 +- .../app/ui/replace/ReplaceRuleScreen.kt | 13 +- .../app/ui/widget/components/LazyList.kt | 82 +++ .../widget/components/VerticalFastScroller.kt | 476 ++++++++++++++++++ 4 files changed, 569 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/LazyList.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/VerticalFastScroller.kt diff --git a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt index cfb9e6dab..7952d5a48 100644 --- a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt @@ -34,6 +34,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.legado.app.data.entities.Bookmark +import io.legado.app.ui.widget.components.FastScrollLazyColumn +import io.legado.app.ui.widget.components.Scroller import io.legado.app.ui.widget.components.SearchBarSection import org.koin.androidx.compose.koinViewModel @@ -142,7 +144,7 @@ fun AllBookmarkScreen( } } ) { paddingValues -> - LazyColumn( + FastScrollLazyColumn( modifier = Modifier .fillMaxSize() .padding(paddingValues) @@ -151,7 +153,7 @@ fun AllBookmarkScreen( val isCollapsed = collapsedGroups.contains(headerKey.toString()) - stickyHeader(key = headerKey.toString()) { + stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") { BookAuthorHeader( bookTitle = headerKey.bookName, bookAuthor = headerKey.bookAuthor, diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index 81ea07be7..6905ed9fd 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -46,6 +46,7 @@ import io.legado.app.data.entities.ReplaceRule import io.legado.app.ui.replace.edit.ReplaceEditActivity import io.legado.app.ui.widget.components.ActionItem import io.legado.app.ui.widget.components.AnimatedText +import io.legado.app.ui.widget.components.FastScrollLazyColumn import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SelectionBottomBar import kotlinx.coroutines.coroutineScope @@ -325,14 +326,12 @@ fun ReplaceRuleScreen( modifier = Modifier .fillMaxSize() ) { - LazyColumn( + FastScrollLazyColumn( state = listState, modifier = Modifier .padding(padding) .fillMaxSize(), contentPadding = PaddingValues( - start = 12.dp, - end = 12.dp, top = 8.dp, bottom = 120.dp ), @@ -342,7 +341,9 @@ fun ReplaceRuleScreen( val isSelected = selectedRuleIds.contains(ui.id) ReplaceRuleItem( - modifier = Modifier.animateItem(), + modifier = Modifier + .padding(horizontal = 12.dp) + .animateItem(), name = ui.name, isEnabled = ui.isEnabled, isSelected = isSelected, @@ -631,8 +632,8 @@ fun ReplaceRuleItem( ) ) { ListItem( - modifier = Modifier.animateContentSize(), - + modifier = Modifier + .animateContentSize(), headlineContent = { AnimatedContent(targetState = name, label = "RuleNameAnimation") { targetName -> Text( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/LazyList.kt b/app/src/main/java/io/legado/app/ui/widget/components/LazyList.kt new file mode 100644 index 000000000..01c775ac1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/LazyList.kt @@ -0,0 +1,82 @@ +package io.legado.app.ui.widget.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp + +/** + * LazyColumn with scrollbar (non-controllable). + * This code is copied from the repository https://github.com/komikku-app/komikku + */ +@Composable +fun ScrollbarLazyColumn( + 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, + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit, +) { + val direction = LocalLayoutDirection.current + val density = LocalDensity.current + val positionOffset = remember(contentPadding) { + with(density) { contentPadding.calculateEndPadding(direction).toPx() } + } + LazyColumn( + modifier = modifier, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + userScrollEnabled = userScrollEnabled, + content = content, + ) +} + +/** + * LazyColumn with fast scroller. + */ +@Composable +fun FastScrollLazyColumn( + 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, + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit, +) { + VerticalFastScroller( + listState = state, + modifier = modifier, + topContentPadding = contentPadding.calculateTopPadding(), + endContentPadding = contentPadding.calculateEndPadding(LocalLayoutDirection.current), + ) { + LazyColumn( + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + userScrollEnabled = userScrollEnabled, + content = content, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/VerticalFastScroller.kt b/app/src/main/java/io/legado/app/ui/widget/components/VerticalFastScroller.kt new file mode 100644 index 000000000..beae23495 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/VerticalFastScroller.kt @@ -0,0 +1,476 @@ +package io.legado.app.ui.widget.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.fastLastOrNull +import androidx.compose.ui.util.fastMaxBy +import io.legado.app.ui.widget.components.Scroller.STICKY_HEADER_KEY_PREFIX +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Draws vertical fast scroller to a lazy list + * + * Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list. + * This code is copied from the repository https://github.com/komikku-app/komikku + */ +@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 + if (layoutInfo.visibleItemsInfo.isEmpty() || layoutInfo.totalItemsCount == 0) 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, + ) + } + + // listState.isScrollInProgress occasionally flickers + val scrollStateTracker = remember { MutableData(listState.isScrollInProgress) } + val stableScrollInProgress = scrollStateTracker.value || listState.isScrollInProgress + scrollStateTracker.value = listState.isScrollInProgress + val anyScrollInProgress = stableScrollInProgress || isThumbDragged + + 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 + val scrollHeightPx = contentHeight.toFloat() - + listState.layoutInfo.beforeContentPadding - + listState.layoutInfo.afterContentPadding - + thumbBottomPadding + + val visibleItems = layoutInfo.visibleItemsInfo + val topItem = visibleItems.fastFirstOrNull { + it.bottom >= 0 && + (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true + } ?: visibleItems.first() + val bottomItem = visibleItems.fastLastOrNull { + it.top <= scrollHeightPx && + (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true + } ?: visibleItems.last() + + val topHiddenProportion = -1f * topItem.top / topItem.size.coerceAtLeast(1) + val bottomHiddenProportion = (bottomItem.bottom - scrollHeightPx) / bottomItem.size.coerceAtLeast(1) + val previousSections = topHiddenProportion + topItem.index + val remainingSections = bottomHiddenProportion + (layoutInfo.totalItemsCount - (bottomItem.index + 1)) + val scrollableSections = previousSections + remainingSections + + val layoutChangeTracker = remember { MutableData(scrollableSections) } + val layoutChanged = !anyScrollInProgress && abs(layoutChangeTracker.value - scrollableSections) > 0.1 + layoutChangeTracker.value = scrollableSections + + val estimateConfidence = remember { MutableData(remainingSections) } + if (layoutChanged) estimateConfidence.value = remainingSections + val maxRemainingSections = remember(estimateConfidence.value) { scrollableSections } + estimateConfidence.value = max(estimateConfidence.value, remainingSections) + + if (maxRemainingSections < 0.5) return@subcompose + + // When thumb dragged + LaunchedEffect(thumbOffsetY) { + if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect + val thumbProportion = (thumbOffsetY - thumbTopPadding) / trackHeightPx + if (thumbProportion <= 0.001f) { + estimateConfidence.value = -1f + listState.scrollToItem(index = 0, scrollOffset = 0) + scrolled.tryEmit(Unit) + return@LaunchedEffect + } + val scrollRemainingSections = (1f - thumbProportion) * maxRemainingSections + val currentSection = layoutInfo.totalItemsCount - scrollRemainingSections + val scrollSectionIndex = currentSection.toInt().coerceAtMost(layoutInfo.totalItemsCount) + val expectedScrollItem = visibleItems.find { it.index == scrollSectionIndex } ?: visibleItems.first() + val scrollRelativeOffset = expectedScrollItem.size * (currentSection - scrollSectionIndex) + val scrollSectionOffset = (scrollRelativeOffset - scrollHeightPx).roundToInt() + val scrollItemIndex = scrollSectionIndex.coerceIn(0, layoutInfo.totalItemsCount - 1) + val scrollItemOffset = scrollSectionOffset + (scrollSectionIndex - scrollItemIndex) * bottomItem.size + listState.scrollToItem(index = scrollItemIndex, scrollOffset = scrollItemOffset) + scrolled.tryEmit(Unit) + } + + // When list scrolled + if (layoutInfo.totalItemsCount != 0 && !isThumbDragged) { + val proportion = 1f - remainingSections / maxRemainingSections + thumbOffsetY = trackHeightPx * proportion + thumbTopPadding + if (stableScrollInProgress) 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 = remember(columns, layoutInfo.totalItemsCount) { + 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(columns) { slotSizesSums(constraints).size.coerceAtLeast(1) } + val scrollRange = remember(columns) { computeGridScrollRange(state = state, columnCount = columnCount) } + + // When thumb dragged + LaunchedEffect(thumbOffsetY) { + if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect + 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 laidOutRows = 1 + abs(endChild.index - startChild.index) / columnCount + val avgSizePerRow = laidOutArea.toFloat() / laidOutRows + + val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx + val scrollAmt = scrollRatio * (scrollRange.toFloat() - heightPx).coerceAtLeast(1f) + val rowNumber = (scrollAmt / avgSizePerRow).toInt() + val rowOffset = scrollAmt - rowNumber * avgSizePerRow + + state.scrollToItem(index = columnCount * rowNumber, scrollOffset = rowOffset.roundToInt()) + scrolled.tryEmit(Unit) + } + + // When list scrolled + LaunchedEffect(state.firstVisibleItemScrollOffset) { + if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect + val scrollOffset = computeGridScrollOffset(state = state, columnCount = columnCount) + /* + LazyGridItemInfo doesn't always give the accurate height of the object, so we clamp the proportion + at 1 to ensure that there are no issues due to this -- ideally we would correctly compute the value + */ + val extraScrollRange = (scrollRange.toFloat() - heightPx).coerceAtLeast(1f) + val proportion = (scrollOffset.toFloat() / extraScrollRange).coerceAtMost(1f) + 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) + } + } + } +} + +// TODO: not sure why abs corrections are in the following functions; these can probably be removed + +private fun computeGridScrollOffset(state: LazyGridState, columnCount: Int): 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 laidOutRows = 1 + abs(endChild.index - startChild.index) / columnCount + val avgSizePerRow = laidOutArea.toFloat() / laidOutRows + + val rowsBefore = min(startChild.index, endChild.index).coerceAtLeast(0) / columnCount + return (rowsBefore * avgSizePerRow - startChild.offset.y).roundToInt() +} + +private fun computeGridScrollRange(state: LazyGridState, columnCount: Int): 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 laidOutRows = 1 + abs(endChild.index - startChild.index) / columnCount + val avgSizePerRow = laidOutArea.toFloat() / laidOutRows + + val totalRows = 1 + (state.layoutInfo.totalItemsCount - 1) / columnCount + val endSpacing = avgSizePerRow - endChild.size.height + return (endSpacing + (laidOutArea.toFloat() / laidOutRows) * totalRows).roundToInt() +} + +private class MutableData(var value: T) + +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