mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Add fast scroller to Library screen (#7600)
Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
This commit is contained in:
		| @@ -0,0 +1,62 @@ | ||||
| package eu.kanade.presentation.components | ||||
|  | ||||
| import androidx.compose.foundation.gestures.FlingBehavior | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.PaddingValues | ||||
| import androidx.compose.foundation.lazy.grid.GridCells | ||||
| import androidx.compose.foundation.lazy.grid.LazyGridScope | ||||
| import androidx.compose.foundation.lazy.grid.LazyGridState | ||||
| import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | ||||
| import androidx.compose.foundation.lazy.grid.rememberLazyGridState | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.unit.Dp | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.presentation.util.flingBehaviorIgnoringMotionScale | ||||
|  | ||||
| @Composable | ||||
| fun FastScrollLazyVerticalGrid( | ||||
|     columns: GridCells, | ||||
|     modifier: Modifier = Modifier, | ||||
|     state: LazyGridState = rememberLazyGridState(), | ||||
|     thumbAllowed: () -> Boolean = { true }, | ||||
|     thumbColor: Color = MaterialTheme.colorScheme.primary, | ||||
|     contentPadding: PaddingValues = PaddingValues(0.dp), | ||||
|     topContentPadding: Dp = Dp.Hairline, | ||||
|     bottomContentPadding: Dp = Dp.Hairline, | ||||
|     endContentPadding: Dp = Dp.Hairline, | ||||
|     reverseLayout: Boolean = false, | ||||
|     verticalArrangement: Arrangement.Vertical = | ||||
|         if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, | ||||
|     horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, | ||||
|     flingBehavior: FlingBehavior = flingBehaviorIgnoringMotionScale(), | ||||
|     userScrollEnabled: Boolean = true, | ||||
|     content: LazyGridScope.() -> Unit, | ||||
| ) { | ||||
|     VerticalGridFastScroller( | ||||
|         state = state, | ||||
|         columns = columns, | ||||
|         arrangement = horizontalArrangement, | ||||
|         contentPadding = contentPadding, | ||||
|         modifier = modifier, | ||||
|         thumbAllowed = thumbAllowed, | ||||
|         thumbColor = thumbColor, | ||||
|         topContentPadding = topContentPadding, | ||||
|         bottomContentPadding = bottomContentPadding, | ||||
|         endContentPadding = endContentPadding, | ||||
|     ) { | ||||
|         LazyVerticalGrid( | ||||
|             columns = columns, | ||||
|             state = state, | ||||
|             contentPadding = contentPadding, | ||||
|             reverseLayout = reverseLayout, | ||||
|             verticalArrangement = verticalArrangement, | ||||
|             horizontalArrangement = horizontalArrangement, | ||||
|             flingBehavior = flingBehavior, | ||||
|             userScrollEnabled = userScrollEnabled, | ||||
|             content = content, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -9,13 +9,19 @@ 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 | ||||
| @@ -30,11 +36,15 @@ 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 | ||||
| @@ -129,7 +139,10 @@ fun VerticalFastScroller( | ||||
|                                 orientation = Orientation.Vertical, | ||||
|                                 state = rememberDraggableState { delta -> | ||||
|                                     val newOffsetY = thumbOffsetY + delta | ||||
|                                     thumbOffsetY = newOffsetY.coerceIn(thumbTopPadding, thumbTopPadding + trackHeightPx) | ||||
|                                     thumbOffsetY = newOffsetY.coerceIn( | ||||
|                                         thumbTopPadding, | ||||
|                                         thumbTopPadding + trackHeightPx, | ||||
|                                     ) | ||||
|                                 }, | ||||
|                             ) | ||||
|                         } else Modifier, | ||||
| @@ -161,6 +174,207 @@ fun VerticalFastScroller( | ||||
|     } | ||||
| } | ||||
|  | ||||
| @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 | ||||
|   | ||||
| @@ -2,16 +2,18 @@ package eu.kanade.presentation.library.components | ||||
|  | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.PaddingValues | ||||
| import androidx.compose.foundation.layout.calculateEndPadding | ||||
| import androidx.compose.foundation.lazy.grid.GridCells | ||||
| import androidx.compose.foundation.lazy.grid.GridItemSpan | ||||
| import androidx.compose.foundation.lazy.grid.LazyGridScope | ||||
| import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalLayoutDirection | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.zIndex | ||||
| import eu.kanade.presentation.components.FastScrollLazyVerticalGrid | ||||
| import eu.kanade.presentation.components.TextButton | ||||
| import eu.kanade.presentation.util.bottomNavPaddingValues | ||||
| import eu.kanade.presentation.util.plus | ||||
| @@ -23,10 +25,12 @@ fun LazyLibraryGrid( | ||||
|     columns: Int, | ||||
|     content: LazyGridScope.() -> Unit, | ||||
| ) { | ||||
|     LazyVerticalGrid( | ||||
|         modifier = modifier, | ||||
|     FastScrollLazyVerticalGrid( | ||||
|         columns = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns), | ||||
|         contentPadding = bottomNavPaddingValues + PaddingValues(12.dp, 2.dp), | ||||
|         modifier = modifier, | ||||
|         contentPadding = bottomNavPaddingValues + PaddingValues(end = 12.dp, start = 12.dp, bottom = 2.dp, top = 12.dp), | ||||
|         topContentPadding = bottomNavPaddingValues.calculateTopPadding(), | ||||
|         endContentPadding = bottomNavPaddingValues.calculateEndPadding(LocalLayoutDirection.current), | ||||
|         verticalArrangement = Arrangement.spacedBy(12.dp), | ||||
|         horizontalArrangement = Arrangement.spacedBy(12.dp), | ||||
|         content = content, | ||||
| @@ -37,8 +41,8 @@ fun LazyGridScope.globalSearchItem( | ||||
|     searchQuery: String?, | ||||
|     onGlobalSearchClicked: () -> Unit, | ||||
| ) { | ||||
|     item(span = { GridItemSpan(maxLineSpan) }) { | ||||
|         if (searchQuery.isNullOrEmpty().not()) { | ||||
|     if (searchQuery.isNullOrEmpty().not()) { | ||||
|         item(span = { GridItemSpan(maxLineSpan) }) { | ||||
|             TextButton(onClick = onGlobalSearchClicked) { | ||||
|                 Text( | ||||
|                     text = stringResource(R.string.action_global_search_query, searchQuery!!), | ||||
|   | ||||
| @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.fillMaxHeight | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.foundation.lazy.items | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| @@ -18,10 +17,10 @@ import androidx.compose.ui.zIndex | ||||
| import eu.kanade.domain.manga.model.MangaCover | ||||
| import eu.kanade.presentation.components.Badge | ||||
| import eu.kanade.presentation.components.BadgeGroup | ||||
| import eu.kanade.presentation.components.FastScrollLazyColumn | ||||
| import eu.kanade.presentation.components.TextButton | ||||
| import eu.kanade.presentation.util.bottomNavPaddingValues | ||||
| import eu.kanade.presentation.util.horizontalPadding | ||||
| import eu.kanade.presentation.util.plus | ||||
| import eu.kanade.presentation.util.selectedBackground | ||||
| import eu.kanade.presentation.util.verticalPadding | ||||
| import eu.kanade.tachiyomi.R | ||||
| @@ -37,7 +36,7 @@ fun LibraryList( | ||||
|     searchQuery: String?, | ||||
|     onGlobalSearchClicked: () -> Unit, | ||||
| ) { | ||||
|     LazyColumn( | ||||
|     FastScrollLazyColumn( | ||||
|         contentPadding = bottomNavPaddingValues, | ||||
|     ) { | ||||
|         item { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user