Page swipe navigation and history tracking improvements, version bump to 1.1.

This commit is contained in:
Sergey Chernov 2026-07-10 20:57:04 +03:00
parent 85d97d5a90
commit 9cd8ebc422
2 changed files with 178 additions and 12 deletions

View File

@ -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 {

View File

@ -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<Int, TextLineMetrics>() }
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<androidx.compose.ui.input.pointer.PointerInputChange>.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<Int, TextLineMetrics>,
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<Int, TextLineMetrics>,
): 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<PageScrollTarget>()
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<Int>,
val lineBottoms: List<Int>,
) {
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