mirror of
https://github.com/null2264/yokai.git
synced 2025-06-21 10:44:42 +00:00
style: Scrollbar
And add fast scroller component for later
This commit is contained in:
parent
f7e5abba59
commit
e554513392
3 changed files with 699 additions and 1 deletions
|
@ -29,6 +29,7 @@ import yokai.presentation.component.Gap
|
||||||
import yokai.presentation.component.preference.Preference
|
import yokai.presentation.component.preference.Preference
|
||||||
import yokai.presentation.component.preference.PreferenceItem
|
import yokai.presentation.component.preference.PreferenceItem
|
||||||
import yokai.presentation.component.preference.widget.PreferenceGroupHeader
|
import yokai.presentation.component.preference.widget.PreferenceGroupHeader
|
||||||
|
import yokai.presentation.core.drawVerticalScrollbar
|
||||||
import yokai.presentation.core.enterAlwaysCollapsedScrollBehavior
|
import yokai.presentation.core.enterAlwaysCollapsedScrollBehavior
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -106,7 +107,7 @@ fun PreferenceScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier,
|
modifier = modifier.drawVerticalScrollbar(listState),
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
state = listState
|
state = listState
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -0,0 +1,249 @@
|
||||||
|
package yokai.presentation.core
|
||||||
|
|
||||||
|
import android.view.ViewConfiguration
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.fastFirstOrNull
|
||||||
|
import androidx.compose.ui.util.fastSumBy
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.sample
|
||||||
|
import yokai.presentation.core.components.Scroller.STICKY_HEADER_KEY_PREFIX
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws horizontal scrollbar to a LazyList.
|
||||||
|
*
|
||||||
|
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list.
|
||||||
|
*/
|
||||||
|
fun Modifier.drawHorizontalScrollbar(
|
||||||
|
state: LazyListState,
|
||||||
|
reverseScrolling: Boolean = false,
|
||||||
|
// The amount of offset the scrollbar position towards the top of the layout
|
||||||
|
positionOffsetPx: Float = 0f,
|
||||||
|
): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling, positionOffsetPx)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws vertical scrollbar to a LazyList.
|
||||||
|
*
|
||||||
|
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list.
|
||||||
|
*/
|
||||||
|
fun Modifier.drawVerticalScrollbar(
|
||||||
|
state: LazyListState,
|
||||||
|
reverseScrolling: Boolean = false,
|
||||||
|
// The amount of offset the scrollbar position towards the start of the layout
|
||||||
|
positionOffsetPx: Float = 0f,
|
||||||
|
): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling, positionOffsetPx)
|
||||||
|
|
||||||
|
private fun Modifier.drawScrollbar(
|
||||||
|
state: LazyListState,
|
||||||
|
orientation: Orientation,
|
||||||
|
reverseScrolling: Boolean,
|
||||||
|
positionOffset: Float,
|
||||||
|
): Modifier = drawScrollbar(
|
||||||
|
orientation,
|
||||||
|
reverseScrolling,
|
||||||
|
) { reverseDirection, atEnd, thickness, color, alpha ->
|
||||||
|
val layoutInfo = state.layoutInfo
|
||||||
|
val viewportSize = if (orientation == Orientation.Horizontal) {
|
||||||
|
layoutInfo.viewportSize.width
|
||||||
|
} else {
|
||||||
|
layoutInfo.viewportSize.height
|
||||||
|
} - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding
|
||||||
|
val items = layoutInfo.visibleItemsInfo
|
||||||
|
val itemsSize = items.fastSumBy { it.size }
|
||||||
|
val showScrollbar = items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize
|
||||||
|
val estimatedItemSize = if (items.isEmpty()) 0f else itemsSize.toFloat() / items.size
|
||||||
|
val totalSize = estimatedItemSize * layoutInfo.totalItemsCount
|
||||||
|
val thumbSize = viewportSize / totalSize * viewportSize
|
||||||
|
val startOffset = if (items.isEmpty()) {
|
||||||
|
0f
|
||||||
|
} else {
|
||||||
|
items
|
||||||
|
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }
|
||||||
|
?.run {
|
||||||
|
val startPadding = if (reverseDirection) {
|
||||||
|
layoutInfo.afterContentPadding
|
||||||
|
} else {
|
||||||
|
layoutInfo.beforeContentPadding
|
||||||
|
}
|
||||||
|
startPadding + ((estimatedItemSize * index - offset) / totalSize * viewportSize)
|
||||||
|
} ?: 0f
|
||||||
|
}
|
||||||
|
val drawScrollbar = onDrawScrollbar(
|
||||||
|
orientation, reverseDirection, atEnd, showScrollbar,
|
||||||
|
thickness, color, alpha, thumbSize, startOffset, positionOffset,
|
||||||
|
)
|
||||||
|
drawContent()
|
||||||
|
drawScrollbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ContentDrawScope.onDrawScrollbar(
|
||||||
|
orientation: Orientation,
|
||||||
|
reverseDirection: Boolean,
|
||||||
|
atEnd: Boolean,
|
||||||
|
showScrollbar: Boolean,
|
||||||
|
thickness: Float,
|
||||||
|
color: Color,
|
||||||
|
alpha: () -> Float,
|
||||||
|
thumbSize: Float,
|
||||||
|
scrollOffset: Float,
|
||||||
|
positionOffset: Float,
|
||||||
|
): DrawScope.() -> Unit {
|
||||||
|
val topLeft = if (orientation == Orientation.Horizontal) {
|
||||||
|
Offset(
|
||||||
|
if (reverseDirection) size.width - scrollOffset - thumbSize else scrollOffset,
|
||||||
|
if (atEnd) size.height - positionOffset - thickness else positionOffset,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Offset(
|
||||||
|
if (atEnd) size.width - positionOffset - thickness else positionOffset,
|
||||||
|
if (reverseDirection) size.height - scrollOffset - thumbSize else scrollOffset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val size = if (orientation == Orientation.Horizontal) {
|
||||||
|
Size(thumbSize, thickness)
|
||||||
|
} else {
|
||||||
|
Size(thickness, thumbSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
if (showScrollbar) {
|
||||||
|
drawRect(
|
||||||
|
color = color,
|
||||||
|
topLeft = topLeft,
|
||||||
|
size = size,
|
||||||
|
alpha = alpha(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.drawScrollbar(
|
||||||
|
orientation: Orientation,
|
||||||
|
reverseScrolling: Boolean,
|
||||||
|
onDraw: ContentDrawScope.(
|
||||||
|
reverseDirection: Boolean,
|
||||||
|
atEnd: Boolean,
|
||||||
|
thickness: Float,
|
||||||
|
color: Color,
|
||||||
|
alpha: () -> Float,
|
||||||
|
) -> Unit,
|
||||||
|
): Modifier = composed {
|
||||||
|
val scrolled = remember {
|
||||||
|
MutableSharedFlow<Unit>(
|
||||||
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val nestedScrollConnection = remember(orientation, scrolled) {
|
||||||
|
object : NestedScrollConnection {
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource,
|
||||||
|
): Offset {
|
||||||
|
val delta = if (orientation == Orientation.Horizontal) consumed.x else consumed.y
|
||||||
|
if (delta != 0f) scrolled.tryEmit(Unit)
|
||||||
|
return Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val alpha = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(scrolled, alpha) {
|
||||||
|
scrolled
|
||||||
|
.sample(100)
|
||||||
|
.collectLatest {
|
||||||
|
alpha.snapTo(1f)
|
||||||
|
alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
|
||||||
|
val reverseDirection = if (orientation == Orientation.Horizontal) {
|
||||||
|
if (isLtr) reverseScrolling else !reverseScrolling
|
||||||
|
} else {
|
||||||
|
reverseScrolling
|
||||||
|
}
|
||||||
|
val atEnd = if (orientation == Orientation.Vertical) isLtr else true
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val thickness = remember { ViewConfiguration.get(context).scaledScrollBarSize.toFloat() }
|
||||||
|
val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.364f)
|
||||||
|
Modifier
|
||||||
|
.nestedScroll(nestedScrollConnection)
|
||||||
|
.drawWithContent {
|
||||||
|
onDraw(reverseDirection, atEnd, thickness, color, alpha::value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FadeOutAnimationSpec = tween<Float>(
|
||||||
|
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
|
||||||
|
delayMillis = ViewConfiguration.getScrollDefaultDelay(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Preview(widthDp = 400, heightDp = 400, showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun LazyListScrollbarPreview() {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.drawVerticalScrollbar(state),
|
||||||
|
state = state,
|
||||||
|
) {
|
||||||
|
items(50) {
|
||||||
|
Text(
|
||||||
|
text = "Item ${it + 1}",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(widthDp = 400, showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun LazyListHorizontalScrollbarPreview() {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.drawHorizontalScrollbar(state),
|
||||||
|
state = state,
|
||||||
|
) {
|
||||||
|
items(50) {
|
||||||
|
Text(
|
||||||
|
text = (it + 1).toString(),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,448 @@
|
||||||
|
package yokai.presentation.core.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.Arrangement
|
||||||
|
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.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.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
|
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.mutableFloatStateOf
|
||||||
|
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.Constraints
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.fastFirstOrNull
|
||||||
|
import androidx.compose.ui.util.fastForEach
|
||||||
|
import androidx.compose.ui.util.fastMaxBy
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.sample
|
||||||
|
import yokai.presentation.core.components.Scroller.STICKY_HEADER_KEY_PREFIX
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws vertical fast scroller to a lazy list
|
||||||
|
*
|
||||||
|
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun VerticalFastScroller(
|
||||||
|
listState: LazyListState,
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
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) { mutableFloatStateOf(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 -
|
||||||
|
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
|
||||||
|
.sample(100)
|
||||||
|
.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 && !listState.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 && !listState.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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..<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) { mutableFloatStateOf(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
|
||||||
|
.sample(100)
|
||||||
|
.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(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 {
|
||||||
|
if (state.layoutInfo.totalItemsCount == 0) return 0
|
||||||
|
val visibleItems = state.layoutInfo.visibleItemsInfo
|
||||||
|
val startChild = visibleItems
|
||||||
|
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!!
|
||||||
|
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
|
||||||
|
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!!
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
object Scroller {
|
||||||
|
const val STICKY_HEADER_KEY_PREFIX = "sticky:"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ThumbLength = 48.dp
|
||||||
|
private val ThumbThickness = 12.dp
|
||||||
|
private val ThumbShape = RoundedCornerShape(ThumbThickness / 2)
|
||||||
|
private val FadeOutAnimationSpec = tween<Float>(
|
||||||
|
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
|
||||||
|
delayMillis = 2000,
|
||||||
|
)
|
||||||
|
private val ImmediateFadeOutAnimationSpec = tween<Float>(
|
||||||
|
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LazyListItemInfo.top: Int
|
||||||
|
get() = offset
|
||||||
|
|
||||||
|
private val LazyListItemInfo.bottom: Int
|
||||||
|
get() = offset + size
|
Loading…
Add table
Add a link
Reference in a new issue