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.ExperimentalWasmDsl
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
val appVersionName = "1.0"
|
val appVersionName = "1.1"
|
||||||
val appVersionCode = 8
|
val appVersionCode = 9
|
||||||
val appVersionDisplay = "$appVersionName.$appVersionCode"
|
val appVersionDisplay = "$appVersionName.$appVersionCode"
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
|
|||||||
@ -105,6 +105,7 @@ import net.sergeych.toread.text.SoftHyphen
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@ -134,6 +135,7 @@ internal fun ContinuousBookReader(
|
|||||||
val hyphenation = remember { HyphenationRegistry() }
|
val hyphenation = remember { HyphenationRegistry() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val textLineMetricsByItem = remember(contentPlan, readerFontSettings) { mutableStateMapOf<Int, TextLineMetrics>() }
|
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 contentPadding = PaddingValues(top=6.dp, bottom = 6.dp, start = 4.dp, end = 6.dp)
|
||||||
val userScrollConnection = remember(onUserScroll) {
|
val userScrollConnection = remember(onUserScroll) {
|
||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
@ -152,17 +154,31 @@ internal fun ContinuousBookReader(
|
|||||||
.background(readerBackgroundColor)
|
.background(readerBackgroundColor)
|
||||||
.nestedScroll(userScrollConnection)
|
.nestedScroll(userScrollConnection)
|
||||||
.readerFontZoomGesture(onReaderFontZoom)
|
.readerFontZoomGesture(onReaderFontZoom)
|
||||||
.pageTurnOnTouchTap(
|
.pageTurnOnTouchSwipe(
|
||||||
onPageDown = {
|
onPageDown = {
|
||||||
onUserScroll()
|
onUserScroll()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
listState.pageScrollByPage(1, textLineMetricsByItem)
|
listState.pageScrollByPage(1, textLineMetricsByItem, pageHistory)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPageUp = {
|
onPageUp = {
|
||||||
onUserScroll()
|
onUserScroll()
|
||||||
scope.launch {
|
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)
|
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(
|
private fun Modifier.pageTurnOnTouchTap(
|
||||||
onPageDown: () -> Unit,
|
onPageDown: () -> Unit,
|
||||||
onPageUp: () -> Unit,
|
onPageUp: () -> Unit,
|
||||||
@ -365,8 +459,16 @@ private fun Modifier.pageTurnOnTouchTap(
|
|||||||
private suspend fun LazyListState.pageScrollByPage(
|
private suspend fun LazyListState.pageScrollByPage(
|
||||||
direction: Int,
|
direction: Int,
|
||||||
textLineMetricsByItem: Map<Int, TextLineMetrics>,
|
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) {
|
if (target != null) {
|
||||||
animateScrollToItem(target.itemIndex, target.scrollOffset)
|
animateScrollToItem(target.itemIndex, target.scrollOffset)
|
||||||
} else {
|
} else {
|
||||||
@ -396,11 +498,67 @@ private fun LazyListState.pageScrollTarget(
|
|||||||
} ?: return null
|
} ?: return null
|
||||||
val metrics = textLineMetricsByItem[targetItem.index] ?: return null
|
val metrics = textLineMetricsByItem[targetItem.index] ?: return null
|
||||||
val targetItemOffset = targetViewportOffset - targetItem.offset
|
val targetItemOffset = targetViewportOffset - targetItem.offset
|
||||||
val lineTop = metrics.lineTopContaining(targetItemOffset) ?: return null
|
val lineTop = metrics.lineTopForViewportStart(targetItemOffset) ?: return null
|
||||||
|
|
||||||
return PageScrollTarget(targetItem.index, lineTop)
|
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(
|
private data class PageScrollTarget(
|
||||||
val itemIndex: Int,
|
val itemIndex: Int,
|
||||||
val scrollOffset: Int,
|
val scrollOffset: Int,
|
||||||
@ -410,12 +568,18 @@ private data class TextLineMetrics(
|
|||||||
val lineTops: List<Int>,
|
val lineTops: List<Int>,
|
||||||
val lineBottoms: List<Int>,
|
val lineBottoms: List<Int>,
|
||||||
) {
|
) {
|
||||||
fun lineTopContaining(offsetPx: Int): Int? {
|
fun lineTopForViewportStart(offsetPx: Int): Int? =
|
||||||
val lineIndex = lineTops.indices.firstOrNull { index ->
|
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]
|
offsetPx >= lineTops[index] && offsetPx < lineBottoms[index]
|
||||||
} ?: return null
|
}?.let { lineTops[it] }
|
||||||
return lineTops[lineIndex]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TextLayoutResult.toTextLineMetrics(): TextLineMetrics =
|
private fun TextLayoutResult.toTextLineMetrics(): TextLineMetrics =
|
||||||
@ -1039,6 +1203,8 @@ private val isDesktopPlatform: Boolean by lazy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val ReaderZoomGestureStep = 1.12f
|
private const val ReaderZoomGestureStep = 1.12f
|
||||||
|
private const val ReaderSwipeTouchSlopMultiplier = 1.5f
|
||||||
|
private const val ReaderHorizontalSwipeDominance = 2f
|
||||||
|
|
||||||
private val MinimumBookImageMaxDimension = 800.dp
|
private val MinimumBookImageMaxDimension = 800.dp
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user