Inline LazyColumnScrollbar

This commit is contained in:
tarsin committed 2023-07-09 11:36:37 +08:00
1 parent d2b141e218
commit 95fcab0980
4 files changed
+344 -7

No files matched your search

@@ -72,7 +72,7 @@ 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.InternalLazyColumnScrollbar
import my.nanihadesuka.compose.LazyColumnScrollbar
class HistoryScene : BaseScene() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -171,11 +171,7 @@ class HistoryScene : BaseScene() {
}
}
Box(modifier = Modifier.padding(paddingValues = paddingValues)) {
InternalLazyColumnScrollbar(
listState = state,
thumbColor = MaterialTheme.colorScheme.primary,
thumbSelectedColor = MaterialTheme.colorScheme.primary,
)
LazyColumnScrollbar(listState = state)
}
}
Deferred({ delay(200) }) {
@@ -0,0 +1,321 @@
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),
)
}
}
}
@@ -0,0 +1,21 @@
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,
}