Add LazyGridScrollbar for preview jump
This commit is contained in:
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user