Convert to use tachiyomi fastscroller impl
This commit is contained in:
1 parent
2a95d6707a
commit
cfe7ee7f9c
9 files changed
+607
-749
No files matched your search
@@ -4,10 +4,8 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
@@ -59,13 +57,13 @@ import com.hippo.ehviewer.ui.legacy.calculateSuitableSpanCount
|
||||
import com.hippo.ehviewer.ui.main.EhPreviewItem
|
||||
import com.hippo.ehviewer.ui.navToReader
|
||||
import com.hippo.ehviewer.ui.setMD3Content
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
|
||||
import com.hippo.ehviewer.ui.tools.rememberDialogState
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.util.getParcelableCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import my.nanihadesuka.compose.LazyGridScrollbar
|
||||
|
||||
class GalleryPreviewScreen : Fragment() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = ComposeView(inflater.context).apply {
|
||||
@@ -138,30 +136,25 @@ class GalleryPreviewScreen : Fragment() {
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columnCount),
|
||||
modifier = Modifier.nestedScroll(scrollBehaviour.nestedScrollConnection).padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h), vertical = dimensionResource(id = R.dimen.gallery_list_margin_v)),
|
||||
state = state,
|
||||
contentPadding = paddingValues,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(
|
||||
count = data.itemCount,
|
||||
key = data.itemKey(key = { item -> item.position }),
|
||||
contentType = data.itemContentType(),
|
||||
) { index ->
|
||||
val item = data[index]
|
||||
EhPreviewItem(item, index) {
|
||||
onPreviewCLick(index)
|
||||
}
|
||||
FastScrollLazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columnCount),
|
||||
modifier = Modifier.nestedScroll(scrollBehaviour.nestedScrollConnection).padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h)),
|
||||
state = state,
|
||||
contentPadding = paddingValues,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(
|
||||
count = data.itemCount,
|
||||
key = data.itemKey(key = { item -> item.position }),
|
||||
contentType = data.itemContentType(),
|
||||
) { index ->
|
||||
val item = data[index]
|
||||
EhPreviewItem(item, index) {
|
||||
onPreviewCLick(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.padding(paddingValues = paddingValues)) {
|
||||
LazyGridScrollbar(listState = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,11 @@ import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ClearAll
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
@@ -66,12 +64,13 @@ import com.hippo.ehviewer.Settings.listThumbSize
|
||||
import com.hippo.ehviewer.ui.doGalleryInfoAction
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.setMD3Content
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.rememberDialogState
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.system.pxToDp
|
||||
import kotlinx.coroutines.delay
|
||||
import my.nanihadesuka.compose.LazyColumnScrollbar
|
||||
|
||||
class HistoryScene : BaseScene() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = ComposeView(inflater.context).apply {
|
||||
@@ -109,66 +108,60 @@ class HistoryScene : BaseScene() {
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
val state = rememberLazyListState()
|
||||
Box {
|
||||
val cardHeight = remember { (3 * listThumbSize * 3).pxToDp.dp }
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h), vertical = dimensionResource(id = R.dimen.gallery_list_margin_v)),
|
||||
state = state,
|
||||
contentPadding = paddingValues,
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.gallery_list_interval)),
|
||||
) {
|
||||
items(
|
||||
count = historyData.itemCount,
|
||||
key = historyData.itemKey(key = { item -> item.gid }),
|
||||
contentType = historyData.itemContentType(),
|
||||
) { index ->
|
||||
val info = historyData[index]
|
||||
info?.let {
|
||||
val dismissState = rememberDismissState(
|
||||
confirmValueChange = {
|
||||
if (it == DismissValue.DismissedToStart) {
|
||||
coroutineScope.launchIO {
|
||||
EhDB.deleteHistoryInfo(info)
|
||||
}
|
||||
val cardHeight = remember { (3 * listThumbSize * 3).pxToDp.dp }
|
||||
FastScrollLazyColumn(
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h)),
|
||||
contentPadding = paddingValues,
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.gallery_list_interval)),
|
||||
) {
|
||||
items(
|
||||
count = historyData.itemCount,
|
||||
key = historyData.itemKey(key = { item -> item.gid }),
|
||||
contentType = historyData.itemContentType(),
|
||||
) { index ->
|
||||
val info = historyData[index]
|
||||
if (info != null) {
|
||||
val dismissState = rememberDismissState(
|
||||
confirmValueChange = {
|
||||
if (it == DismissValue.DismissedToStart) {
|
||||
coroutineScope.launchIO {
|
||||
EhDB.deleteHistoryInfo(info)
|
||||
}
|
||||
true
|
||||
},
|
||||
)
|
||||
|
||||
SwipeToDismiss(
|
||||
state = dismissState,
|
||||
background = {},
|
||||
dismissContent = {
|
||||
// TODO: item delete & add animation
|
||||
// Bug tracker: https://issuetracker.google.com/issues/150812265
|
||||
GalleryInfoListItem(
|
||||
onClick = {
|
||||
navAnimated(
|
||||
R.id.galleryDetailScene,
|
||||
bundleOf(
|
||||
GalleryDetailScene.KEY_ACTION to GalleryDetailScene.ACTION_GALLERY_INFO,
|
||||
GalleryDetailScene.KEY_GALLERY_INFO to it,
|
||||
),
|
||||
)
|
||||
},
|
||||
onLongClick = {
|
||||
coroutineScope.launchIO {
|
||||
dialogState.doGalleryInfoAction(info, context)
|
||||
}
|
||||
},
|
||||
info = it,
|
||||
modifier = Modifier.height(cardHeight),
|
||||
)
|
||||
},
|
||||
directions = setOf(DismissDirection.EndToStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
true
|
||||
},
|
||||
)
|
||||
SwipeToDismiss(
|
||||
state = dismissState,
|
||||
background = {},
|
||||
dismissContent = {
|
||||
// TODO: item delete & add animation
|
||||
// Bug tracker: https://issuetracker.google.com/issues/150812265
|
||||
GalleryInfoListItem(
|
||||
onClick = {
|
||||
navAnimated(
|
||||
R.id.galleryDetailScene,
|
||||
bundleOf(
|
||||
GalleryDetailScene.KEY_ACTION to GalleryDetailScene.ACTION_GALLERY_INFO,
|
||||
GalleryDetailScene.KEY_GALLERY_INFO to info,
|
||||
),
|
||||
)
|
||||
},
|
||||
onLongClick = {
|
||||
coroutineScope.launchIO {
|
||||
dialogState.doGalleryInfoAction(info, context)
|
||||
}
|
||||
},
|
||||
info = info,
|
||||
modifier = Modifier.height(cardHeight),
|
||||
)
|
||||
},
|
||||
directions = setOf(DismissDirection.EndToStart),
|
||||
)
|
||||
} else {
|
||||
CrystalCard(modifier = Modifier.height(cardHeight).fillMaxWidth()) {}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.padding(paddingValues = paddingValues)) {
|
||||
LazyColumnScrollbar(listState = state)
|
||||
}
|
||||
}
|
||||
Deferred({ delay(200) }) {
|
||||
if (historyData.itemCount == 0) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun FastScrollLazyVerticalGrid(
|
||||
columns: GridCells,
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyGridState = rememberLazyGridState(),
|
||||
thumbAllowed: () -> Boolean = { true },
|
||||
thumbColor: Color = MaterialTheme.colorScheme.primary,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
userScrollEnabled: Boolean = true,
|
||||
content: LazyGridScope.() -> Unit,
|
||||
) {
|
||||
VerticalGridFastScroller(
|
||||
state = state,
|
||||
columns = columns,
|
||||
arrangement = horizontalArrangement,
|
||||
contentPadding = contentPadding,
|
||||
thumbAllowed = thumbAllowed,
|
||||
thumbColor = thumbColor,
|
||||
topContentPadding = contentPadding.calculateTopPadding(),
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = columns,
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
userScrollEnabled = userScrollEnabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@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,
|
||||
userScrollEnabled: Boolean = true,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
VerticalFastScroller(
|
||||
listState = state,
|
||||
topContentPadding = contentPadding.calculateTopPadding(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
userScrollEnabled = userScrollEnabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
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 com.hippo.ehviewer.ui.tools.Scroller.STICKY_HEADER_KEY_PREFIX
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* 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(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 {
|
||||
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 = 8.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
|
||||
Reference in new issue
Block a user