From df90b4c36f8d72e3401016a0622c78ae4a9ab4ef Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 18 May 2026 15:45:26 +0300 Subject: [PATCH] better pagination --- .../net/sergeych/toread/ReaderContent.kt | 97 +++++++++++++++++-- 1 file changed, 91 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index e75e8c8..5d884ea 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -77,6 +78,7 @@ import net.sergeych.toread.text.SoftHyphen import kotlinx.coroutines.launch import kotlin.math.max import kotlin.math.min +import kotlin.math.roundToInt @Composable internal fun ContinuousBookReader( @@ -90,6 +92,7 @@ internal fun ContinuousBookReader( ) { val hyphenation = remember { HyphenationRegistry() } val scope = rememberCoroutineScope() + val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf() } val contentPadding = if (isAndroidPlatform()) { PaddingValues(start = 0.dp, top = 6.dp, end = 0.dp, bottom = 6.dp) } else { @@ -103,12 +106,12 @@ internal fun ContinuousBookReader( .pageTurnOnTouchTap( onPageDown = { scope.launch { - listState.animateScrollBy(listState.pageScrollDistance()) + listState.pageScrollByPage(1, textLineMetricsByItem) } }, onPageUp = { scope.launch { - listState.animateScrollBy(-listState.pageScrollDistance()) + listState.pageScrollByPage(-1, textLineMetricsByItem) } }, ), @@ -175,6 +178,7 @@ internal fun ContinuousBookReader( textAlign = TextAlign.Justify, // so we add 6.dp to make it look symmetric modifier = Modifier.padding(start = (element.depth * 8).dp + 6.dp, end = 0.dp), + onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() }, ) is ReaderElement.Subtitle -> ReaderText( text = element.text, @@ -184,6 +188,7 @@ internal fun ContinuousBookReader( highlightedRange = highlightedRange, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp), + onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() }, ) } } @@ -195,14 +200,34 @@ private fun Modifier.pageTurnOnTouchTap( onPageUp: () -> Unit, ): Modifier = pointerInput(onPageDown, onPageUp) { awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) if (down.type != PointerType.Touch) { waitForUpOrCancellation(pass = PointerEventPass.Final) return@awaitEachGesture } - val up = waitForUpOrCancellation(pass = PointerEventPass.Final) ?: return@awaitEachGesture - if (up.isConsumed) return@awaitEachGesture + val touchSlop = viewConfiguration.touchSlop + val touchSlopSquared = touchSlop * touchSlop + var upPosition: Offset? = null + var cancelled = false + + while (upPosition == null && !cancelled) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } + if (change == null) { + cancelled = true + } else if (change.pressed) { + val dx = change.position.x - down.position.x + val dy = change.position.y - down.position.y + if (dx * dx + dy * dy > touchSlopSquared) { + cancelled = true + } + } else { + upPosition = change.position + } + } + + if (cancelled) return@awaitEachGesture if (down.position.x < size.width / 2f) { onPageDown() @@ -212,12 +237,68 @@ private fun Modifier.pageTurnOnTouchTap( } } +private suspend fun LazyListState.pageScrollByPage( + direction: Int, + textLineMetricsByItem: Map, +) { + val target = pageScrollTarget(direction, textLineMetricsByItem) + if (target != null) { + animateScrollToItem(target.itemIndex, target.scrollOffset) + } else { + animateScrollBy(pageScrollDistance() * direction) + } +} + private fun LazyListState.pageScrollDistance(): Float { val layoutInfo = layoutInfo val viewportHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset return viewportHeight.toFloat().coerceAtLeast(0f) } +private fun LazyListState.pageScrollTarget( + direction: Int, + textLineMetricsByItem: Map, +): PageScrollTarget? { + val layoutInfo = layoutInfo + val viewportHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset + val targetViewportOffset = if (direction > 0) { + layoutInfo.viewportEndOffset + } else { + layoutInfo.viewportStartOffset - viewportHeight + } + val targetItem = layoutInfo.visibleItemsInfo.firstOrNull { item -> + targetViewportOffset >= item.offset && targetViewportOffset < item.offset + item.size + } ?: return null + val metrics = textLineMetricsByItem[targetItem.index] ?: return null + val targetItemOffset = targetViewportOffset - targetItem.offset + val lineTop = metrics.lineTopContaining(targetItemOffset) ?: return null + + return PageScrollTarget(targetItem.index, lineTop) +} + +private data class PageScrollTarget( + val itemIndex: Int, + val scrollOffset: Int, +) + +private data class TextLineMetrics( + val lineTops: List, + val lineBottoms: List, +) { + fun lineTopContaining(offsetPx: Int): Int? { + val lineIndex = lineTops.indices.firstOrNull { index -> + offsetPx >= lineTops[index] && offsetPx < lineBottoms[index] + } ?: return null + return lineTops[lineIndex] + } +} + +private fun TextLayoutResult.toTextLineMetrics(): TextLineMetrics = + TextLineMetrics( + lineTops = List(lineCount) { line -> getLineTop(line).roundToInt() }, + lineBottoms = List(lineCount) { line -> getLineBottom(line).roundToInt() }, + ) + @Composable private fun DetailsPane( book: Fb2Book, @@ -388,6 +469,7 @@ private fun ReaderText( textAlign: TextAlign, modifier: Modifier = Modifier, highlightedRange: ReaderSentenceRange? = null, + onTextLayout: (TextLayoutResult) -> Unit = {}, ) { val highlightColor = MaterialTheme.colorScheme.secondaryContainer val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor) @@ -433,7 +515,10 @@ private fun ReaderText( style = style, textAlign = textAlign, modifier = textModifier, - onTextLayout = { textLayout = it }, + onTextLayout = { + textLayout = it + onTextLayout(it) + }, ) }