Fixed scrollbar sometimes not showing during scroll or not reaching the bottom with few items (#2304)

This commit is contained in:
anirudhn
2025-08-05 17:08:47 -07:00
committed by GitHub
parent 4de3bf574a
commit 095ef8e74b
2 changed files with 105 additions and 76 deletions

View File

@@ -11,6 +11,8 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- `Other` - for technical stuff.
## [Unreleased]
### Fixes
- Fixed scrollbar sometimes not showing during scroll or not reaching the bottom with few items ([@anirudhsnayak](https://github.com/anirudhsnayak)) ([#2304](https://github.com/mihonapp/mihon/pull/2304))
## [v0.19.0] - 2025-08-04
### Added

View File

@@ -44,6 +44,7 @@ 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 kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -79,8 +80,7 @@ fun VerticalFastScroller(
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
if (layoutInfo.visibleItemsInfo.isEmpty() || layoutInfo.totalItemsCount == 0) return@subcompose
val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) }
@@ -94,6 +94,12 @@ fun VerticalFastScroller(
)
}
// 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 -
@@ -101,27 +107,65 @@ fun VerticalFastScroller(
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 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())
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
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)
if (layoutInfo.totalItemsCount != 0 && !isThumbDragged) {
val proportion = 1f - remainingSections / maxRemainingSections
thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
scrolled.tryEmit(Unit)
if (stableScrollInProgress) scrolled.tryEmit(Unit)
}
// Thumb alpha
@@ -220,6 +264,11 @@ private fun rememberColumnWidthSums(
}
}
/*
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,
@@ -248,7 +297,9 @@ fun VerticalGridFastScroller(
val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val scrollerPlaceable = subcompose("scroller") {
val layoutInfo = state.layoutInfo
val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
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) }
@@ -270,41 +321,38 @@ fun VerticalGridFastScroller(
val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
val trackHeightPx = heightPx - thumbHeightPx
val columnCount = remember { slotSizesSums(constraints).size }
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 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))
val scrollAmt = scrollRatio * (scrollRange.toFloat() - heightPx).coerceAtLeast(1f)
val rowNumber = (scrollAmt / avgSizePerRow).toInt()
val rowOffset = scrollAmt - rowNumber * avgSizePerRow
// 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())
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 = computeScrollOffset(state = state)
val scrollRange = computeScrollRange(state = state)
val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
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)
}
@@ -374,57 +422,36 @@ fun VerticalGridFastScroller(
}
}
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()
}
// TODO: not sure why abs corrections are in the following functions; these can probably be removed
private fun computeScrollRange(state: LazyGridState): Int {
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 laidOutRange = abs(startChild.index - endChild.index) + 1
return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
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 computeScrollOffset(state: LazyListState): Int {
private fun computeGridScrollRange(state: LazyGridState, columnCount: Int): 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 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.top
val laidOutArea = abs(endChild.bottom - startDecoratedTop)
val itemRange = abs(minPosition - maxPosition) + 1
val avgSizePerRow = laidOutArea.toFloat() / itemRange
return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
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 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()
}
private class MutableData<T>(var value: T)
object Scroller {
const val STICKY_HEADER_KEY_PREFIX = "sticky:"