[新增] 为部分由Compose重写的界面增加快速滚动条
This commit is contained in:
4 files changed
+569
-8
No files matched your search
@@ -34,6 +34,8 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.legado.app.data.entities.Bookmark
|
import io.legado.app.data.entities.Bookmark
|
||||||
|
import io.legado.app.ui.widget.components.FastScrollLazyColumn
|
||||||
|
import io.legado.app.ui.widget.components.Scroller
|
||||||
import io.legado.app.ui.widget.components.SearchBarSection
|
import io.legado.app.ui.widget.components.SearchBarSection
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
@@ -142,7 +144,7 @@ fun AllBookmarkScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
FastScrollLazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
@@ -151,7 +153,7 @@ fun AllBookmarkScreen(
|
|||||||
|
|
||||||
val isCollapsed = collapsedGroups.contains(headerKey.toString())
|
val isCollapsed = collapsedGroups.contains(headerKey.toString())
|
||||||
|
|
||||||
stickyHeader(key = headerKey.toString()) {
|
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
|
||||||
BookAuthorHeader(
|
BookAuthorHeader(
|
||||||
bookTitle = headerKey.bookName,
|
bookTitle = headerKey.bookName,
|
||||||
bookAuthor = headerKey.bookAuthor,
|
bookAuthor = headerKey.bookAuthor,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import io.legado.app.data.entities.ReplaceRule
|
|||||||
import io.legado.app.ui.replace.edit.ReplaceEditActivity
|
import io.legado.app.ui.replace.edit.ReplaceEditActivity
|
||||||
import io.legado.app.ui.widget.components.ActionItem
|
import io.legado.app.ui.widget.components.ActionItem
|
||||||
import io.legado.app.ui.widget.components.AnimatedText
|
import io.legado.app.ui.widget.components.AnimatedText
|
||||||
|
import io.legado.app.ui.widget.components.FastScrollLazyColumn
|
||||||
import io.legado.app.ui.widget.components.SearchBarSection
|
import io.legado.app.ui.widget.components.SearchBarSection
|
||||||
import io.legado.app.ui.widget.components.SelectionBottomBar
|
import io.legado.app.ui.widget.components.SelectionBottomBar
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@@ -325,14 +326,12 @@ fun ReplaceRuleScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
FastScrollLazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
start = 12.dp,
|
|
||||||
end = 12.dp,
|
|
||||||
top = 8.dp,
|
top = 8.dp,
|
||||||
bottom = 120.dp
|
bottom = 120.dp
|
||||||
),
|
),
|
||||||
@@ -342,7 +341,9 @@ fun ReplaceRuleScreen(
|
|||||||
val isSelected = selectedRuleIds.contains(ui.id)
|
val isSelected = selectedRuleIds.contains(ui.id)
|
||||||
|
|
||||||
ReplaceRuleItem(
|
ReplaceRuleItem(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.animateItem(),
|
||||||
name = ui.name,
|
name = ui.name,
|
||||||
isEnabled = ui.isEnabled,
|
isEnabled = ui.isEnabled,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
@@ -631,8 +632,8 @@ fun ReplaceRuleItem(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.animateContentSize(),
|
modifier = Modifier
|
||||||
|
.animateContentSize(),
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
AnimatedContent(targetState = name, label = "RuleNameAnimation") { targetName ->
|
AnimatedContent(targetState = name, label = "RuleNameAnimation") { targetName ->
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package io.legado.app.ui.widget.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.calculateEndPadding
|
||||||
|
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.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LazyColumn with scrollbar (non-controllable).
|
||||||
|
* This code is copied from the repository https://github.com/komikku-app/komikku
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScrollbarLazyColumn(
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
val direction = LocalLayoutDirection.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val positionOffset = remember(contentPadding) {
|
||||||
|
with(density) { contentPadding.calculateEndPadding(direction).toPx() }
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
state = state,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
reverseLayout = reverseLayout,
|
||||||
|
verticalArrangement = verticalArrangement,
|
||||||
|
horizontalAlignment = horizontalAlignment,
|
||||||
|
userScrollEnabled = userScrollEnabled,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
userScrollEnabled = userScrollEnabled,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
package io.legado.app.ui.widget.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.fastLastOrNull
|
||||||
|
import androidx.compose.ui.util.fastMaxBy
|
||||||
|
import io.legado.app.ui.widget.components.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.
|
||||||
|
* This code is copied from the repository https://github.com/komikku-app/komikku
|
||||||
|
*/
|
||||||
|
@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
|
||||||
|
if (layoutInfo.visibleItemsInfo.isEmpty() || layoutInfo.totalItemsCount == 0) 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listState.isScrollInProgress occasionally flickers
|
||||||
|
val scrollStateTracker = remember { MutableData(listState.isScrollInProgress) }
|
||||||
|
val stableScrollInProgress = scrollStateTracker.value || listState.isScrollInProgress
|
||||||
|
scrollStateTracker.value = listState.isScrollInProgress
|
||||||
|
val anyScrollInProgress = stableScrollInProgress || isThumbDragged
|
||||||
|
|
||||||
|
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
|
||||||
|
val scrollHeightPx = contentHeight.toFloat() -
|
||||||
|
listState.layoutInfo.beforeContentPadding -
|
||||||
|
listState.layoutInfo.afterContentPadding -
|
||||||
|
thumbBottomPadding
|
||||||
|
|
||||||
|
val visibleItems = layoutInfo.visibleItemsInfo
|
||||||
|
val topItem = visibleItems.fastFirstOrNull {
|
||||||
|
it.bottom >= 0 &&
|
||||||
|
(it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true
|
||||||
|
} ?: visibleItems.first()
|
||||||
|
val bottomItem = visibleItems.fastLastOrNull {
|
||||||
|
it.top <= scrollHeightPx &&
|
||||||
|
(it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true
|
||||||
|
} ?: visibleItems.last()
|
||||||
|
|
||||||
|
val topHiddenProportion = -1f * topItem.top / topItem.size.coerceAtLeast(1)
|
||||||
|
val bottomHiddenProportion = (bottomItem.bottom - scrollHeightPx) / bottomItem.size.coerceAtLeast(1)
|
||||||
|
val previousSections = topHiddenProportion + topItem.index
|
||||||
|
val remainingSections = bottomHiddenProportion + (layoutInfo.totalItemsCount - (bottomItem.index + 1))
|
||||||
|
val scrollableSections = previousSections + remainingSections
|
||||||
|
|
||||||
|
val layoutChangeTracker = remember { MutableData(scrollableSections) }
|
||||||
|
val layoutChanged = !anyScrollInProgress && abs(layoutChangeTracker.value - scrollableSections) > 0.1
|
||||||
|
layoutChangeTracker.value = scrollableSections
|
||||||
|
|
||||||
|
val estimateConfidence = remember { MutableData(remainingSections) }
|
||||||
|
if (layoutChanged) estimateConfidence.value = remainingSections
|
||||||
|
val maxRemainingSections = remember(estimateConfidence.value) { scrollableSections }
|
||||||
|
estimateConfidence.value = max(estimateConfidence.value, remainingSections)
|
||||||
|
|
||||||
|
if (maxRemainingSections < 0.5) return@subcompose
|
||||||
|
|
||||||
|
// When thumb dragged
|
||||||
|
LaunchedEffect(thumbOffsetY) {
|
||||||
|
if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
|
||||||
|
val thumbProportion = (thumbOffsetY - thumbTopPadding) / trackHeightPx
|
||||||
|
if (thumbProportion <= 0.001f) {
|
||||||
|
estimateConfidence.value = -1f
|
||||||
|
listState.scrollToItem(index = 0, scrollOffset = 0)
|
||||||
|
scrolled.tryEmit(Unit)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val scrollRemainingSections = (1f - thumbProportion) * maxRemainingSections
|
||||||
|
val currentSection = layoutInfo.totalItemsCount - scrollRemainingSections
|
||||||
|
val scrollSectionIndex = currentSection.toInt().coerceAtMost(layoutInfo.totalItemsCount)
|
||||||
|
val expectedScrollItem = visibleItems.find { it.index == scrollSectionIndex } ?: visibleItems.first()
|
||||||
|
val scrollRelativeOffset = expectedScrollItem.size * (currentSection - scrollSectionIndex)
|
||||||
|
val scrollSectionOffset = (scrollRelativeOffset - scrollHeightPx).roundToInt()
|
||||||
|
val scrollItemIndex = scrollSectionIndex.coerceIn(0, layoutInfo.totalItemsCount - 1)
|
||||||
|
val scrollItemOffset = scrollSectionOffset + (scrollSectionIndex - scrollItemIndex) * bottomItem.size
|
||||||
|
listState.scrollToItem(index = scrollItemIndex, scrollOffset = scrollItemOffset)
|
||||||
|
scrolled.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When list scrolled
|
||||||
|
if (layoutInfo.totalItemsCount != 0 && !isThumbDragged) {
|
||||||
|
val proportion = 1f - remainingSections / maxRemainingSections
|
||||||
|
thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
|
||||||
|
if (stableScrollInProgress) 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
VerticalGridFastScroller was written with a regularity assumption, so it is slightly inaccurate for layouts with
|
||||||
|
varying row sizes.
|
||||||
|
*/
|
||||||
|
// TODO: Ideally rewrite VerticalGridFastScroller to use similar logic as VerticalFastScroller
|
||||||
|
@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 = remember(columns, layoutInfo.totalItemsCount) {
|
||||||
|
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(columns) { slotSizesSums(constraints).size.coerceAtLeast(1) }
|
||||||
|
val scrollRange = remember(columns) { computeGridScrollRange(state = state, columnCount = columnCount) }
|
||||||
|
|
||||||
|
// When thumb dragged
|
||||||
|
LaunchedEffect(thumbOffsetY) {
|
||||||
|
if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
|
||||||
|
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 laidOutRows = 1 + abs(endChild.index - startChild.index) / columnCount
|
||||||
|
val avgSizePerRow = laidOutArea.toFloat() / laidOutRows
|
||||||
|
|
||||||
|
val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
|
||||||
|
val scrollAmt = scrollRatio * (scrollRange.toFloat() - heightPx).coerceAtLeast(1f)
|
||||||
|
val rowNumber = (scrollAmt / avgSizePerRow).toInt()
|
||||||
|
val rowOffset = scrollAmt - rowNumber * avgSizePerRow
|
||||||
|
|
||||||
|
state.scrollToItem(index = columnCount * rowNumber, scrollOffset = rowOffset.roundToInt())
|
||||||
|
scrolled.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When list scrolled
|
||||||
|
LaunchedEffect(state.firstVisibleItemScrollOffset) {
|
||||||
|
if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
|
||||||
|
val scrollOffset = computeGridScrollOffset(state = state, columnCount = columnCount)
|
||||||
|
/*
|
||||||
|
LazyGridItemInfo doesn't always give the accurate height of the object, so we clamp the proportion
|
||||||
|
at 1 to ensure that there are no issues due to this -- ideally we would correctly compute the value
|
||||||
|
*/
|
||||||
|
val extraScrollRange = (scrollRange.toFloat() - heightPx).coerceAtLeast(1f)
|
||||||
|
val proportion = (scrollOffset.toFloat() / extraScrollRange).coerceAtMost(1f)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: not sure why abs corrections are in the following functions; these can probably be removed
|
||||||
|
|
||||||
|
private fun computeGridScrollOffset(state: LazyGridState, columnCount: Int): 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 laidOutRows = 1 + abs(endChild.index - startChild.index) / columnCount
|
||||||
|
val avgSizePerRow = laidOutArea.toFloat() / laidOutRows
|
||||||
|
|
||||||
|
val rowsBefore = min(startChild.index, endChild.index).coerceAtLeast(0) / columnCount
|
||||||
|
return (rowsBefore * avgSizePerRow - startChild.offset.y).roundToInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeGridScrollRange(state: LazyGridState, columnCount: Int): 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 laidOutRows = 1 + abs(endChild.index - startChild.index) / columnCount
|
||||||
|
val avgSizePerRow = laidOutArea.toFloat() / laidOutRows
|
||||||
|
|
||||||
|
val totalRows = 1 + (state.layoutInfo.totalItemsCount - 1) / columnCount
|
||||||
|
val endSpacing = avgSizePerRow - endChild.size.height
|
||||||
|
return (endSpacing + (laidOutArea.toFloat() / laidOutRows) * totalRows).roundToInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MutableData<T>(var value: T)
|
||||||
|
|
||||||
|
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
|
||||||
Reference in new issue
Block a user