Convert to use tachiyomi fastscroller impl

This commit is contained in:
tarsin committed 2023-08-02 08:42:55 +08:00
1 parent 2a95d6707a
commit cfe7ee7f9c
9 files changed
+607 -749

No files matched your search

+1
View File
@@ -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,
}