浏览代码

Add fast scroller to extensions screen (#7340)

Ivan Iskandar 2 年之前
父节点
当前提交
e6a9d0b090

+ 2 - 2
app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt

@@ -40,7 +40,7 @@ import com.google.accompanist.swiperefresh.SwipeRefresh
 import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
 import eu.kanade.presentation.browse.components.BaseBrowseItem
 import eu.kanade.presentation.browse.components.ExtensionIcon
-import eu.kanade.presentation.components.ScrollbarLazyColumn
+import eu.kanade.presentation.components.FastScrollLazyColumn
 import eu.kanade.presentation.components.SwipeRefreshIndicator
 import eu.kanade.presentation.theme.header
 import eu.kanade.presentation.util.horizontalPadding
@@ -113,7 +113,7 @@ fun ExtensionContent(
 ) {
     var trustState by remember { mutableStateOf<Extension.Untrusted?>(null) }
 
-    ScrollbarLazyColumn(
+    FastScrollLazyColumn(
         contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues,
     ) {
         items(

+ 35 - 0
app/src/main/java/eu/kanade/presentation/components/LazyList.kt

@@ -56,3 +56,38 @@ fun ScrollbarLazyColumn(
         content = content,
     )
 }
+
+/**
+ * LazyColumn with fast scroller.
+ */
+@Composable
+fun FastScrollLazyColumn(
+    modifier: Modifier = Modifier,
+    state: LazyListState = rememberLazyListState(),
+    contentPadding: PaddingValues = PaddingValues(0.dp),
+    reverseLayout: Boolean = false,
+    verticalArrangement: Arrangement.Vertical =
+        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
+    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
+    userScrollEnabled: Boolean = true,
+    content: LazyListScope.() -> Unit,
+) {
+    VerticalFastScroller(
+        listState = state,
+        modifier = modifier,
+        topContentPadding = contentPadding.calculateTopPadding(),
+        endContentPadding = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
+    ) {
+        LazyColumn(
+            state = state,
+            contentPadding = contentPadding,
+            reverseLayout = reverseLayout,
+            verticalArrangement = verticalArrangement,
+            horizontalAlignment = horizontalAlignment,
+            flingBehavior = flingBehavior,
+            userScrollEnabled = userScrollEnabled,
+            content = content,
+        )
+    }
+}

+ 195 - 0
app/src/main/java/eu/kanade/presentation/components/VerticalFastScroller.kt

@@ -0,0 +1,195 @@
+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.Box
+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.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.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastMaxBy
+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,
+    thumbColor: Color = MaterialTheme.colorScheme.primary,
+    topContentPadding: 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 heightPx = contentHeight.toFloat() - thumbTopPadding - 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 {
+                    alpha.snapTo(1f)
+                    alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
+                }
+            }
+
+            Box(
+                modifier = Modifier
+                    .offset { IntOffset(0, thumbOffsetY.roundToInt()) }
+                    .height(ThumbLength)
+                    .then(
+                        // Exclude thumb from gesture area only when needed
+                        if (isThumbVisible && !isThumbDragged && !listState.isScrollInProgress) {
+                            Modifier.systemGestureExclusion()
+                        } else Modifier,
+                    )
+                    .padding(horizontal = 8.dp)
+                    .padding(end = endContentPadding)
+                    .width(ThumbThickness)
+                    .alpha(alpha.value)
+                    .background(color = thumbColor, shape = ThumbShape)
+                    .then(
+                        // Recompose opts
+                        if (!listState.isScrollInProgress) {
+                            Modifier.draggable(
+                                interactionSource = dragInteractionSource,
+                                orientation = Orientation.Vertical,
+                                enabled = isThumbVisible,
+                                state = rememberDraggableState { delta ->
+                                    val newOffsetY = thumbOffsetY + delta
+                                    thumbOffsetY = newOffsetY.coerceIn(thumbTopPadding, thumbTopPadding + trackHeightPx)
+                                },
+                            )
+                        } else Modifier,
+                    ),
+            )
+        }.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: 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 LazyListItemInfo.top: Int
+    get() = offset
+
+private val LazyListItemInfo.bottom: Int
+    get() = offset + size