diff --git a/app/src/main/java/my/nanihadesuka/lazycolumnscrollbar/MainActivity.kt b/app/src/main/java/my/nanihadesuka/lazycolumnscrollbar/MainActivity.kt index 1e861e5..0610e7d 100644 --- a/app/src/main/java/my/nanihadesuka/lazycolumnscrollbar/MainActivity.kt +++ b/app/src/main/java/my/nanihadesuka/lazycolumnscrollbar/MainActivity.kt @@ -26,6 +26,11 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.staggeredgrid.LazyHorizontalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -47,14 +52,17 @@ import androidx.compose.ui.unit.dp import my.nanihadesuka.compose.ColumnScrollbar import my.nanihadesuka.compose.LazyColumnScrollbar import my.nanihadesuka.compose.LazyHorizontalGridScrollbar +import my.nanihadesuka.compose.LazyHorizontalStaggeredGridScrollbar import my.nanihadesuka.compose.LazyVerticalGridScrollbar import my.nanihadesuka.compose.LazyRowScrollbar +import my.nanihadesuka.compose.LazyVerticalStaggeredGridScrollbar import my.nanihadesuka.compose.RowScrollbar import my.nanihadesuka.compose.ScrollbarSelectionActionable import my.nanihadesuka.compose.ScrollbarSelectionMode import my.nanihadesuka.compose.ScrollbarLayoutSide import my.nanihadesuka.compose.ScrollbarSettings import my.nanihadesuka.lazycolumnscrollbar.ui.theme.LazyColumnScrollbarTheme +import kotlin.random.Random class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -66,7 +74,8 @@ class MainActivity : ComponentActivity() { enum class TypeTab { Column, Row, LazyColumn, LazyRow, - LazyVerticalGrid, LazyHorizontalGrid + LazyVerticalGrid, LazyHorizontalGrid, + LazyVerticalStaggeredGrid, LazyHorizontalStaggeredGrid } @OptIn(ExperimentalLayoutApi::class) @@ -96,6 +105,8 @@ fun MainView() { TypeTab.LazyRow -> LazyRowView() TypeTab.LazyVerticalGrid -> LazyVerticalGridView() TypeTab.LazyHorizontalGrid -> LazyHorizontalGridView() + TypeTab.LazyVerticalStaggeredGrid -> LazyVerticalStaggeredGridView() + TypeTab.LazyHorizontalStaggeredGrid -> LazyHorizontalStaggeredGridView() } } } @@ -313,6 +324,107 @@ fun LazyHorizontalGridView() { } } +@Composable +fun LazyVerticalStaggeredGridView() { + val items by rememberSaveable { + mutableStateOf(List(101) { it to Random.nextFloat() + 0.5F }) + } + + val lazyStaggeredGridState = rememberLazyStaggeredGridState() + Box( + modifier = Modifier + .padding(16.dp) + .border(width = 1.dp, MaterialTheme.colorScheme.primary) + .padding(1.dp) + ) { + LazyVerticalStaggeredGridScrollbar( + state = lazyStaggeredGridState, + settings = ScrollbarSettings( + selectionMode = ScrollbarSelectionMode.Thumb, + alwaysShowScrollbar = true, + ), + indicatorContent = { index, isThumbSelected -> + Indicator(text = "i:$index", isThumbSelected = isThumbSelected) + } + ) { + LazyVerticalStaggeredGrid( + state = lazyStaggeredGridState, + columns = StaggeredGridCells.Adaptive(minSize = 128.dp), + verticalItemSpacing = 3.dp, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + items(items, key = { it.first }) { (index, aspectRatio) -> + Surface( + tonalElevation = 3.dp, + modifier = Modifier.aspectRatio(aspectRatio), + color = Color.Yellow + ) { + Text( + text = "Item $index", + modifier = Modifier + .padding(24.dp), + color = Color.Black + ) + + } + } + } + } + + } +} + +@Composable +fun LazyHorizontalStaggeredGridView() { + val items by rememberSaveable { + mutableStateOf(List(101) { it to Random.nextFloat() + 0.5F }) + } + + val lazyStaggeredGridState = rememberLazyStaggeredGridState() + Box( + modifier = Modifier + .padding(16.dp) + .border(width = 1.dp, MaterialTheme.colorScheme.primary) + .padding(1.dp) + ) { + LazyHorizontalStaggeredGridScrollbar( + state = lazyStaggeredGridState, + settings = ScrollbarSettings( + selectionMode = ScrollbarSelectionMode.Thumb, + alwaysShowScrollbar = false + ), + indicatorContent = { index, isThumbSelected -> + Indicator(text = "i:$index", isThumbSelected = isThumbSelected) + } + ) { + LazyHorizontalStaggeredGrid( + state = lazyStaggeredGridState, + rows = StaggeredGridCells.Adaptive(minSize = 128.dp), + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(3.dp), + horizontalItemSpacing = 3.dp, + ) { + items(items, key = { it.first }) { (index, aspectRatio) -> + Surface( + tonalElevation = 3.dp, + modifier = Modifier.aspectRatio(aspectRatio), + color = Color.Yellow + ) { + Text( + text = "Item $index", + modifier = Modifier + .padding(24.dp), + color = Color.Black + ) + + } + } + } + } + } +} + + @Composable fun ColumnView() { val listData = remember { (0..18).toList() } @@ -419,4 +531,4 @@ fun Indicator(text: String, isThumbSelected: Boolean) { .padding(12.dp) ) } -} \ No newline at end of file +} diff --git a/lib/src/commonMain/kotlin/my/nanihadesuka/compose/LazyHorizontalStaggeredGridScrollbar.kt b/lib/src/commonMain/kotlin/my/nanihadesuka/compose/LazyHorizontalStaggeredGridScrollbar.kt new file mode 100644 index 0000000..6748580 --- /dev/null +++ b/lib/src/commonMain/kotlin/my/nanihadesuka/compose/LazyHorizontalStaggeredGridScrollbar.kt @@ -0,0 +1,60 @@ +package my.nanihadesuka.compose + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import my.nanihadesuka.compose.controller.rememberLazyStaggeredGridStateController +import my.nanihadesuka.compose.generic.ElementScrollbar + +@Composable +fun LazyHorizontalStaggeredGridScrollbar( + state: LazyStaggeredGridState, + modifier: Modifier = Modifier, + reverseLayout: Boolean = false, + settings: ScrollbarSettings = ScrollbarSettings.Default, + indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, + content: @Composable () -> Unit +) { + if (!settings.enabled) content() + else Box(modifier) { + content() + InternalLazyHorizontalGridScrollbar( + state = state, + reverseLayout = reverseLayout, + settings = settings, + indicatorContent = indicatorContent, + ) + } +} + +/** + * Use this variation if you want to place the scrollbar independently of the list position + */ +@Composable +fun InternalLazyHorizontalGridScrollbar( + state: LazyStaggeredGridState, + modifier: Modifier = Modifier, + reverseLayout: Boolean = false, + settings: ScrollbarSettings = ScrollbarSettings.Default, + indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, +) { + val controller = rememberLazyStaggeredGridStateController( + state = state, + reverseLayout = reverseLayout, + thumbMinLength = settings.thumbMinLength, + thumbMaxLength = settings.thumbMaxLength, + alwaysShowScrollBar = settings.alwaysShowScrollbar, + selectionMode = settings.selectionMode, + orientation = Orientation.Horizontal + ) + + ElementScrollbar( + orientation = Orientation.Horizontal, + stateController = controller, + modifier = modifier, + settings = settings, + indicatorContent = indicatorContent + ) +} diff --git a/lib/src/commonMain/kotlin/my/nanihadesuka/compose/LazyVerticalStaggeredGridScrollbar.kt b/lib/src/commonMain/kotlin/my/nanihadesuka/compose/LazyVerticalStaggeredGridScrollbar.kt new file mode 100644 index 0000000..92189af --- /dev/null +++ b/lib/src/commonMain/kotlin/my/nanihadesuka/compose/LazyVerticalStaggeredGridScrollbar.kt @@ -0,0 +1,60 @@ +package my.nanihadesuka.compose + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import my.nanihadesuka.compose.controller.rememberLazyStaggeredGridStateController +import my.nanihadesuka.compose.generic.ElementScrollbar + +@Composable +fun LazyVerticalStaggeredGridScrollbar( + state: LazyStaggeredGridState, + modifier: Modifier = Modifier, + reverseLayout: Boolean = false, + settings: ScrollbarSettings = ScrollbarSettings.Default, + indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, + content: @Composable () -> Unit +) { + if (!settings.enabled) content() + else Box(modifier) { + content() + InternalLazyVerticalGridScrollbar( + state = state, + reverseLayout = reverseLayout, + settings = settings, + indicatorContent = indicatorContent, + ) + } +} + +/** + * Use this variation if you want to place the scrollbar independently of the list position + */ +@Composable +fun InternalLazyVerticalGridScrollbar( + state: LazyStaggeredGridState, + modifier: Modifier = Modifier, + reverseLayout: Boolean = false, + settings: ScrollbarSettings = ScrollbarSettings.Default, + indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, +) { + val controller = rememberLazyStaggeredGridStateController( + state = state, + reverseLayout = reverseLayout, + thumbMinLength = settings.thumbMinLength, + thumbMaxLength = settings.thumbMaxLength, + alwaysShowScrollBar = settings.alwaysShowScrollbar, + selectionMode = settings.selectionMode, + orientation = Orientation.Vertical + ) + + ElementScrollbar( + orientation = Orientation.Vertical, + stateController = controller, + modifier = modifier, + settings = settings, + indicatorContent = indicatorContent + ) +} diff --git a/lib/src/commonMain/kotlin/my/nanihadesuka/compose/controller/LazyStaggeredGridStateController.kt b/lib/src/commonMain/kotlin/my/nanihadesuka/compose/controller/LazyStaggeredGridStateController.kt new file mode 100644 index 0000000..e62075d --- /dev/null +++ b/lib/src/commonMain/kotlin/my/nanihadesuka/compose/controller/LazyStaggeredGridStateController.kt @@ -0,0 +1,285 @@ +package my.nanihadesuka.compose.controller + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +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.ceil +import kotlin.math.floor + +@Composable +internal fun rememberLazyStaggeredGridStateController( + state: LazyStaggeredGridState, + reverseLayout: Boolean, + thumbMinLength: Float, + thumbMaxLength: Float, + alwaysShowScrollBar: Boolean, + selectionMode: ScrollbarSelectionMode, + orientation: Orientation +): LazyStaggeredGridStateController { + val coroutineScope = rememberCoroutineScope() + + val thumbMinLengthUpdated = rememberUpdatedState(thumbMinLength) + val thumbMaxLengthUpdated = rememberUpdatedState(thumbMaxLength) + val alwaysShowScrollBarUpdated = rememberUpdatedState(alwaysShowScrollBar) + val selectionModeUpdated = rememberUpdatedState(selectionMode) + val orientationUpdated = rememberUpdatedState(orientation) + val reverseLayout = remember { derivedStateOf { reverseLayout } } + + val isSelected = remember { mutableStateOf(false) } + val dragOffset = remember { mutableFloatStateOf(0f) } + + val realFirstVisibleItem = remember { + derivedStateOf { + state.layoutInfo.visibleItemsInfo.firstOrNull { + it.index == state.firstVisibleItemIndex + } + } + } + + // Workaround to know indirectly how many columns/rows are being used (LazyGridState doesn't store it) + val nElementsMainAxis = remember { + derivedStateOf { + var count = 0 + for (item in state.layoutInfo.visibleItemsInfo) { + val index = when (orientation) { + Orientation.Vertical -> item.lane + Orientation.Horizontal -> item.lane + } + if (index == -1) + break + if (count == index) { + 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 LazyStaggeredGridItemInfo.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 LazyStaggeredGridItemInfo.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 thumbSizeNormalizedReal = 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 = + ceil(it.visibleItemsInfo.size.toFloat() / nElementsMainAxis.value.toFloat()) - if (isStickyHeaderInAction.value) 1f else 0f + val realVisibleSize = realSize - firstPartial - lastPartial + realVisibleSize / ceil(it.totalItemsCount.toFloat() / nElementsMainAxis.value.toFloat()) + } + } + } + + val thumbSizeNormalized = remember { + derivedStateOf { + thumbSizeNormalizedReal.value.coerceIn( + thumbMinLengthUpdated.value, + thumbMaxLengthUpdated.value, + ) + } + } + + fun offsetCorrection(top: Float): Float { + val topRealMax = (1f - thumbSizeNormalizedReal.value).coerceIn(0f, 1f) + if (thumbSizeNormalizedReal.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 thumbOffsetNormalized = 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 { + ceil(index.toFloat() / nElementsMainAxis.value.toFloat()) + fractionHiddenTop( + state.firstVisibleItemScrollOffset + ) + } / ceil(it.totalItemsCount.toFloat() / nElementsMainAxis.value.toFloat()) + offsetCorrection(top) + } + } + } + + val thumbIsInAction = remember { + derivedStateOf { + state.isScrollInProgress || isSelected.value || alwaysShowScrollBarUpdated.value + } + } + + return remember { + LazyStaggeredGridStateController( + thumbSizeNormalized = thumbSizeNormalized, + thumbSizeNormalizedReal = thumbSizeNormalizedReal, + thumbOffsetNormalized = thumbOffsetNormalized, + thumbIsInAction = thumbIsInAction, + _isSelected = isSelected, + dragOffset = dragOffset, + selectionMode = selectionModeUpdated, + realFirstVisibleItem = realFirstVisibleItem, + thumbMinLength = thumbMinLengthUpdated, + reverseLayout = reverseLayout, + orientation = orientationUpdated, + nElementsMainAxis = nElementsMainAxis, + state = state, + coroutineScope = coroutineScope + ) + } +} + +internal class LazyStaggeredGridStateController( + override val thumbSizeNormalized: State, + override val thumbOffsetNormalized: State, + override val thumbIsInAction: State, + private val _isSelected: MutableState, + private val dragOffset: MutableFloatState, + private val selectionMode: State, + private val realFirstVisibleItem: State, + private val thumbSizeNormalizedReal: State, + private val thumbMinLength: State, + private val reverseLayout: State, + private val orientation: State, + private val nElementsMainAxis: State, + private val state: LazyStaggeredGridState, + 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 - thumbOffsetNormalized.value - thumbSizeNormalized.value + else -> thumbOffsetNormalized.value + } + + when (selectionMode.value) { + ScrollbarSelectionMode.Full -> { + if (newOffset in currentOffset..(currentOffset + thumbSizeNormalized.value)) + setDragOffset(currentOffset) + else + setScrollOffset(newOffset) + _isSelected.value = true + } + + ScrollbarSelectionMode.Thumb -> { + if (newOffset in currentOffset..(currentOffset + thumbSizeNormalized.value)) { + setDragOffset(currentOffset) + _isSelected.value = true + } + } + + ScrollbarSelectionMode.Disabled -> Unit + } + } + + override fun onDragStopped() { + _isSelected.value = false + } + + private fun setScrollOffset(newOffset: Float) { + setDragOffset(newOffset) + val totalItemsCount = + ceil(state.layoutInfo.totalItemsCount.toFloat() / nElementsMainAxis.value.toFloat()) + val exactIndex = offsetCorrectionInverse(totalItemsCount * dragOffset.floatValue) + val index: Int = floor(exactIndex).toInt() * nElementsMainAxis.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 - thumbSizeNormalized.value).coerceAtLeast(0f) + dragOffset.floatValue = value.coerceIn(0f, maxValue) + } + + private fun offsetCorrectionInverse(top: Float): Float { + if (thumbSizeNormalizedReal.value >= thumbMinLength.value) + return top + val topRealMax = 1f - thumbSizeNormalizedReal.value + val topMax = 1f - thumbMinLength.value + return top * topRealMax / topMax + } +}