From 9cd8ebc422a62c01c51e9fd79d75dc2b5062b2c0 Mon Sep 17 00:00:00 2001 From: sergeych Date: Fri, 10 Jul 2026 20:57:04 +0300 Subject: [PATCH] Page swipe navigation and history tracking improvements, version bump to 1.1. --- composeApp/build.gradle.kts | 4 +- .../net/sergeych/toread/ReaderContent.kt | 186 +++++++++++++++++- 2 files changed, 178 insertions(+), 12 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 033fe8d..49fafcd 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -2,8 +2,8 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget -val appVersionName = "1.0" -val appVersionCode = 8 +val appVersionName = "1.1" +val appVersionCode = 9 val appVersionDisplay = "$appVersionName.$appVersionCode" plugins { diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index edc4a7c..33151fc 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -105,6 +105,7 @@ import net.sergeych.toread.text.SoftHyphen import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @@ -134,6 +135,7 @@ internal fun ContinuousBookReader( val hyphenation = remember { HyphenationRegistry() } val scope = rememberCoroutineScope() val textLineMetricsByItem = remember(contentPlan, readerFontSettings) { mutableStateMapOf() } + val pageHistory = remember(contentPlan, readerFontSettings) { ReaderPageHistory() } val contentPadding = PaddingValues(top=6.dp, bottom = 6.dp, start = 4.dp, end = 6.dp) val userScrollConnection = remember(onUserScroll) { object : NestedScrollConnection { @@ -152,17 +154,31 @@ internal fun ContinuousBookReader( .background(readerBackgroundColor) .nestedScroll(userScrollConnection) .readerFontZoomGesture(onReaderFontZoom) - .pageTurnOnTouchTap( + .pageTurnOnTouchSwipe( onPageDown = { onUserScroll() scope.launch { - listState.pageScrollByPage(1, textLineMetricsByItem) + listState.pageScrollByPage(1, textLineMetricsByItem, pageHistory) } }, onPageUp = { onUserScroll() scope.launch { - listState.pageScrollByPage(-1, textLineMetricsByItem) + listState.pageScrollByPage(-1, textLineMetricsByItem, pageHistory) + } + }, + ) + .pageTurnOnTouchTap( + onPageDown = { + onUserScroll() + scope.launch { + listState.pageScrollByPage(1, textLineMetricsByItem, pageHistory) + } + }, + onPageUp = { + onUserScroll() + scope.launch { + listState.pageScrollByPage(-1, textLineMetricsByItem, pageHistory) } }, ), @@ -317,6 +333,84 @@ private fun List.firstTwoD return sqrt(dx * dx + dy * dy) } +private fun Modifier.pageTurnOnTouchSwipe( + onPageDown: () -> Unit, + onPageUp: () -> Unit, +): Modifier = pointerInput(onPageDown, onPageUp) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + if (down.isConsumed) return@awaitEachGesture + if (down.type != PointerType.Touch) { + waitForUpOrCancellation(pass = PointerEventPass.Final) + return@awaitEachGesture + } + + val touchSlop = viewConfiguration.touchSlop + val swipeSlop = touchSlop * ReaderSwipeTouchSlopMultiplier + val touchSlopSquared = touchSlop * touchSlop + var cancelled = false + var movedBeyondTapSlop = false + var pageScrollDirection = 0 + + while (!cancelled && pageScrollDirection == 0) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + if (event.changes.count { it.pressed && it.type == PointerType.Touch } > 1) { + cancelled = true + continue + } + + val change = event.changes.firstOrNull { it.id == down.id } + if (change == null) { + cancelled = true + } else if (change.isConsumed) { + cancelled = true + } else if (change.pressed) { + val dx = change.position.x - down.position.x + val dy = change.position.y - down.position.y + val absX = abs(dx) + val absY = abs(dy) + val isHorizontalSwipeCandidate = absX >= absY * ReaderHorizontalSwipeDominance + if (dx * dx + dy * dy > touchSlopSquared) { + movedBeyondTapSlop = true + } + if (absX >= swipeSlop && isHorizontalSwipeCandidate) { + pageScrollDirection = if (dx < 0f) 1 else -1 + change.consume() + } else if (movedBeyondTapSlop && !isHorizontalSwipeCandidate) { + cancelled = true + } else if (movedBeyondTapSlop && change.position != change.previousPosition) { + change.consume() + } + } else { + cancelled = true + } + } + + if (cancelled) return@awaitEachGesture + + if (pageScrollDirection != 0) { + var released = false + while (!released) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + event.changes.forEach { change -> + if (change.type == PointerType.Touch) { + change.consume() + } + } + released = event.changes + .firstOrNull { it.id == down.id } + ?.pressed != true + } + if (pageScrollDirection > 0) { + onPageDown() + } else { + onPageUp() + } + return@awaitEachGesture + } + } +} + private fun Modifier.pageTurnOnTouchTap( onPageDown: () -> Unit, onPageUp: () -> Unit, @@ -365,8 +459,16 @@ private fun Modifier.pageTurnOnTouchTap( private suspend fun LazyListState.pageScrollByPage( direction: Int, textLineMetricsByItem: Map, + pageHistory: ReaderPageHistory, ) { - val target = pageScrollTarget(direction, textLineMetricsByItem) + val current = currentPageAnchor(textLineMetricsByItem) + val target = if (current != null) { + pageHistory.targetFor(current, direction) { + pageScrollTarget(direction, textLineMetricsByItem) + } + } else { + pageScrollTarget(direction, textLineMetricsByItem) + } if (target != null) { animateScrollToItem(target.itemIndex, target.scrollOffset) } else { @@ -396,11 +498,67 @@ private fun LazyListState.pageScrollTarget( } ?: return null val metrics = textLineMetricsByItem[targetItem.index] ?: return null val targetItemOffset = targetViewportOffset - targetItem.offset - val lineTop = metrics.lineTopContaining(targetItemOffset) ?: return null + val lineTop = metrics.lineTopForViewportStart(targetItemOffset) ?: return null return PageScrollTarget(targetItem.index, lineTop) } +private fun LazyListState.currentPageAnchor( + textLineMetricsByItem: Map, +): PageScrollTarget? { + val itemIndex = firstVisibleItemIndex + val scrollOffset = firstVisibleItemScrollOffset + val normalizedScrollOffset = textLineMetricsByItem[itemIndex] + ?.lineTopForScrollOffset(scrollOffset) + ?: scrollOffset + return PageScrollTarget(itemIndex, normalizedScrollOffset) +} + +private class ReaderPageHistory { + private val anchors = mutableListOf() + private var currentIndex = -1 + + fun targetFor( + current: PageScrollTarget, + direction: Int, + calculateTarget: () -> PageScrollTarget?, + ): PageScrollTarget? { + moveToCurrent(current) + val adjacentIndex = currentIndex + direction + if (adjacentIndex in anchors.indices) { + currentIndex = adjacentIndex + return anchors[currentIndex] + } + + val target = calculateTarget() ?: return null + if (target == current) return target + + if (direction > 0) { + if (currentIndex < anchors.lastIndex) { + anchors.subList(currentIndex + 1, anchors.size).clear() + } + anchors += target + currentIndex = anchors.lastIndex + } else { + anchors.add(0, target) + currentIndex = 0 + } + return target + } + + private fun moveToCurrent(current: PageScrollTarget) { + if (currentIndex in anchors.indices && anchors[currentIndex] == current) return + val existingIndex = anchors.indexOf(current) + if (existingIndex >= 0) { + currentIndex = existingIndex + } else { + anchors.clear() + anchors += current + currentIndex = 0 + } + } +} + private data class PageScrollTarget( val itemIndex: Int, val scrollOffset: Int, @@ -410,12 +568,18 @@ private data class TextLineMetrics( val lineTops: List, val lineBottoms: List, ) { - fun lineTopContaining(offsetPx: Int): Int? { - val lineIndex = lineTops.indices.firstOrNull { index -> + fun lineTopForViewportStart(offsetPx: Int): Int? = + lineTopContaining(offsetPx) + ?: lineTops.firstOrNull { it >= offsetPx } + ?: lineTops.lastOrNull() + + fun lineTopForScrollOffset(offsetPx: Int): Int? = + lineTops.firstOrNull { it == offsetPx } ?: lineTopForViewportStart(offsetPx) + + private fun lineTopContaining(offsetPx: Int): Int? = + lineTops.indices.firstOrNull { index -> offsetPx >= lineTops[index] && offsetPx < lineBottoms[index] - } ?: return null - return lineTops[lineIndex] - } + }?.let { lineTops[it] } } private fun TextLayoutResult.toTextLineMetrics(): TextLineMetrics = @@ -1039,6 +1203,8 @@ private val isDesktopPlatform: Boolean by lazy { } private const val ReaderZoomGestureStep = 1.12f +private const val ReaderSwipeTouchSlopMultiplier = 1.5f +private const val ReaderHorizontalSwipeDominance = 2f private val MinimumBookImageMaxDimension = 800.dp