123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418 |
- package eu.kanade.presentation.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.mutableStateOf
- 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.fastForEach
- import androidx.compose.ui.util.fastMaxBy
- import eu.kanade.presentation.util.plus
- import kotlinx.coroutines.channels.BufferOverflow
- import kotlinx.coroutines.flow.MutableSharedFlow
- import kotlinx.coroutines.flow.collectLatest
- import kotlin.math.abs
- import kotlin.math.max
- import kotlin.math.min
- import kotlin.math.roundToInt
- @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) { mutableStateOf(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 - 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.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 until size) {
- this[i] += this[i - 1]
- }
- }
- }
- }
- }
- @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 = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
- if (!showScroller) return@subcompose
- val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
- var thumbOffsetY by remember(thumbTopPadding) { mutableStateOf(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 { 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.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(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)
- }
- }
- }
- }
- 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.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()
- }
- private fun computeScrollRange(state: LazyListState): Int {
- if (state.layoutInfo.totalItemsCount == 0) return 0
- val visibleItems = state.layoutInfo.visibleItemsInfo
- val startChild = visibleItems.first()
- 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 val ThumbLength = 48.dp
- private val ThumbThickness = 8.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
|