From cfe7ee7f9cad018c59d7886429972f2eb3fbc2e5 Mon Sep 17 00:00:00 2001 From: tarsin Date: Wed, 2 Aug 2023 08:42:47 +0800 Subject: [PATCH] Convert to use tachiyomi fastscroller impl --- app/build.gradle.kts | 1 + .../ehviewer/ui/scene/GalleryPreviewScreen.kt | 41 +- .../hippo/ehviewer/ui/scene/HistoryScene.kt | 115 +++-- .../com/hippo/ehviewer/ui/tools/LazyGrid.kt | 51 ++ .../com/hippo/ehviewer/ui/tools/LazyList.kt | 40 ++ .../ehviewer/ui/tools/VerticalFastScroller.kt | 444 ++++++++++++++++++ .../compose/LazyColumnScrollbar.kt | 321 ------------- .../nanihadesuka/compose/LazyGridScrollbar.kt | 322 ------------- .../compose/ScrollbarSelectableMode.kt | 21 - 9 files changed, 607 insertions(+), 749 deletions(-) create mode 100644 app/src/main/java/com/hippo/ehviewer/ui/tools/LazyGrid.kt create mode 100644 app/src/main/java/com/hippo/ehviewer/ui/tools/LazyList.kt create mode 100644 app/src/main/java/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt delete mode 100644 app/src/main/java/my/nanihadesuka/compose/LazyColumnScrollbar.kt delete mode 100644 app/src/main/java/my/nanihadesuka/compose/LazyGridScrollbar.kt delete mode 100644 app/src/main/java/my/nanihadesuka/compose/ScrollbarSelectableMode.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 39da2bffc..7a70e1187 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryPreviewScreen.kt b/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryPreviewScreen.kt index 0aeccd7f4..a98f8b92b 100644 --- a/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryPreviewScreen.kt +++ b/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryPreviewScreen.kt @@ -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) - } } } } diff --git a/app/src/main/java/com/hippo/ehviewer/ui/scene/HistoryScene.kt b/app/src/main/java/com/hippo/ehviewer/ui/scene/HistoryScene.kt index 2fe4024c5..c94e419aa 100644 --- a/app/src/main/java/com/hippo/ehviewer/ui/scene/HistoryScene.kt +++ b/app/src/main/java/com/hippo/ehviewer/ui/scene/HistoryScene.kt @@ -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) { diff --git a/app/src/main/java/com/hippo/ehviewer/ui/tools/LazyGrid.kt b/app/src/main/java/com/hippo/ehviewer/ui/tools/LazyGrid.kt new file mode 100644 index 000000000..68a22a7f6 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/ui/tools/LazyGrid.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/ui/tools/LazyList.kt b/app/src/main/java/com/hippo/ehviewer/ui/tools/LazyList.kt new file mode 100644 index 000000000..deb0b03f3 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/ui/tools/LazyList.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt b/app/src/main/java/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt new file mode 100644 index 000000000..fa66b0e5a --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt @@ -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( + 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 List>( + 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.. 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( + 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( + durationMillis = ViewConfiguration.getScrollBarFadeDuration(), + delayMillis = 2000, +) +private val ImmediateFadeOutAnimationSpec = tween( + durationMillis = ViewConfiguration.getScrollBarFadeDuration(), +) + +private val LazyListItemInfo.top: Int + get() = offset + +private val LazyListItemInfo.bottom: Int + get() = offset + size diff --git a/app/src/main/java/my/nanihadesuka/compose/LazyColumnScrollbar.kt b/app/src/main/java/my/nanihadesuka/compose/LazyColumnScrollbar.kt deleted file mode 100644 index 844de4110..000000000 --- a/app/src/main/java/my/nanihadesuka/compose/LazyColumnScrollbar.kt +++ /dev/null @@ -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), - ) - } - } -} diff --git a/app/src/main/java/my/nanihadesuka/compose/LazyGridScrollbar.kt b/app/src/main/java/my/nanihadesuka/compose/LazyGridScrollbar.kt deleted file mode 100644 index 5044f7bb0..000000000 --- a/app/src/main/java/my/nanihadesuka/compose/LazyGridScrollbar.kt +++ /dev/null @@ -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), - ) - } - } -} diff --git a/app/src/main/java/my/nanihadesuka/compose/ScrollbarSelectableMode.kt b/app/src/main/java/my/nanihadesuka/compose/ScrollbarSelectableMode.kt deleted file mode 100644 index 4ff08c5e0..000000000 --- a/app/src/main/java/my/nanihadesuka/compose/ScrollbarSelectableMode.kt +++ /dev/null @@ -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, -}