Add LazyGridScrollbar for preview jump

This commit is contained in:
tarsin committed 2023-07-09 11:54:13 +08:00
1 parent 95fcab0980
commit 8ae75101ef
2 files changed
+351 -54

No files matched your search

@@ -5,20 +5,16 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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
import androidx.compose.material.icons.filled.CallMissedOutgoing
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -29,11 +25,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.Fragment
@@ -65,11 +61,10 @@ import com.hippo.ehviewer.ui.setMD3Content
import com.hippo.ehviewer.ui.tools.rememberDialogState
import com.hippo.ehviewer.ui.tools.rememberMemorized
import com.hippo.ehviewer.util.getParcelableCompat
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import kotlin.math.roundToInt
import my.nanihadesuka.compose.LazyGridScrollbar
class GalleryPreviewScreen : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -104,24 +99,6 @@ class GalleryPreviewScreen : Fragment() {
result.first.first
}
suspend fun showGoToDialog() {
val toPage = dialogState.show(initial = 1F, title = R.string.go_to) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 18.dp)) {
Text(text = "1", modifier = Modifier.padding(12.dp))
Slider(
value = expectedValue,
onValueChange = { expectedValue = it },
modifier = Modifier.height(48.dp).width(0.dp).weight(1F).align(Alignment.CenterVertically),
valueRange = 1f..galleryDetail.previewPages.toFloat(),
steps = galleryDetail.previewPages - 2,
)
Text(text = pages.toString(), modifier = Modifier.padding(12.dp))
}
}.roundToInt()
val index = (toPage - 1) * pgSize
withUIContext { state.scrollToItem(index) }
}
val previewPagesMap = rememberMemorized { galleryDetail.previewList.associateBy { it.position } as MutableMap }
val data = remember {
Pager(PagingConfig(pageSize = pgSize, initialLoadSize = pgSize)) {
@@ -157,42 +134,40 @@ class GalleryPreviewScreen : Fragment() {
Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null)
}
},
actions = {
if (galleryDetail.previewPages > 1) {
IconButton(onClick = { coroutineScope.launch { showGoToDialog() } }) {
Icon(imageVector = Icons.Default.CallMissedOutgoing, contentDescription = null)
}
}
},
scrollBehavior = scrollBehaviour,
)
},
) { paddingValues ->
LazyVerticalGrid(
columns = GridCells.Fixed(columnCount),
modifier = Modifier.nestedScroll(scrollBehaviour.nestedScrollConnection),
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]
if (item != null) {
EhPreviewItem(item) {
onPreviewCLick(item.position)
}
} else {
EhPreviewItemPlaceholder(index) {
onPreviewCLick(index)
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]
if (item != null) {
EhPreviewItem(item) {
onPreviewCLick(item.position)
}
} else {
EhPreviewItemPlaceholder(index) {
onPreviewCLick(index)
}
}
}
}
}
Box(modifier = Modifier.padding(paddingValues = paddingValues)) {
LazyGridScrollbar(listState = state)
}
}
}
}
@@ -0,0 +1,322 @@
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),
)
}
}
}