diff --git a/README.md b/README.md index 63da512..118dd15 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Compose implementation of the scroll bar. Can drag, scroll smoothly and includes - Support for LazyColumn's sticky headers - Support for LazyColumn's reverseLayout - Optional current position indicator -- Multiple selection states (Disabled, Full or Thumb) +- Multiple selection states (Disabled, Full, Thumb) +- Multiple selection actionable states (Always, WhenVisible) - Customizable look - Easy integration with other composables - UI tests @@ -31,8 +32,8 @@ Add it to your app build.gradle ```groovy dependencies { - implementation 'com.github.nanihadesuka:LazyColumnScrollbar:1.6.3' - } + implementation 'com.github.nanihadesuka:LazyColumnScrollbar:1.7.0' +} ``` # How to use for lazyColumn @@ -58,22 +59,13 @@ LazyColumnScrollbar(listState) { ``` indicatorContent example: -``` +```kotlin indicatorContent = { index, isThumbSelected -> Text( text = "i: $index", Modifier - .clip( - RoundedCornerShape( - topStart = 20.dp, - bottomStart = 20.dp, - bottomEnd = 16.dp - ) - ) - .background(Color.Green) - .padding(8.dp) - .clip(CircleShape) - .background(if (isThumbSelected) Color.Red else Color.Black) + .padding(4.dp) + .background(if (isThumbSelected) Color.Red else Color.Black, CircleShape) .padding(12.dp) ) } @@ -92,6 +84,8 @@ fun LazyColumnScrollbar( thumbSelectedColor: Color = Color(0xFF5281CA), thumbShape: Shape = CircleShape, selectionMode: ScrollbarSelectionMode = ScrollbarSelectionMode.Thumb, + selectionActionable: ScrollbarSelectionActionable = ScrollbarSelectionActionable.Always, + hideDelayMillis: Int = 400, enabled: Boolean = true, indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, content: @Composable () -> Unit @@ -135,6 +129,8 @@ fun ColumnScrollbar( thumbShape: Shape = CircleShape, enabled: Boolean = true, selectionMode: ScrollbarSelectionMode = ScrollbarSelectionMode.Thumb, + selectionActionable: ScrollbarSelectionActionable = ScrollbarSelectionActionable.Always, + hideDelayMillis: Int = 400, indicatorContent: (@Composable (normalizedOffset: Float, isThumbSelected: Boolean) -> Unit)? = null, content: @Composable () -> Unit ) diff --git a/app/src/main/java/my/nanihadesuka/lazycolumnscrollbar/MainActivity.kt b/app/src/main/java/my/nanihadesuka/lazycolumnscrollbar/MainActivity.kt index b7254fd..4a1074c 100644 --- a/app/src/main/java/my/nanihadesuka/lazycolumnscrollbar/MainActivity.kt +++ b/app/src/main/java/my/nanihadesuka/lazycolumnscrollbar/MainActivity.kt @@ -3,7 +3,9 @@ package my.nanihadesuka.lazycolumnscrollbar import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -11,8 +13,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text @@ -49,7 +53,7 @@ fun MainView() { @OptIn(ExperimentalFoundationApi::class) @Composable fun LazyColumnView() { - val listData = (0..30).toList() + val listData = (0..1000).toList() val listState = rememberLazyListState() Box( diff --git a/lib/build.gradle b/lib/build.gradle index e229e7b..716ce59 100644 --- a/lib/build.gradle +++ b/lib/build.gradle @@ -10,8 +10,8 @@ android { defaultConfig { minSdk 21 targetSdk 33 - versionCode 14 - versionName "1.6.3" + versionCode 15 + versionName "1.7.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" diff --git a/lib/src/main/java/my/nanihadesuka/compose/ColumnScrollbar.kt b/lib/src/main/java/my/nanihadesuka/compose/ColumnScrollbar.kt index 90a27fb..cf6653d 100644 --- a/lib/src/main/java/my/nanihadesuka/compose/ColumnScrollbar.kt +++ b/lib/src/main/java/my/nanihadesuka/compose/ColumnScrollbar.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -44,6 +45,8 @@ fun ColumnScrollbar( thumbShape: Shape = CircleShape, enabled: Boolean = true, selectionMode: ScrollbarSelectionMode = ScrollbarSelectionMode.Thumb, + selectionActionable: ScrollbarSelectionActionable = ScrollbarSelectionActionable.Always, + hideDelayMillis: Int = 400, indicatorContent: (@Composable (normalizedOffset: Float, isThumbSelected: Boolean) -> Unit)? = null, content: @Composable () -> Unit ) { @@ -62,6 +65,8 @@ fun ColumnScrollbar( visibleHeightDp = with(LocalDensity.current) { constraints.maxHeight.toDp() }, indicatorContent = indicatorContent, selectionMode = selectionMode, + selectionActionable = selectionActionable, + hideDelayMillis = hideDelayMillis, ) } } @@ -87,6 +92,8 @@ fun InternalColumnScrollbar( thumbSelectedColor: Color = Color(0xFF5281CA), thumbShape: Shape = CircleShape, selectionMode: ScrollbarSelectionMode = ScrollbarSelectionMode.Thumb, + selectionActionable: ScrollbarSelectionActionable = ScrollbarSelectionActionable.Always, + hideDelayMillis: Int = 400, indicatorContent: (@Composable (normalizedOffset: Float, isThumbSelected: Boolean) -> Unit)? = null, visibleHeightDp: Dp, ) { @@ -151,16 +158,33 @@ fun InternalColumnScrollbar( val isInAction = state.isScrollInProgress || isSelected + val isInActionSelectable = remember { mutableStateOf(isInAction) } + val durationAnimationMillis: Int = 500 + LaunchedEffect(isInAction) { + if (isInAction) { + isInActionSelectable.value = true + } else { + delay(timeMillis = durationAnimationMillis.toLong() + hideDelayMillis.toLong()) + isInActionSelectable.value = false + } + } + val alpha by animateFloatAsState( - targetValue = if (isInAction) 1f else 0f, animationSpec = tween( - durationMillis = if (isInAction) 75 else 500, delayMillis = if (isInAction) 0 else 500 - ) + targetValue = if (isInAction) 1f else 0f, + animationSpec = tween( + durationMillis = if (isInAction) 75 else durationAnimationMillis, + delayMillis = if (isInAction) 0 else hideDelayMillis + ), + label = "scrollbar alpha value" ) val displacement by animateFloatAsState( - targetValue = if (isInAction) 0f else 14f, animationSpec = tween( - durationMillis = if (isInAction) 75 else 500, delayMillis = if (isInAction) 0 else 500 - ) + targetValue = if (isInAction) 0f else 14f, + animationSpec = tween( + durationMillis = if (isInAction) 75 else durationAnimationMillis, + delayMillis = if (isInAction) 0 else hideDelayMillis + ), + label = "scrollbar displacement value" ) BoxWithConstraints( @@ -226,21 +250,31 @@ fun InternalColumnScrollbar( onDragStarted = { offset -> val newOffset = offset.y / constraints.maxHeight.toFloat() val currentOffset = normalizedOffsetPosition - when (selectionMode) { - ScrollbarSelectionMode.Full -> { - if (newOffset in currentOffset..(currentOffset + normalizedThumbSizeUpdated)) - setDragOffset(currentOffset) - else - setScrollOffset(newOffset) - isSelected = true - } - ScrollbarSelectionMode.Thumb -> { - if (newOffset in currentOffset..(currentOffset + normalizedThumbSizeUpdated)) { - setDragOffset(currentOffset) + + val allowedToInteract = when (selectionActionable) { + ScrollbarSelectionActionable.Always -> true + ScrollbarSelectionActionable.WhenVisible -> isInActionSelectable.value + } + + if (allowedToInteract) { + when (selectionMode) { + ScrollbarSelectionMode.Full -> { + if (newOffset in currentOffset..(currentOffset + normalizedThumbSizeUpdated)) + setDragOffset(currentOffset) + else + setScrollOffset(newOffset) isSelected = true } + + ScrollbarSelectionMode.Thumb -> { + if (newOffset in currentOffset..(currentOffset + normalizedThumbSizeUpdated)) { + setDragOffset(currentOffset) + isSelected = true + } + } + + ScrollbarSelectionMode.Disabled -> Unit } - ScrollbarSelectionMode.Disabled -> Unit } }, onDragStopped = { diff --git a/lib/src/main/java/my/nanihadesuka/compose/LazyColumnScrollbar.kt b/lib/src/main/java/my/nanihadesuka/compose/LazyColumnScrollbar.kt index acc7a59..4b7abe4 100644 --- a/lib/src/main/java/my/nanihadesuka/compose/LazyColumnScrollbar.kt +++ b/lib/src/main/java/my/nanihadesuka/compose/LazyColumnScrollbar.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -34,6 +35,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.floor @@ -56,6 +58,8 @@ fun LazyColumnScrollbar( thumbSelectedColor: Color = Color(0xFF5281CA), thumbShape: Shape = CircleShape, selectionMode: ScrollbarSelectionMode = ScrollbarSelectionMode.Thumb, + selectionActionable: ScrollbarSelectionActionable = ScrollbarSelectionActionable.Always, + hideDelayMillis: Int = 400, enabled: Boolean = true, indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, content: @Composable () -> Unit @@ -71,6 +75,8 @@ fun LazyColumnScrollbar( thumbMinHeight = thumbMinHeight, thumbColor = thumbColor, thumbSelectedColor = thumbSelectedColor, + selectionActionable = selectionActionable, + hideDelayMillis = hideDelayMillis, thumbShape = thumbShape, selectionMode = selectionMode, indicatorContent = indicatorContent, @@ -98,6 +104,8 @@ fun InternalLazyColumnScrollbar( thumbSelectedColor: Color = Color(0xFF5281CA), thumbShape: Shape = CircleShape, selectionMode: ScrollbarSelectionMode = ScrollbarSelectionMode.Thumb, + selectionActionable: ScrollbarSelectionActionable = ScrollbarSelectionActionable.Always, + hideDelayMillis: Int = 400, indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, ) { val firstVisibleItemIndex = remember { derivedStateOf { listState.firstVisibleItemIndex } } @@ -140,7 +148,8 @@ fun InternalLazyColumnScrollbar( return@let 0f val firstItem = realFirstVisibleItem ?: return@let 0f - val firstPartial = firstItem.fractionHiddenTop(listState.firstVisibleItemScrollOffset) + val firstPartial = + firstItem.fractionHiddenTop(listState.firstVisibleItemScrollOffset) val lastPartial = 1f - it.visibleItemsInfo.last().fractionVisibleBottom(it.viewportEndOffset) @@ -219,20 +228,33 @@ fun InternalLazyColumnScrollbar( val isInAction = listState.isScrollInProgress || isSelected + val isInActionSelectable = remember { mutableStateOf(isInAction) } + val durationAnimationMillis: Int = 500 + LaunchedEffect(isInAction) { + if (isInAction) { + isInActionSelectable.value = true + } else { + delay(timeMillis = durationAnimationMillis.toLong() + hideDelayMillis.toLong()) + isInActionSelectable.value = false + } + } + val alpha by animateFloatAsState( targetValue = if (isInAction) 1f else 0f, animationSpec = tween( - durationMillis = if (isInAction) 75 else 500, - delayMillis = if (isInAction) 0 else 500 - ) + durationMillis = if (isInAction) 75 else durationAnimationMillis, + delayMillis = if (isInAction) 0 else hideDelayMillis + ), + label = "scrollbar alpha value" ) val displacement by animateFloatAsState( targetValue = if (isInAction) 0f else 14f, animationSpec = tween( - durationMillis = if (isInAction) 75 else 500, - delayMillis = if (isInAction) 0 else 500 - ) + durationMillis = if (isInAction) 75 else durationAnimationMillis, + delayMillis = if (isInAction) 0 else hideDelayMillis + ), + label = "scrollbar displacement value" ) BoxWithConstraints( @@ -309,23 +331,31 @@ fun InternalLazyColumnScrollbar( 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) + val allowedToInteract = when (selectionActionable) { + ScrollbarSelectionActionable.Always -> true + ScrollbarSelectionActionable.WhenVisible -> isInActionSelectable.value + } + + if (allowedToInteract) { + when (selectionMode) { + ScrollbarSelectionMode.Full -> { + if (newOffset in currentOffset..(currentOffset + normalizedThumbSize)) + setDragOffset(currentOffset) + else + setScrollOffset(newOffset) isSelected = true } - } - ScrollbarSelectionMode.Disabled -> Unit + ScrollbarSelectionMode.Thumb -> { + if (newOffset in currentOffset..(currentOffset + normalizedThumbSize)) { + setDragOffset(currentOffset) + isSelected = true + } + } + + ScrollbarSelectionMode.Disabled -> Unit + } } }, onDragStopped = { diff --git a/lib/src/main/java/my/nanihadesuka/compose/ScrollbarSelectionActionable.kt b/lib/src/main/java/my/nanihadesuka/compose/ScrollbarSelectionActionable.kt new file mode 100644 index 0000000..ded3869 --- /dev/null +++ b/lib/src/main/java/my/nanihadesuka/compose/ScrollbarSelectionActionable.kt @@ -0,0 +1,16 @@ +package my.nanihadesuka.compose + +/** + * Scrollbar selection modes. + */ +enum class ScrollbarSelectionActionable { + /** + * Can select scrollbar always (when visible or hidden) + */ + Always, + + /** + * Can select scrollbar only when visible + */ + WhenVisible, +} \ No newline at end of file diff --git a/lib/src/test/java/my/nanihadesuka/compose/ColumnScrollbarTest.kt b/lib/src/test/java/my/nanihadesuka/compose/ColumnScrollbarTest.kt index 2bd1a80..b3196d8 100644 --- a/lib/src/test/java/my/nanihadesuka/compose/ColumnScrollbarTest.kt +++ b/lib/src/test/java/my/nanihadesuka/compose/ColumnScrollbarTest.kt @@ -277,6 +277,45 @@ class ColumnScrollbarTest(private val itemCount: Int) { } } + @Test + fun `scrollbar selection actionable - Always`() { + if (itemCount == 0) return + + setContent( + selectionMode = ScrollbarSelectionMode.Full, + selectionActionable = ScrollbarSelectionActionable.Always, + thumbMinHeight = 0.1f, + ) + scrollbarScreen(composeRule) { + assert { isAtTop() } + moveScrollbarToBottom(startFrom = 0.05f) + assert { isAtBottom() } + moveScrollbarToTop(startFrom = 0.5f) + assert { isAtTop() } + moveScrollbarToBottom(startFrom = 0.95f) + assert { isAtBottom() } + } + } + + fun `scrollbar selection actionable - WhenVisible`() { + if (itemCount == 0) return + + setContent( + selectionMode = ScrollbarSelectionMode.Full, + selectionActionable = ScrollbarSelectionActionable.WhenVisible, + thumbMinHeight = 0.1f, + ) + scrollbarScreen(composeRule) { + assert { isAtTop() } + moveScrollbarToBottom(startFrom = 0.05f) + assert { isAtTop() } + moveScrollbarToTop(startFrom = 0.5f) + assert { isAtTop() } + moveScrollbarToBottom(startFrom = 0.95f) + assert { isAtBottom() } + } + } + @Composable private fun IndicatorContent(value: Float) { Surface { @@ -298,6 +337,7 @@ class ColumnScrollbarTest(private val itemCount: Int) { thumbShape: Shape = CircleShape, enabled: Boolean = true, selectionMode: ScrollbarSelectionMode = ScrollbarSelectionMode.Thumb, + selectionActionable: ScrollbarSelectionActionable = ScrollbarSelectionActionable.Always, indicatorContent: (@Composable (normalizedOffset: Float, isThumbSelected: Boolean) -> Unit)? = null, listItemsCount: Int = itemCount ) { @@ -314,7 +354,8 @@ class ColumnScrollbarTest(private val itemCount: Int) { thumbShape = thumbShape, indicatorContent = indicatorContent, selectionMode = selectionMode, - ) { + selectionActionable = selectionActionable, + ) { Column(Modifier.verticalScroll(state = state)) { repeat(listItemsCount) { Text( diff --git a/lib/src/test/java/my/nanihadesuka/compose/LazyColumnScrollbarTest.kt b/lib/src/test/java/my/nanihadesuka/compose/LazyColumnScrollbarTest.kt index 0d6f934..f9a7a38 100644 --- a/lib/src/test/java/my/nanihadesuka/compose/LazyColumnScrollbarTest.kt +++ b/lib/src/test/java/my/nanihadesuka/compose/LazyColumnScrollbarTest.kt @@ -337,6 +337,45 @@ class LazyColumnScrollbarTest(private val itemCount: Int) { } } + @Test + fun `scrollbar selection actionable - Always`() { + if (itemCount == 0) return + + setContent( + selectionMode = ScrollbarSelectionMode.Full, + selectionActionable = ScrollbarSelectionActionable.Always, + thumbMinHeight = 0.1f, + ) + scrollbarScreen(composeRule) { + assert { isAtTop() } + moveScrollbarToBottom(startFrom = 0.05f) + assert { isAtBottom() } + moveScrollbarToTop(startFrom = 0.5f) + assert { isAtTop() } + moveScrollbarToBottom(startFrom = 0.95f) + assert { isAtBottom() } + } + } + + fun `scrollbar selection actionable - WhenVisible`() { + if (itemCount == 0) return + + setContent( + selectionMode = ScrollbarSelectionMode.Full, + selectionActionable = ScrollbarSelectionActionable.WhenVisible, + thumbMinHeight = 0.1f, + ) + scrollbarScreen(composeRule) { + assert { isAtTop() } + moveScrollbarToBottom(startFrom = 0.05f) + assert { isAtTop() } + moveScrollbarToTop(startFrom = 0.5f) + assert { isAtTop() } + moveScrollbarToBottom(startFrom = 0.95f) + assert { isAtBottom() } + } + } + @Composable private fun IndicatorContent(value: Int) { Surface { @@ -358,6 +397,7 @@ class LazyColumnScrollbarTest(private val itemCount: Int) { thumbShape: Shape = CircleShape, enabled: Boolean = true, selectionMode: ScrollbarSelectionMode = ScrollbarSelectionMode.Thumb, + selectionActionable: ScrollbarSelectionActionable = ScrollbarSelectionActionable.Always, indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, listItemsCount: Int = itemCount, reverseLayout: Boolean = false @@ -375,6 +415,7 @@ class LazyColumnScrollbarTest(private val itemCount: Int) { thumbShape = thumbShape, indicatorContent = indicatorContent, selectionMode = selectionMode, + selectionActionable = selectionActionable, ) { LazyColumn( state = state,