mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-11-04 08:08:55 +01:00 
			
		
		
		
	Add fast scroller to extensions screen (#7340)
This commit is contained in:
		@@ -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(
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
		Reference in New Issue
	
	Block a user