Page swipe navigation and history tracking improvements, version bump to 1.1.
This commit is contained in:
parent
85d97d5a90
commit
9cd8ebc422
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user