Add fast scroller to extensions screen (#7340)

This commit is contained in:
Ivan Iskandar 2022-06-19 21:33:25 +07:00 committed by GitHub
parent 82879a129e
commit e6a9d0b090
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 232 additions and 2 deletions

View File

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

View File

@ -56,3 +56,38 @@ fun ScrollbarLazyColumn(
content = content, 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,
)
}
}

View File

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