mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-21 20:47:03 -05:00
Add fast scroller to extensions screen (#7340)
This commit is contained in:
parent
82879a129e
commit
e6a9d0b090
3 changed files with 232 additions and 2 deletions
|
@ -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
|
Loading…
Reference in a new issue