diff --git a/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryPreviewScreen.kt b/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryPreviewScreen.kt index ca8b26ca3..b390536df 100644 --- a/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryPreviewScreen.kt +++ b/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryPreviewScreen.kt @@ -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) + } } } } diff --git a/app/src/main/java/my/nanihadesuka/compose/LazyGridScrollbar.kt b/app/src/main/java/my/nanihadesuka/compose/LazyGridScrollbar.kt new file mode 100644 index 000000000..5044f7bb0 --- /dev/null +++ b/app/src/main/java/my/nanihadesuka/compose/LazyGridScrollbar.kt @@ -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), + ) + } + } +}