better pagination

This commit is contained in:
Sergey Chernov 2026-05-18 15:45:26 +03:00
parent 44cc0bbaf3
commit df90b4c36f

View File

@ -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<Int, TextLineMetrics>() }
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<Int, TextLineMetrics>,
) {
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<Int, TextLineMetrics>,
): 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<Int>,
val lineBottoms: List<Int>,
) {
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)
},
)
}