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
@@ -179,6 +179,7 @@ dependencies {
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material3:material3-window-size-class")
|
||||
implementation("androidx.compose.ui:ui-util")
|
||||
|
||||
implementation("androidx.core:core-ktx:1.12.0-beta01")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,321 +0,0 @@
|
||||
package my.nanihadesuka.compose
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
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.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* Scrollbar for LazyColumn
|
||||
* Use this variation if you want to place the scrollbar independently of the LazyColumn position
|
||||
*
|
||||
* @param rightSide true -> right, false -> left
|
||||
* @param thickness Thickness of the scrollbar thumb
|
||||
* @param padding Padding of the scrollbar
|
||||
* @param thumbMinHeight Thumb minimum height proportional to total scrollbar's height (eg: 0.1 -> 10% of total)
|
||||
*/
|
||||
@Composable
|
||||
fun LazyColumnScrollbar(
|
||||
listState: LazyListState,
|
||||
rightSide: Boolean = true,
|
||||
thickness: Dp = 6.dp,
|
||||
padding: Dp = 8.dp,
|
||||
thumbMinHeight: Float = 0.1f,
|
||||
thumbColor: Color = MaterialTheme.colorScheme.primary,
|
||||
thumbSelectedColor: Color = MaterialTheme.colorScheme.primary,
|
||||
thumbShape: Shape = CircleShape,
|
||||
selectionMode: ScrollbarSelectableMode = ScrollbarSelectableMode.Thumb,
|
||||
indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val firstVisibleItemIndex = remember { derivedStateOf { listState.firstVisibleItemIndex } }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var isSelected by remember { mutableStateOf(false) }
|
||||
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val reverseLayout by remember { derivedStateOf { listState.layoutInfo.reverseLayout } }
|
||||
|
||||
val realFirstVisibleItem by remember {
|
||||
derivedStateOf {
|
||||
listState.layoutInfo.visibleItemsInfo.firstOrNull {
|
||||
it.index == listState.firstVisibleItemIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isStickyHeaderInAction by remember {
|
||||
derivedStateOf {
|
||||
val realIndex = realFirstVisibleItem?.index ?: return@derivedStateOf false
|
||||
val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index
|
||||
?: return@derivedStateOf false
|
||||
realIndex != firstVisibleIndex
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyListItemInfo.fractionHiddenTop() =
|
||||
if (size == 0) 0f else -offset.toFloat() / size.toFloat()
|
||||
|
||||
fun LazyListItemInfo.fractionVisibleBottom(viewportEndOffset: Int) =
|
||||
if (size == 0) 0f else (viewportEndOffset - offset).toFloat() / size.toFloat()
|
||||
|
||||
val normalizedThumbSizeReal by remember {
|
||||
derivedStateOf {
|
||||
listState.layoutInfo.let {
|
||||
if (it.totalItemsCount == 0) {
|
||||
return@let 0f
|
||||
}
|
||||
|
||||
val firstItem = realFirstVisibleItem ?: return@let 0f
|
||||
val firstPartial = firstItem.fractionHiddenTop()
|
||||
val lastPartial =
|
||||
1f - it.visibleItemsInfo.last().fractionVisibleBottom(it.viewportEndOffset)
|
||||
|
||||
val realSize = it.visibleItemsInfo.size - if (isStickyHeaderInAction) 1 else 0
|
||||
val realVisibleSize = realSize.toFloat() - firstPartial - lastPartial
|
||||
realVisibleSize / it.totalItemsCount.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val normalizedThumbSize by remember {
|
||||
derivedStateOf {
|
||||
normalizedThumbSizeReal.coerceAtLeast(thumbMinHeight)
|
||||
}
|
||||
}
|
||||
|
||||
fun offsetCorrection(top: Float): Float {
|
||||
val topRealMax = (1f - normalizedThumbSizeReal).coerceIn(0f, 1f)
|
||||
if (normalizedThumbSizeReal >= thumbMinHeight) {
|
||||
return when {
|
||||
reverseLayout -> topRealMax - top
|
||||
else -> top
|
||||
}
|
||||
}
|
||||
|
||||
val topMax = 1f - thumbMinHeight
|
||||
return when {
|
||||
reverseLayout -> (topRealMax - top) * topMax / topRealMax
|
||||
else -> top * topMax / topRealMax
|
||||
}
|
||||
}
|
||||
|
||||
fun offsetCorrectionInverse(top: Float): Float {
|
||||
if (normalizedThumbSizeReal >= thumbMinHeight) {
|
||||
return top
|
||||
}
|
||||
val topRealMax = 1f - normalizedThumbSizeReal
|
||||
val topMax = 1f - thumbMinHeight
|
||||
return top * topRealMax / topMax
|
||||
}
|
||||
|
||||
val normalizedOffsetPosition by remember {
|
||||
derivedStateOf {
|
||||
listState.layoutInfo.let {
|
||||
if (it.totalItemsCount == 0 || it.visibleItemsInfo.isEmpty()) {
|
||||
return@let 0f
|
||||
}
|
||||
|
||||
val firstItem = realFirstVisibleItem ?: return@let 0f
|
||||
val top = firstItem
|
||||
.run { index.toFloat() + fractionHiddenTop() } / it.totalItemsCount.toFloat()
|
||||
offsetCorrection(top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDragOffset(value: Float) {
|
||||
val maxValue = (1f - normalizedThumbSize).coerceAtLeast(0f)
|
||||
dragOffset = value.coerceIn(0f, maxValue)
|
||||
}
|
||||
|
||||
fun setScrollOffset(newOffset: Float) {
|
||||
setDragOffset(newOffset)
|
||||
val totalItemsCount = listState.layoutInfo.totalItemsCount.toFloat()
|
||||
val exactIndex = offsetCorrectionInverse(totalItemsCount * dragOffset)
|
||||
val index: Int = floor(exactIndex).toInt()
|
||||
val remainder: Float = exactIndex - floor(exactIndex)
|
||||
|
||||
coroutineScope.launch {
|
||||
listState.scrollToItem(index = index, scrollOffset = 0)
|
||||
val offset = realFirstVisibleItem
|
||||
?.size
|
||||
?.let { it.toFloat() * remainder }
|
||||
?.toInt() ?: 0
|
||||
listState.scrollToItem(index = index, scrollOffset = offset)
|
||||
}
|
||||
}
|
||||
|
||||
val isInAction = listState.isScrollInProgress || isSelected
|
||||
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (isInAction) 1f else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = if (isInAction) 75 else 500,
|
||||
delayMillis = if (isInAction) 0 else 500,
|
||||
),
|
||||
label = "alpha",
|
||||
)
|
||||
|
||||
val displacement by animateFloatAsState(
|
||||
targetValue = if (isInAction) 0f else 14f,
|
||||
animationSpec = tween(
|
||||
durationMillis = if (isInAction) 75 else 500,
|
||||
delayMillis = if (isInAction) 0 else 500,
|
||||
),
|
||||
label = "displacement",
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.alpha(alpha)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (indicatorContent != null) {
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.align(if (rightSide) Alignment.TopEnd else Alignment.TopStart)
|
||||
.fillMaxHeight()
|
||||
.graphicsLayer(
|
||||
translationX = with(LocalDensity.current) { (if (rightSide) displacement.dp else -displacement.dp).toPx() },
|
||||
translationY = constraints.maxHeight.toFloat() * normalizedOffsetPosition,
|
||||
),
|
||||
) {
|
||||
ConstraintLayout(
|
||||
Modifier.align(Alignment.TopEnd),
|
||||
) {
|
||||
val (box, content) = createRefs()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(normalizedThumbSize)
|
||||
.padding(
|
||||
start = if (rightSide) 0.dp else padding,
|
||||
end = if (!rightSide) 0.dp else padding,
|
||||
)
|
||||
.width(thickness)
|
||||
.constrainAs(box) {
|
||||
if (rightSide) {
|
||||
end.linkTo(parent.end)
|
||||
} else {
|
||||
start.linkTo(parent.start)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.constrainAs(content) {
|
||||
top.linkTo(box.top)
|
||||
bottom.linkTo(box.bottom)
|
||||
if (rightSide) {
|
||||
end.linkTo(box.start)
|
||||
} else {
|
||||
start.linkTo(box.end)
|
||||
}
|
||||
},
|
||||
) {
|
||||
indicatorContent(
|
||||
firstVisibleItemIndex.value,
|
||||
isSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.align(if (rightSide) Alignment.TopEnd else Alignment.TopStart)
|
||||
.fillMaxHeight()
|
||||
.draggable(
|
||||
state = rememberDraggableState { delta ->
|
||||
val displace = if (reverseLayout) -delta else delta // side effect ?
|
||||
if (isSelected) {
|
||||
setScrollOffset(dragOffset + displace / constraints.maxHeight.toFloat())
|
||||
}
|
||||
},
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = selectionMode != ScrollbarSelectableMode.Disabled,
|
||||
startDragImmediately = true,
|
||||
onDragStarted = onDragStarted@{ offset ->
|
||||
val maxHeight = constraints.maxHeight.toFloat()
|
||||
if (maxHeight <= 0f) return@onDragStarted
|
||||
val newOffset = when {
|
||||
reverseLayout -> (maxHeight - offset.y) / maxHeight
|
||||
else -> offset.y / maxHeight
|
||||
}
|
||||
val currentOffset = when {
|
||||
reverseLayout -> 1f - normalizedOffsetPosition - normalizedThumbSize
|
||||
else -> normalizedOffsetPosition
|
||||
}
|
||||
when (selectionMode) {
|
||||
ScrollbarSelectableMode.Full -> {
|
||||
if (newOffset in currentOffset..(currentOffset + normalizedThumbSize)) {
|
||||
setDragOffset(currentOffset)
|
||||
} else {
|
||||
setScrollOffset(newOffset)
|
||||
}
|
||||
isSelected = true
|
||||
}
|
||||
ScrollbarSelectableMode.Thumb -> {
|
||||
if (newOffset in currentOffset..(currentOffset + normalizedThumbSize)) {
|
||||
setDragOffset(currentOffset)
|
||||
isSelected = true
|
||||
}
|
||||
}
|
||||
ScrollbarSelectableMode.Disabled -> Unit
|
||||
}
|
||||
},
|
||||
onDragStopped = {
|
||||
isSelected = false
|
||||
},
|
||||
)
|
||||
.graphicsLayer(translationX = with(LocalDensity.current) { (if (rightSide) displacement.dp else -displacement.dp).toPx() }),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.graphicsLayer(translationY = constraints.maxHeight.toFloat() * normalizedOffsetPosition)
|
||||
.padding(horizontal = padding)
|
||||
.width(thickness)
|
||||
.clip(thumbShape)
|
||||
.background(if (isSelected) thumbSelectedColor else thumbColor)
|
||||
.fillMaxHeight(normalizedThumbSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
package my.nanihadesuka.compose
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
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.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* Scrollbar for LazyColumn
|
||||
* Use this variation if you want to place the scrollbar independently of the LazyColumn position
|
||||
*
|
||||
* @param rightSide true -> right, false -> left
|
||||
* @param thickness Thickness of the scrollbar thumb
|
||||
* @param padding Padding of the scrollbar
|
||||
* @param thumbMinHeight Thumb minimum height proportional to total scrollbar's height (eg: 0.1 -> 10% of total)
|
||||
*/
|
||||
@Composable
|
||||
fun LazyGridScrollbar(
|
||||
listState: LazyGridState,
|
||||
rightSide: Boolean = true,
|
||||
thickness: Dp = 6.dp,
|
||||
padding: Dp = 8.dp,
|
||||
thumbMinHeight: Float = 0.1f,
|
||||
thumbColor: Color = MaterialTheme.colorScheme.primary,
|
||||
thumbSelectedColor: Color = MaterialTheme.colorScheme.primary,
|
||||
thumbShape: Shape = CircleShape,
|
||||
selectionMode: ScrollbarSelectableMode = ScrollbarSelectableMode.Thumb,
|
||||
indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val firstVisibleItemIndex = remember { derivedStateOf { listState.firstVisibleItemIndex } }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var isSelected by remember { mutableStateOf(false) }
|
||||
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val reverseLayout by remember { derivedStateOf { listState.layoutInfo.reverseLayout } }
|
||||
|
||||
val realFirstVisibleItem by remember {
|
||||
derivedStateOf {
|
||||
listState.layoutInfo.visibleItemsInfo.firstOrNull {
|
||||
it.index == listState.firstVisibleItemIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isStickyHeaderInAction by remember {
|
||||
derivedStateOf {
|
||||
val realIndex = realFirstVisibleItem?.index ?: return@derivedStateOf false
|
||||
val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index
|
||||
?: return@derivedStateOf false
|
||||
realIndex != firstVisibleIndex
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyGridItemInfo.fractionHiddenTop() =
|
||||
if (size == IntSize.Zero) 0f else -offset.y.toFloat() / size.height.toFloat()
|
||||
|
||||
fun LazyGridItemInfo.fractionVisibleBottom(viewportEndOffset: Int) =
|
||||
if (size == IntSize.Zero) 0f else (viewportEndOffset - offset.y).toFloat() / size.height.toFloat()
|
||||
|
||||
val normalizedThumbSizeReal by remember {
|
||||
derivedStateOf {
|
||||
listState.layoutInfo.let {
|
||||
if (it.totalItemsCount == 0) {
|
||||
return@let 0f
|
||||
}
|
||||
|
||||
val firstItem = realFirstVisibleItem ?: return@let 0f
|
||||
val firstPartial = firstItem.fractionHiddenTop()
|
||||
val lastPartial =
|
||||
1f - it.visibleItemsInfo.last().fractionVisibleBottom(it.viewportEndOffset)
|
||||
|
||||
val realSize = it.visibleItemsInfo.size - if (isStickyHeaderInAction) 1 else 0
|
||||
val realVisibleSize = realSize.toFloat() - firstPartial - lastPartial
|
||||
realVisibleSize / it.totalItemsCount.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val normalizedThumbSize by remember {
|
||||
derivedStateOf {
|
||||
normalizedThumbSizeReal.coerceAtLeast(thumbMinHeight)
|
||||
}
|
||||
}
|
||||
|
||||
fun offsetCorrection(top: Float): Float {
|
||||
val topRealMax = (1f - normalizedThumbSizeReal).coerceIn(0f, 1f)
|
||||
if (normalizedThumbSizeReal >= thumbMinHeight) {
|
||||
return when {
|
||||
reverseLayout -> topRealMax - top
|
||||
else -> top
|
||||
}
|
||||
}
|
||||
|
||||
val topMax = 1f - thumbMinHeight
|
||||
return when {
|
||||
reverseLayout -> (topRealMax - top) * topMax / topRealMax
|
||||
else -> top * topMax / topRealMax
|
||||
}
|
||||
}
|
||||
|
||||
fun offsetCorrectionInverse(top: Float): Float {
|
||||
if (normalizedThumbSizeReal >= thumbMinHeight) {
|
||||
return top
|
||||
}
|
||||
val topRealMax = 1f - normalizedThumbSizeReal
|
||||
val topMax = 1f - thumbMinHeight
|
||||
return top * topRealMax / topMax
|
||||
}
|
||||
|
||||
val normalizedOffsetPosition by remember {
|
||||
derivedStateOf {
|
||||
listState.layoutInfo.let {
|
||||
if (it.totalItemsCount == 0 || it.visibleItemsInfo.isEmpty()) {
|
||||
return@let 0f
|
||||
}
|
||||
|
||||
val firstItem = realFirstVisibleItem ?: return@let 0f
|
||||
val top = firstItem
|
||||
.run { index.toFloat() + fractionHiddenTop() } / it.totalItemsCount.toFloat()
|
||||
offsetCorrection(top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDragOffset(value: Float) {
|
||||
val maxValue = (1f - normalizedThumbSize).coerceAtLeast(0f)
|
||||
dragOffset = value.coerceIn(0f, maxValue)
|
||||
}
|
||||
|
||||
fun setScrollOffset(newOffset: Float) {
|
||||
setDragOffset(newOffset)
|
||||
val totalItemsCount = listState.layoutInfo.totalItemsCount.toFloat()
|
||||
val exactIndex = offsetCorrectionInverse(totalItemsCount * dragOffset)
|
||||
val index: Int = floor(exactIndex).toInt()
|
||||
val remainder: Float = exactIndex - floor(exactIndex)
|
||||
|
||||
coroutineScope.launch {
|
||||
listState.scrollToItem(index = index, scrollOffset = 0)
|
||||
val offset = realFirstVisibleItem
|
||||
?.size
|
||||
?.let { it.height.toFloat() * remainder }
|
||||
?.toInt() ?: 0
|
||||
listState.scrollToItem(index = index, scrollOffset = offset)
|
||||
}
|
||||
}
|
||||
|
||||
val isInAction = listState.isScrollInProgress || isSelected
|
||||
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (isInAction) 1f else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = if (isInAction) 75 else 500,
|
||||
delayMillis = if (isInAction) 0 else 500,
|
||||
),
|
||||
label = "alpha",
|
||||
)
|
||||
|
||||
val displacement by animateFloatAsState(
|
||||
targetValue = if (isInAction) 0f else 14f,
|
||||
animationSpec = tween(
|
||||
durationMillis = if (isInAction) 75 else 500,
|
||||
delayMillis = if (isInAction) 0 else 500,
|
||||
),
|
||||
label = "displacement",
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.alpha(alpha)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (indicatorContent != null) {
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.align(if (rightSide) Alignment.TopEnd else Alignment.TopStart)
|
||||
.fillMaxHeight()
|
||||
.graphicsLayer(
|
||||
translationX = with(LocalDensity.current) { (if (rightSide) displacement.dp else -displacement.dp).toPx() },
|
||||
translationY = constraints.maxHeight.toFloat() * normalizedOffsetPosition,
|
||||
),
|
||||
) {
|
||||
ConstraintLayout(
|
||||
Modifier.align(Alignment.TopEnd),
|
||||
) {
|
||||
val (box, content) = createRefs()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(normalizedThumbSize)
|
||||
.padding(
|
||||
start = if (rightSide) 0.dp else padding,
|
||||
end = if (!rightSide) 0.dp else padding,
|
||||
)
|
||||
.width(thickness)
|
||||
.constrainAs(box) {
|
||||
if (rightSide) {
|
||||
end.linkTo(parent.end)
|
||||
} else {
|
||||
start.linkTo(parent.start)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.constrainAs(content) {
|
||||
top.linkTo(box.top)
|
||||
bottom.linkTo(box.bottom)
|
||||
if (rightSide) {
|
||||
end.linkTo(box.start)
|
||||
} else {
|
||||
start.linkTo(box.end)
|
||||
}
|
||||
},
|
||||
) {
|
||||
indicatorContent(
|
||||
firstVisibleItemIndex.value,
|
||||
isSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.align(if (rightSide) Alignment.TopEnd else Alignment.TopStart)
|
||||
.fillMaxHeight()
|
||||
.draggable(
|
||||
state = rememberDraggableState { delta ->
|
||||
val displace = if (reverseLayout) -delta else delta // side effect ?
|
||||
if (isSelected) {
|
||||
setScrollOffset(dragOffset + displace / constraints.maxHeight.toFloat())
|
||||
}
|
||||
},
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = selectionMode != ScrollbarSelectableMode.Disabled,
|
||||
startDragImmediately = true,
|
||||
onDragStarted = onDragStarted@{ offset ->
|
||||
val maxHeight = constraints.maxHeight.toFloat()
|
||||
if (maxHeight <= 0f) return@onDragStarted
|
||||
val newOffset = when {
|
||||
reverseLayout -> (maxHeight - offset.y) / maxHeight
|
||||
else -> offset.y / maxHeight
|
||||
}
|
||||
val currentOffset = when {
|
||||
reverseLayout -> 1f - normalizedOffsetPosition - normalizedThumbSize
|
||||
else -> normalizedOffsetPosition
|
||||
}
|
||||
when (selectionMode) {
|
||||
ScrollbarSelectableMode.Full -> {
|
||||
if (newOffset in currentOffset..(currentOffset + normalizedThumbSize)) {
|
||||
setDragOffset(currentOffset)
|
||||
} else {
|
||||
setScrollOffset(newOffset)
|
||||
}
|
||||
isSelected = true
|
||||
}
|
||||
ScrollbarSelectableMode.Thumb -> {
|
||||
if (newOffset in currentOffset..(currentOffset + normalizedThumbSize)) {
|
||||
setDragOffset(currentOffset)
|
||||
isSelected = true
|
||||
}
|
||||
}
|
||||
ScrollbarSelectableMode.Disabled -> Unit
|
||||
}
|
||||
},
|
||||
onDragStopped = {
|
||||
isSelected = false
|
||||
},
|
||||
)
|
||||
.graphicsLayer(translationX = with(LocalDensity.current) { (if (rightSide) displacement.dp else -displacement.dp).toPx() }),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.graphicsLayer(translationY = constraints.maxHeight.toFloat() * normalizedOffsetPosition)
|
||||
.padding(horizontal = padding)
|
||||
.width(thickness)
|
||||
.clip(thumbShape)
|
||||
.background(if (isSelected) thumbSelectedColor else thumbColor)
|
||||
.fillMaxHeight(normalizedThumbSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package my.nanihadesuka.compose
|
||||
|
||||
/**
|
||||
* Scrollbar selection modes.
|
||||
*/
|
||||
enum class ScrollbarSelectableMode {
|
||||
/**
|
||||
* Enable selection in the whole scrollbar and thumb
|
||||
*/
|
||||
Full,
|
||||
|
||||
/**
|
||||
* Enable selection in the thumb
|
||||
*/
|
||||
Thumb,
|
||||
|
||||
/**
|
||||
* Disable selection
|
||||
*/
|
||||
Disabled,
|
||||
}
|
||||
Reference in new issue
Block a user