diff --git a/lib/src/main/java/my/nanihadesuka/compose/LazyGridVerticalScrollbar.kt b/lib/src/main/java/my/nanihadesuka/compose/LazyGridVerticalScrollbar.kt index c623dc6..dd35beb 100644 --- a/lib/src/main/java/my/nanihadesuka/compose/LazyGridVerticalScrollbar.kt +++ b/lib/src/main/java/my/nanihadesuka/compose/LazyGridVerticalScrollbar.kt @@ -1,36 +1,22 @@ package my.nanihadesuka.compose 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.fillMaxWidth -import androidx.compose.foundation.lazy.grid.LazyGridItemInfo import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.shape.CircleShape 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.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import my.nanihadesuka.compose.foundation.ScrollbarLayoutSettings -import my.nanihadesuka.compose.foundation.VerticalScrollbarLayout -import kotlin.math.floor +import my.nanihadesuka.compose.controller.rememberLazyGridStateController +import my.nanihadesuka.compose.generic.ElementScrollbar /** * @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) + * @param thumbMinLength Thumb minimum length proportional to total scrollbar's length (eg: 0.1 -> 10% of total) */ @Composable fun LazyGridVerticalScrollbar( @@ -40,7 +26,7 @@ fun LazyGridVerticalScrollbar( alwaysShowScrollBar: Boolean = false, thickness: Dp = 6.dp, padding: Dp = 8.dp, - thumbMinHeight: Float = 0.1f, + thumbMinLength: Float = 0.1f, thumbColor: Color = Color(0xFF2A59B6), thumbSelectedColor: Color = Color(0xFF5281CA), thumbShape: Shape = CircleShape, @@ -61,7 +47,7 @@ fun LazyGridVerticalScrollbar( alwaysShowScrollBar = alwaysShowScrollBar, thickness = thickness, padding = padding, - thumbMinHeight = thumbMinHeight, + thumbMinLength = thumbMinLength, thumbColor = thumbColor, thumbSelectedColor = thumbSelectedColor, selectionActionable = selectionActionable, @@ -74,10 +60,9 @@ fun LazyGridVerticalScrollbar( } /** - * internal function * @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) + * @param thumbMinLength Thumb minimum height proportional to total scrollbar's height (eg: 0.1 -> 10% of total) */ @Composable internal fun InternalLazyGridVerticalScrollbar( @@ -87,7 +72,7 @@ internal fun InternalLazyGridVerticalScrollbar( alwaysShowScrollBar: Boolean = false, thickness: Dp = 6.dp, padding: Dp = 8.dp, - thumbMinHeight: Float = 0.1f, + thumbMinLength: Float = 0.1f, thumbColor: Color = Color(0xFF2A59B6), thumbSelectedColor: Color = Color(0xFF5281CA), thumbShape: Shape = CircleShape, @@ -96,211 +81,27 @@ internal fun InternalLazyGridVerticalScrollbar( hideDelayMillis: Int = 400, indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, ) { - val firstVisibleItemIndex = remember { derivedStateOf { state.firstVisibleItemIndex } } - - val coroutineScope = rememberCoroutineScope() - - var isSelected by remember { mutableStateOf(false) } - - var dragOffset by remember { mutableFloatStateOf(0f) } - - val reverseLayout by remember { derivedStateOf { state.layoutInfo.reverseLayout } } - - val realFirstVisibleItem by remember { - derivedStateOf { - state.layoutInfo.visibleItemsInfo.firstOrNull { - it.index == state.firstVisibleItemIndex - } - } - } - - // Workaround to know indirectly how many columns are being used (LazyGridState doesn't store it) - val nColumns by remember { - derivedStateOf { - var count = 0 - for (item in state.layoutInfo.visibleItemsInfo) { - if (item.column == -1) - break - if (count == item.column) { - count += 1 - } else { - break - } - } - count.coerceAtLeast(1) - } - } - - val isStickyHeaderInAction by remember { - derivedStateOf { - val realIndex = realFirstVisibleItem?.index ?: return@derivedStateOf false - val firstVisibleIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index - ?: return@derivedStateOf false - realIndex != firstVisibleIndex - } - } - - fun LazyGridItemInfo.fractionHiddenTop(firstItemOffset: Int) = - if (size.height == 0) 0f else firstItemOffset / size.height.toFloat() - - fun LazyGridItemInfo.fractionVisibleBottom(viewportEndOffset: Int) = - if (size.height == 0) 0f else (viewportEndOffset - offset.y).toFloat() / size.height.toFloat() - - val normalizedThumbSizeReal by remember { - derivedStateOf { - state.layoutInfo.let { - if (it.totalItemsCount == 0) - return@let 0f - - val firstItem = realFirstVisibleItem ?: return@let 0f - val firstPartial = - firstItem.fractionHiddenTop(state.firstVisibleItemScrollOffset) - val lastPartial = - 1f - it.visibleItemsInfo.last().fractionVisibleBottom(it.viewportEndOffset) - - val realSize = - (it.visibleItemsInfo.size / nColumns) - if (isStickyHeaderInAction) 1 else 0 - val realVisibleSize = realSize.toFloat() - firstPartial - lastPartial - realVisibleSize / (it.totalItemsCount / nColumns).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 { - state.layoutInfo.let { - if (it.totalItemsCount == 0 || it.visibleItemsInfo.isEmpty()) - return@let 0f - - val firstItem = realFirstVisibleItem ?: return@let 0f - val top = firstItem.run { - (index / nColumns).toFloat() + fractionHiddenTop(state.firstVisibleItemScrollOffset) - } / (it.totalItemsCount / nColumns).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 = state.layoutInfo.totalItemsCount.toFloat() / nColumns.toFloat() - val exactIndex = offsetCorrectionInverse(totalItemsCount * dragOffset) - val index: Int = floor(exactIndex).toInt() * nColumns - val remainder: Float = exactIndex - floor(exactIndex) - - coroutineScope.launch { - state.scrollToItem(index = index, scrollOffset = 0) - val offset = realFirstVisibleItem - ?.size - ?.let { it.height.toFloat() * remainder } - ?.toInt() ?: 0 - state.scrollToItem(index = index, scrollOffset = offset) - } - } - - val isInAction = state.isScrollInProgress || isSelected || alwaysShowScrollBar - - BoxWithConstraints( - modifier = modifier.fillMaxWidth() - ) { - val maxHeightFloat = constraints.maxHeight.toFloat() - - VerticalScrollbarLayout( - thumbSizeNormalized = normalizedThumbSize, - thumbOffsetNormalized = normalizedOffsetPosition, - thumbIsInAction = isInAction, - settings = ScrollbarLayoutSettings( - durationAnimationMillis = 500, - hideDelayMillis = hideDelayMillis, - scrollbarPadding = padding, - thumbShape = thumbShape, - thumbThickness = thickness, - thumbColor = if (isSelected) thumbSelectedColor else thumbColor, - side = side, - selectionActionable = selectionActionable - ), - indicator = indicatorContent?.let { - { it(firstVisibleItemIndex.value, isSelected) } - }, - draggableModifier = Modifier.draggable( - state = rememberDraggableState { delta -> - val displace = if (reverseLayout) -delta else delta // side effect ? - if (isSelected) { - setScrollOffset(dragOffset + displace / maxHeightFloat) - } - }, - orientation = Orientation.Vertical, - enabled = selectionMode != ScrollbarSelectionMode.Disabled, - startDragImmediately = true, - onDragStarted = onDragStarted@{ offset -> - if (maxHeightFloat <= 0f) return@onDragStarted - val newOffset = when { - reverseLayout -> (maxHeightFloat - offset.y) / maxHeightFloat - else -> offset.y / maxHeightFloat - } - val currentOffset = when { - reverseLayout -> 1f - normalizedOffsetPosition - normalizedThumbSize - else -> normalizedOffsetPosition - } - - when (selectionMode) { - ScrollbarSelectionMode.Full -> { - if (newOffset in currentOffset..(currentOffset + normalizedThumbSize)) - setDragOffset(currentOffset) - else - setScrollOffset(newOffset) - isSelected = true - } - - ScrollbarSelectionMode.Thumb -> { - if (newOffset in currentOffset..(currentOffset + normalizedThumbSize)) { - setDragOffset(currentOffset) - isSelected = true - } - } - - ScrollbarSelectionMode.Disabled -> Unit - } - }, - onDragStopped = { - isSelected = false - } - ) - ) - } + val controller = rememberLazyGridStateController( + state = state, + thumbMinLength = thumbMinLength, + alwaysShowScrollBar = alwaysShowScrollBar, + selectionMode = selectionMode, + orientation = Orientation.Vertical + ) + + ElementScrollbar( + orientation = Orientation.Vertical, + stateController = controller, + modifier = modifier, + side = side, + thickness = thickness, + padding = padding, + thumbColor = thumbColor, + thumbSelectedColor = thumbSelectedColor, + thumbShape = thumbShape, + selectionMode = selectionMode, + selectionActionable = selectionActionable, + hideDelayMillis = hideDelayMillis, + indicatorContent = indicatorContent + ) } diff --git a/lib/src/main/java/my/nanihadesuka/compose/controller/LazyGridStateController.kt b/lib/src/main/java/my/nanihadesuka/compose/controller/LazyGridStateController.kt new file mode 100644 index 0000000..9577d6c --- /dev/null +++ b/lib/src/main/java/my/nanihadesuka/compose/controller/LazyGridStateController.kt @@ -0,0 +1,275 @@ +package my.nanihadesuka.compose.controller + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import my.nanihadesuka.compose.ScrollbarSelectionMode +import kotlin.math.floor + +@Composable +internal fun rememberLazyGridStateController( + state: LazyGridState, + thumbMinLength: Float, + alwaysShowScrollBar: Boolean, + selectionMode: ScrollbarSelectionMode, + orientation: Orientation +): LazyGridStateController { + + val alwaysShowScrollBarUpdated = rememberUpdatedState(alwaysShowScrollBar) + val thumbMinLengthUpdated = rememberUpdatedState(thumbMinLength) + val selectionModeUpdated = rememberUpdatedState(selectionMode) + val orientationUpdated = rememberUpdatedState(orientation) + + val coroutineScope = rememberCoroutineScope() + + val isSelected = remember { mutableStateOf(false) } + + val dragOffset = remember { mutableFloatStateOf(0f) } + + val reverseLayout = remember { derivedStateOf { state.layoutInfo.reverseLayout } } + + val realFirstVisibleItem = remember { + derivedStateOf { + state.layoutInfo.visibleItemsInfo.firstOrNull { + it.index == state.firstVisibleItemIndex + } + } + } + + // Workaround to know indirectly how many columns are being used (LazyGridState doesn't store it) + val nColumns = remember { + derivedStateOf { + var count = 0 + for (item in state.layoutInfo.visibleItemsInfo) { + if (item.column == -1) + break + if (count == item.column) { + count += 1 + } else { + break + } + } + count.coerceAtLeast(1) + } + } + + val isStickyHeaderInAction = remember { + derivedStateOf { + val realIndex = realFirstVisibleItem.value?.index ?: return@derivedStateOf false + val firstVisibleIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index + ?: return@derivedStateOf false + realIndex != firstVisibleIndex + } + } + + fun LazyGridItemInfo.fractionHiddenTop(firstItemOffset: Int): Float { + return when (orientationUpdated.value) { + Orientation.Vertical -> if (size.height == 0) 0f else firstItemOffset / size.width.toFloat() + Orientation.Horizontal -> if (size.width == 0) 0f else firstItemOffset / size.width.toFloat() + } + } + + fun LazyGridItemInfo.fractionVisibleBottom(viewportEndOffset: Int): Float { + return when (orientationUpdated.value) { + Orientation.Vertical -> if (size.height == 0) 0f else (viewportEndOffset - offset.y).toFloat() / size.height.toFloat() + Orientation.Horizontal -> if (size.width == 0) 0f else (viewportEndOffset - offset.x).toFloat() / size.width.toFloat() + } + } + + + val normalizedThumbSizeReal = remember { + derivedStateOf { + state.layoutInfo.let { + if (it.totalItemsCount == 0) + return@let 0f + + val firstItem = realFirstVisibleItem.value ?: return@let 0f + val firstPartial = + firstItem.fractionHiddenTop(state.firstVisibleItemScrollOffset) + val lastPartial = + 1f - it.visibleItemsInfo.last().fractionVisibleBottom(it.viewportEndOffset) + + val realSize = + (it.visibleItemsInfo.size / nColumns.value) - if (isStickyHeaderInAction.value) 1 else 0 + val realVisibleSize = realSize.toFloat() - firstPartial - lastPartial + realVisibleSize / (it.totalItemsCount / nColumns.value).toFloat() + } + } + } + + val normalizedThumbSize = remember { + derivedStateOf { + normalizedThumbSizeReal.value.coerceAtLeast(thumbMinLengthUpdated.value) + } + } + + fun offsetCorrection(top: Float): Float { + val topRealMax = (1f - normalizedThumbSizeReal.value).coerceIn(0f, 1f) + if (normalizedThumbSizeReal.value >= thumbMinLengthUpdated.value) { + return when { + reverseLayout.value -> topRealMax - top + else -> top + } + } + + val topMax = 1f - thumbMinLengthUpdated.value + return when { + reverseLayout.value -> (topRealMax - top) * topMax / topRealMax + else -> top * topMax / topRealMax + } + } + + val normalizedOffsetPosition = remember { + derivedStateOf { + state.layoutInfo.let { + if (it.totalItemsCount == 0 || it.visibleItemsInfo.isEmpty()) + return@let 0f + + val firstItem = realFirstVisibleItem.value ?: return@let 0f + val top = firstItem.run { + (index / nColumns.value).toFloat() + fractionHiddenTop(state.firstVisibleItemScrollOffset) + } / (it.totalItemsCount / nColumns.value).toFloat() + offsetCorrection(top) + } + } + } + + val isInAction = remember { + derivedStateOf { + state.isScrollInProgress || isSelected.value || alwaysShowScrollBarUpdated.value + } + } + + return remember { + LazyGridStateController( + normalizedThumbSize = normalizedThumbSize, + normalizedOffsetPosition = normalizedOffsetPosition, + thumbIsInAction = isInAction, + _isSelected = isSelected, + dragOffset = dragOffset, + selectionMode = selectionModeUpdated, + realFirstVisibleItem = realFirstVisibleItem, + thumbMinLength = thumbMinLengthUpdated, + normalizedThumbSizeReal = normalizedThumbSizeReal, + reverseLayout = reverseLayout, + orientation = orientationUpdated, + nColumns = nColumns, + state = state, + coroutineScope = coroutineScope + ) + } +} + +internal class LazyGridStateController( + override val normalizedThumbSize: State, + override val normalizedOffsetPosition: State, + override val thumbIsInAction: State, + private val _isSelected: MutableState, + private val dragOffset: MutableFloatState, + private val selectionMode: State, + private val realFirstVisibleItem: State, + private val normalizedThumbSizeReal: State, + private val thumbMinLength: State, + private val reverseLayout: State, + private val orientation: State, + private val nColumns: State, + private val state: LazyGridState, + private val coroutineScope: CoroutineScope, +) : StateController { + + override val isSelected = _isSelected + + override fun indicatorValue(): Int { + return state.firstVisibleItemIndex + } + + override fun onDraggableState(deltaPixels: Float, maxLengthPixels: Float) { + val displace = if (reverseLayout.value) -deltaPixels else deltaPixels // side effect ? + if (isSelected.value) { + setScrollOffset(dragOffset.floatValue + displace / maxLengthPixels) + } + } + + override fun onDragStarted(offsetPixels: Float, maxLengthPixels: Float) { + if (maxLengthPixels <= 0f) return + val newOffset = when { + reverseLayout.value -> (maxLengthPixels - offsetPixels) / maxLengthPixels + else -> offsetPixels / maxLengthPixels + } + val currentOffset = when { + reverseLayout.value -> 1f - normalizedOffsetPosition.value - normalizedThumbSize.value + else -> normalizedOffsetPosition.value + } + + when (selectionMode.value) { + ScrollbarSelectionMode.Full -> { + if (newOffset in currentOffset..(currentOffset + normalizedThumbSize.value)) + setDragOffset(currentOffset) + else + setScrollOffset(newOffset) + _isSelected.value = true + } + + ScrollbarSelectionMode.Thumb -> { + if (newOffset in currentOffset..(currentOffset + normalizedThumbSize.value)) { + setDragOffset(currentOffset) + _isSelected.value = true + } + } + + ScrollbarSelectionMode.Disabled -> Unit + } + } + + override fun onDragStopped() { + _isSelected.value = false + } + + private fun setScrollOffset(newOffset: Float) { + setDragOffset(newOffset) + val totalItemsCount = state.layoutInfo.totalItemsCount.toFloat() / nColumns.value.toFloat() + val exactIndex = offsetCorrectionInverse(totalItemsCount * dragOffset.floatValue) + val index: Int = floor(exactIndex).toInt() * nColumns.value + val remainder: Float = exactIndex - floor(exactIndex) + + coroutineScope.launch { + state.scrollToItem(index = index, scrollOffset = 0) + val offset = realFirstVisibleItem.value + ?.size + ?.let { + val size = when (orientation.value) { + Orientation.Vertical -> it.height + Orientation.Horizontal -> it.width + } + size.toFloat() * remainder + } + ?.toInt() ?: 0 + state.scrollToItem(index = index, scrollOffset = offset) + } + } + + private fun setDragOffset(value: Float) { + val maxValue = (1f - normalizedThumbSize.value).coerceAtLeast(0f) + dragOffset.floatValue = value.coerceIn(0f, maxValue) + } + + private fun offsetCorrectionInverse(top: Float): Float { + if (normalizedThumbSizeReal.value >= thumbMinLength.value) + return top + val topRealMax = 1f - normalizedThumbSizeReal.value + val topMax = 1f - thumbMinLength.value + return top * topRealMax / topMax + } +} \ No newline at end of file diff --git a/lib/src/test/java/my/nanihadesuka/compose/LazyGridVerticalScrollbarTest.kt b/lib/src/test/java/my/nanihadesuka/compose/LazyGridVerticalScrollbarTest.kt index 8f8a3a7..3bf50e7 100644 --- a/lib/src/test/java/my/nanihadesuka/compose/LazyGridVerticalScrollbarTest.kt +++ b/lib/src/test/java/my/nanihadesuka/compose/LazyGridVerticalScrollbarTest.kt @@ -448,7 +448,7 @@ class LazyGridVerticalScrollbarTest(private val itemCount: Int) { thickness = thickness, padding = padding, enabled = enabled, - thumbMinHeight = thumbMinLength, + thumbMinLength = thumbMinLength, thumbColor = thumbColor, thumbSelectedColor = thumbSelectedColor, thumbShape = thumbShape,