فهرست منبع

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>
Andreas 2 سال پیش
والد
کامیت
8bde35298f

+ 62 - 0
app/src/main/java/eu/kanade/presentation/components/LazyGrid.kt

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

+ 215 - 1
app/src/main/java/eu/kanade/presentation/components/VerticalFastScroller.kt

@@ -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

+ 10 - 6
app/src/main/java/eu/kanade/presentation/library/components/LazyLibraryGrid.kt

@@ -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!!),

+ 2 - 3
app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt

@@ -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 {