Просмотр исходного кода

[新增] 为部分由Compose重写的界面增加快速滚动条

HapeLee 10 месяцев назад
Родитель
Сommit
ca9f4966d6

+ 4 - 2
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.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.Bookmark
 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 io.legado.app.ui.widget.components.SearchBarSection
 import org.koin.androidx.compose.koinViewModel
 import org.koin.androidx.compose.koinViewModel
 
 
@@ -142,7 +144,7 @@ fun AllBookmarkScreen(
             }
             }
         }
         }
     ) { paddingValues ->
     ) { paddingValues ->
-        LazyColumn(
+        FastScrollLazyColumn(
             modifier = Modifier
             modifier = Modifier
                 .fillMaxSize()
                 .fillMaxSize()
                 .padding(paddingValues)
                 .padding(paddingValues)
@@ -151,7 +153,7 @@ fun AllBookmarkScreen(
 
 
                 val isCollapsed = collapsedGroups.contains(headerKey.toString())
                 val isCollapsed = collapsedGroups.contains(headerKey.toString())
 
 
-                stickyHeader(key = headerKey.toString()) {
+                stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
                     BookAuthorHeader(
                     BookAuthorHeader(
                         bookTitle = headerKey.bookName,
                         bookTitle = headerKey.bookName,
                         bookAuthor = headerKey.bookAuthor,
                         bookAuthor = headerKey.bookAuthor,

+ 7 - 6
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.replace.edit.ReplaceEditActivity
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.AnimatedText
 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.SearchBarSection
 import io.legado.app.ui.widget.components.SelectionBottomBar
 import io.legado.app.ui.widget.components.SelectionBottomBar
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.coroutineScope
@@ -325,14 +326,12 @@ fun ReplaceRuleScreen(
             modifier = Modifier
             modifier = Modifier
                 .fillMaxSize()
                 .fillMaxSize()
         ) {
         ) {
-            LazyColumn(
+            FastScrollLazyColumn(
                 state = listState,
                 state = listState,
                 modifier = Modifier
                 modifier = Modifier
                     .padding(padding)
                     .padding(padding)
                     .fillMaxSize(),
                     .fillMaxSize(),
                 contentPadding = PaddingValues(
                 contentPadding = PaddingValues(
-                    start = 12.dp,
-                    end = 12.dp,
                     top = 8.dp,
                     top = 8.dp,
                     bottom = 120.dp
                     bottom = 120.dp
                 ),
                 ),
@@ -342,7 +341,9 @@ fun ReplaceRuleScreen(
                     val isSelected = selectedRuleIds.contains(ui.id)
                     val isSelected = selectedRuleIds.contains(ui.id)
 
 
                     ReplaceRuleItem(
                     ReplaceRuleItem(
-                        modifier = Modifier.animateItem(),
+                        modifier = Modifier
+                            .padding(horizontal = 12.dp)
+                            .animateItem(),
                         name = ui.name,
                         name = ui.name,
                         isEnabled = ui.isEnabled,
                         isEnabled = ui.isEnabled,
                         isSelected = isSelected,
                         isSelected = isSelected,
@@ -631,8 +632,8 @@ fun ReplaceRuleItem(
         )
         )
     ) {
     ) {
         ListItem(
         ListItem(
-            modifier = Modifier.animateContentSize(),
-
+            modifier = Modifier
+                .animateContentSize(),
             headlineContent = {
             headlineContent = {
                 AnimatedContent(targetState = name, label = "RuleNameAnimation") { targetName ->
                 AnimatedContent(targetState = name, label = "RuleNameAnimation") { targetName ->
                     Text(
                     Text(

+ 82 - 0
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,
+        )
+    }
+}

+ 476 - 0
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<Unit>(
+                    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<Density.(Constraints) -> List<Int>>(
+    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..<size) {
+                    this[i] += this[i - 1]
+                }
+            }
+        }
+    }
+}
+
+/*
+    VerticalGridFastScroller was written with a regularity assumption, so it is slightly inaccurate for layouts with
+    varying row sizes.
+ */
+// TODO: Ideally rewrite VerticalGridFastScroller to use similar logic as VerticalFastScroller
+@Composable
+fun VerticalGridFastScroller(
+    state: LazyGridState,
+    columns: GridCells,
+    arrangement: Arrangement.Horizontal,
+    contentPadding: PaddingValues,
+    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,
+) {
+    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<Unit>(
+                    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<T>(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<Float>(
+    durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
+    delayMillis = 2000,
+)
+private val ImmediateFadeOutAnimationSpec = tween<Float>(
+    durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
+)
+
+private val LazyListItemInfo.top: Int
+    get() = offset
+
+private val LazyListItemInfo.bottom: Int
+    get() = offset + size