mirror of
https://github.com/mihonapp/mihon.git
synced 2025-10-09 12:59:34 +02:00
Fixed scrollbar sometimes not showing during scroll or not reaching the bottom with few items (#2304)
This commit is contained in:
@@ -11,6 +11,8 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
|||||||
- `Other` - for technical stuff.
|
- `Other` - for technical stuff.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [v0.19.0] - 2025-08-04
|
||||||
### Added
|
### Added
|
||||||
|
@@ -44,6 +44,7 @@ import androidx.compose.ui.unit.LayoutDirection
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.util.fastFirstOrNull
|
import androidx.compose.ui.util.fastFirstOrNull
|
||||||
import androidx.compose.ui.util.fastForEach
|
import androidx.compose.ui.util.fastForEach
|
||||||
|
import androidx.compose.ui.util.fastLastOrNull
|
||||||
import androidx.compose.ui.util.fastMaxBy
|
import androidx.compose.ui.util.fastMaxBy
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -79,8 +80,7 @@ fun VerticalFastScroller(
|
|||||||
val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||||
val scrollerPlaceable = subcompose("scroller") {
|
val scrollerPlaceable = subcompose("scroller") {
|
||||||
val layoutInfo = listState.layoutInfo
|
val layoutInfo = listState.layoutInfo
|
||||||
val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
|
if (layoutInfo.visibleItemsInfo.isEmpty() || layoutInfo.totalItemsCount == 0) return@subcompose
|
||||||
if (!showScroller) return@subcompose
|
|
||||||
|
|
||||||
val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
|
val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
|
||||||
var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) }
|
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 thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() }
|
||||||
val heightPx = contentHeight.toFloat() -
|
val heightPx = contentHeight.toFloat() -
|
||||||
thumbTopPadding -
|
thumbTopPadding -
|
||||||
@@ -101,27 +107,65 @@ fun VerticalFastScroller(
|
|||||||
listState.layoutInfo.afterContentPadding
|
listState.layoutInfo.afterContentPadding
|
||||||
val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
|
val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
|
||||||
val trackHeightPx = heightPx - thumbHeightPx
|
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
|
// When thumb dragged
|
||||||
LaunchedEffect(thumbOffsetY) {
|
LaunchedEffect(thumbOffsetY) {
|
||||||
if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
|
if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
|
||||||
val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
|
val thumbProportion = (thumbOffsetY - thumbTopPadding) / trackHeightPx
|
||||||
val scrollItem = layoutInfo.totalItemsCount * scrollRatio
|
if (thumbProportion <= 0.001f) {
|
||||||
val scrollItemRounded = scrollItem.roundToInt()
|
estimateConfidence.value = -1f
|
||||||
val scrollItemSize = layoutInfo.visibleItemsInfo.find { it.index == scrollItemRounded }?.size ?: 0
|
listState.scrollToItem(index = 0, scrollOffset = 0)
|
||||||
val scrollItemOffset = scrollItemSize * (scrollItem - scrollItemRounded)
|
scrolled.tryEmit(Unit)
|
||||||
listState.scrollToItem(index = scrollItemRounded, scrollOffset = scrollItemOffset.roundToInt())
|
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)
|
scrolled.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// When list scrolled
|
// When list scrolled
|
||||||
LaunchedEffect(listState.firstVisibleItemScrollOffset) {
|
if (layoutInfo.totalItemsCount != 0 && !isThumbDragged) {
|
||||||
if (listState.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
|
val proportion = 1f - remainingSections / maxRemainingSections
|
||||||
val scrollOffset = computeScrollOffset(state = listState)
|
|
||||||
val scrollRange = computeScrollRange(state = listState)
|
|
||||||
val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
|
|
||||||
thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
|
thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
|
||||||
scrolled.tryEmit(Unit)
|
if (stableScrollInProgress) scrolled.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thumb alpha
|
// 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
|
@Composable
|
||||||
fun VerticalGridFastScroller(
|
fun VerticalGridFastScroller(
|
||||||
state: LazyGridState,
|
state: LazyGridState,
|
||||||
@@ -248,7 +297,9 @@ fun VerticalGridFastScroller(
|
|||||||
val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||||
val scrollerPlaceable = subcompose("scroller") {
|
val scrollerPlaceable = subcompose("scroller") {
|
||||||
val layoutInfo = state.layoutInfo
|
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
|
if (!showScroller) return@subcompose
|
||||||
val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
|
val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
|
||||||
var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) }
|
var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) }
|
||||||
@@ -270,41 +321,38 @@ fun VerticalGridFastScroller(
|
|||||||
val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
|
val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
|
||||||
val trackHeightPx = heightPx - thumbHeightPx
|
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
|
// When thumb dragged
|
||||||
LaunchedEffect(thumbOffsetY) {
|
LaunchedEffect(thumbOffsetY) {
|
||||||
if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
|
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 scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
|
||||||
val scrollItem = layoutInfo.totalItemsCount * scrollRatio
|
val scrollAmt = scrollRatio * (scrollRange.toFloat() - heightPx).coerceAtLeast(1f)
|
||||||
// I can't think of anything else rn but this'll do
|
val rowNumber = (scrollAmt / avgSizePerRow).toInt()
|
||||||
val scrollItemWhole = scrollItem.toInt()
|
val rowOffset = scrollAmt - rowNumber * avgSizePerRow
|
||||||
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
|
state.scrollToItem(index = columnCount * rowNumber, scrollOffset = rowOffset.roundToInt())
|
||||||
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)
|
scrolled.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// When list scrolled
|
// When list scrolled
|
||||||
LaunchedEffect(state.firstVisibleItemScrollOffset) {
|
LaunchedEffect(state.firstVisibleItemScrollOffset) {
|
||||||
if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
|
if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
|
||||||
val scrollOffset = computeScrollOffset(state = state)
|
val scrollOffset = computeGridScrollOffset(state = state, columnCount = columnCount)
|
||||||
val scrollRange = computeScrollRange(state = state)
|
/*
|
||||||
val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
|
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
|
thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
|
||||||
scrolled.tryEmit(Unit)
|
scrolled.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
@@ -374,57 +422,36 @@ fun VerticalGridFastScroller(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeScrollOffset(state: LazyGridState): Int {
|
// TODO: not sure why abs corrections are in the following functions; these can probably be removed
|
||||||
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 {
|
private fun computeGridScrollOffset(state: LazyGridState, columnCount: Int): Int {
|
||||||
if (state.layoutInfo.totalItemsCount == 0) return 0
|
if (state.layoutInfo.totalItemsCount == 0) return 0
|
||||||
val visibleItems = state.layoutInfo.visibleItemsInfo
|
val visibleItems = state.layoutInfo.visibleItemsInfo
|
||||||
val startChild = visibleItems.first()
|
val startChild = visibleItems.first()
|
||||||
val endChild = visibleItems.last()
|
val endChild = visibleItems.last()
|
||||||
val laidOutArea = (endChild.offset.y + endChild.size.height) - startChild.offset.y
|
val laidOutArea = (endChild.offset.y + endChild.size.height) - startChild.offset.y
|
||||||
val laidOutRange = abs(startChild.index - endChild.index) + 1
|
val laidOutRows = 1 + abs(endChild.index - startChild.index) / columnCount
|
||||||
return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
|
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
|
if (state.layoutInfo.totalItemsCount == 0) return 0
|
||||||
val visibleItems = state.layoutInfo.visibleItemsInfo
|
val visibleItems = state.layoutInfo.visibleItemsInfo
|
||||||
val startChild = visibleItems
|
val startChild = visibleItems.first()
|
||||||
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!!
|
|
||||||
val endChild = visibleItems.last()
|
val endChild = visibleItems.last()
|
||||||
val minPosition = min(startChild.index, endChild.index)
|
val laidOutArea = (endChild.offset.y + endChild.size.height) - startChild.offset.y
|
||||||
val maxPosition = max(startChild.index, endChild.index)
|
val laidOutRows = 1 + abs(endChild.index - startChild.index) / columnCount
|
||||||
val itemsBefore = minPosition.coerceAtLeast(0)
|
val avgSizePerRow = laidOutArea.toFloat() / laidOutRows
|
||||||
val startDecoratedTop = startChild.top
|
|
||||||
val laidOutArea = abs(endChild.bottom - startDecoratedTop)
|
val totalRows = 1 + (state.layoutInfo.totalItemsCount - 1) / columnCount
|
||||||
val itemRange = abs(minPosition - maxPosition) + 1
|
val endSpacing = avgSizePerRow - endChild.size.height
|
||||||
val avgSizePerRow = laidOutArea.toFloat() / itemRange
|
return (endSpacing + (laidOutArea.toFloat() / laidOutRows) * totalRows).roundToInt()
|
||||||
return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeScrollRange(state: LazyListState): Int {
|
private class MutableData<T>(var value: T)
|
||||||
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 {
|
object Scroller {
|
||||||
const val STICKY_HEADER_KEY_PREFIX = "sticky:"
|
const val STICKY_HEADER_KEY_PREFIX = "sticky:"
|
||||||
|
Reference in New Issue
Block a user