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:
Andreas 2022-07-27 15:13:43 +02:00 committed by GitHub
parent 3fe5e53b25
commit 8bde35298f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 289 additions and 10 deletions

View File

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

View File

@ -9,13 +9,19 @@ import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState 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.shape.RoundedCornerShape
import androidx.compose.foundation.systemGestureExclusion import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.material3.MaterialTheme 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.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity 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.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMaxBy import androidx.compose.ui.util.fastMaxBy
import eu.kanade.presentation.util.plus
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -129,7 +139,10 @@ fun VerticalFastScroller(
orientation = Orientation.Vertical, orientation = Orientation.Vertical,
state = rememberDraggableState { delta -> state = rememberDraggableState { delta ->
val newOffsetY = thumbOffsetY + delta val newOffsetY = thumbOffsetY + delta
thumbOffsetY = newOffsetY.coerceIn(thumbTopPadding, thumbTopPadding + trackHeightPx) thumbOffsetY = newOffsetY.coerceIn(
thumbTopPadding,
thumbTopPadding + trackHeightPx,
)
}, },
) )
} else Modifier, } 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 { private fun computeScrollOffset(state: LazyListState): Int {
if (state.layoutInfo.totalItemsCount == 0) return 0 if (state.layoutInfo.totalItemsCount == 0) return 0
val visibleItems = state.layoutInfo.visibleItemsInfo val visibleItems = state.layoutInfo.visibleItemsInfo

View File

@ -2,16 +2,18 @@ package eu.kanade.presentation.library.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues 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.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import eu.kanade.presentation.components.FastScrollLazyVerticalGrid
import eu.kanade.presentation.components.TextButton import eu.kanade.presentation.components.TextButton
import eu.kanade.presentation.util.bottomNavPaddingValues import eu.kanade.presentation.util.bottomNavPaddingValues
import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.plus
@ -23,10 +25,12 @@ fun LazyLibraryGrid(
columns: Int, columns: Int,
content: LazyGridScope.() -> Unit, content: LazyGridScope.() -> Unit,
) { ) {
LazyVerticalGrid( FastScrollLazyVerticalGrid(
modifier = modifier,
columns = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns), 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), verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
content = content, content = content,
@ -37,8 +41,8 @@ fun LazyGridScope.globalSearchItem(
searchQuery: String?, searchQuery: String?,
onGlobalSearchClicked: () -> Unit, onGlobalSearchClicked: () -> Unit,
) { ) {
item(span = { GridItemSpan(maxLineSpan) }) {
if (searchQuery.isNullOrEmpty().not()) { if (searchQuery.isNullOrEmpty().not()) {
item(span = { GridItemSpan(maxLineSpan) }) {
TextButton(onClick = onGlobalSearchClicked) { TextButton(onClick = onGlobalSearchClicked) {
Text( Text(
text = stringResource(R.string.action_global_search_query, searchQuery!!), text = stringResource(R.string.action_global_search_query, searchQuery!!),

View File

@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -18,10 +17,10 @@ import androidx.compose.ui.zIndex
import eu.kanade.domain.manga.model.MangaCover import eu.kanade.domain.manga.model.MangaCover
import eu.kanade.presentation.components.Badge import eu.kanade.presentation.components.Badge
import eu.kanade.presentation.components.BadgeGroup import eu.kanade.presentation.components.BadgeGroup
import eu.kanade.presentation.components.FastScrollLazyColumn
import eu.kanade.presentation.components.TextButton import eu.kanade.presentation.components.TextButton
import eu.kanade.presentation.util.bottomNavPaddingValues import eu.kanade.presentation.util.bottomNavPaddingValues
import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.selectedBackground import eu.kanade.presentation.util.selectedBackground
import eu.kanade.presentation.util.verticalPadding import eu.kanade.presentation.util.verticalPadding
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@ -37,7 +36,7 @@ fun LibraryList(
searchQuery: String?, searchQuery: String?,
onGlobalSearchClicked: () -> Unit, onGlobalSearchClicked: () -> Unit,
) { ) {
LazyColumn( FastScrollLazyColumn(
contentPadding = bottomNavPaddingValues, contentPadding = bottomNavPaddingValues,
) { ) {
item { item {