Improved PDF vertical reader zoom and orientation handling (#98)

- Implemented `fitZoom` calculation to properly center and scale pages, especially in landscape orientation.
- Updated `clampValues` and animation bounds to ensure content is correctly centered when zoomed out.
- Refined double-tap-to-zoom logic to toggle between `fitZoom`, 1x, and 2.5x scales.
- Replaced manual frame-based zooming with `Animatable.animateTo` for smoother transitions.
- Added `configChanges` to `MainActivity` in `AndroidManifest.xml` to handle orientation and screen size changes manually.
- Reduced delays and improved reliability of initial page snapping in `PdfViewerScreen`.
This commit is contained in:
Aryan 2026-03-20 11:11:48 +05:30 committed by GitHub
parent c9dc3ce8c2
commit a9c791120d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 136 additions and 107 deletions

View file

@ -47,6 +47,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.Reader" android:theme="@style/Theme.Reader"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:launchMode="singleTask"> android:launchMode="singleTask">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View file

@ -260,13 +260,10 @@ internal fun PdfVerticalReader(
val dividerHeightPx = with(density) { dividerHeightDp.toPx() } val dividerHeightPx = with(density) { dividerHeightDp.toPx() }
val layoutState = remember(ratios, screenWidth, screenHeight, density) { val layoutState = remember(ratios, screenWidth, screenHeight, density) {
// Return a wrapper object containing both list and total height to ensure
// atomicity
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float) data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
var currentY = 0.0 var currentY = 0.0
// Special case for single page centering
if (ratios.size == 1) { if (ratios.size == 1) {
val ratio = ratios[0] val ratio = ratios[0]
val safeRatio = if (ratio <= 0f) 1f else ratio val safeRatio = if (ratio <= 0f) 1f else ratio
@ -278,7 +275,6 @@ internal fun PdfVerticalReader(
val pages = ratios.mapIndexed { index, ratio -> val pages = ratios.mapIndexed { index, ratio ->
val safeRatio = if (ratio <= 0f) 1f else ratio val safeRatio = if (ratio <= 0f) 1f else ratio
// Use double for calculation
val pageHeightDouble = screenWidth.toDouble() / safeRatio.toDouble() val pageHeightDouble = screenWidth.toDouble() / safeRatio.toDouble()
val pageHeight = pageHeightDouble.toFloat() val pageHeight = pageHeightDouble.toFloat()
@ -297,9 +293,7 @@ internal fun PdfVerticalReader(
info info
} }
// Calculate exact total height based on the double accumulator
val totalH = if (pages.isNotEmpty()) { val totalH = if (pages.isNotEmpty()) {
// Determine the bottom of the last element strictly
val last = pages.last() val last = pages.last()
last.y + last.height last.y + last.height
} else { } else {
@ -314,58 +308,85 @@ internal fun PdfVerticalReader(
Timber.tag(SCROLL_BOUNDS_TAG) Timber.tag(SCROLL_BOUNDS_TAG)
.d("Layout Recalculated. Page Count: ${layoutInfo.size}, TotalDocHeight: $totalDocHeight") .d("Layout Recalculated. Page Count: ${layoutInfo.size}, TotalDocHeight: $totalDocHeight")
val zoomAnimatable = remember { Animatable(1f) } val fitZoom = remember(ratios, screenWidth, screenHeight) {
val panXAnimatable = remember { Animatable(0f) } if (ratios.isEmpty() || screenWidth == 0f || screenHeight == 0f) 1f
else {
val firstRatio = ratios.firstOrNull { it > 0f } ?: 1f
val baseHeight = screenWidth / firstRatio
if (screenWidth > screenHeight) {
((screenHeight - 32f) / baseHeight).coerceAtMost(1f)
} else {
1f
}
}
}
val zoomAnimatable = remember { Animatable(fitZoom) }
val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) }
val panYAnimatable = remember { Animatable(0f) } val panYAnimatable = remember { Animatable(0f) }
var previousLayoutPages by remember { mutableStateOf<List<PdfPageLayout>?>(null) } var previousScreenWidth by remember { mutableFloatStateOf(0f) }
LaunchedEffect(screenWidth, screenHeight) {
if (previousScreenWidth > 0f && previousScreenWidth != screenWidth) {
if (zoomAnimatable.value <= 1.1f) {
val centeredX = if ((screenWidth * fitZoom) < screenWidth) {
(screenWidth - (screenWidth * fitZoom)) / 2f
} else 0f
zoomAnimatable.snapTo(fitZoom)
panXAnimatable.snapTo(centeredX)
onZoomChange(fitZoom)
}
}
previousScreenWidth = screenWidth
}
var isInitialLayout by remember { mutableStateOf(true) }
val currentScaleProvider = remember(zoomAnimatable) { { zoomAnimatable.value } } val currentScaleProvider = remember(zoomAnimatable) { { zoomAnimatable.value } }
SideEffect { LaunchedEffect(layoutState.pages) {
val prevPages = previousLayoutPages if (!isInitialLayout) {
val currentPages = layoutState.pages val targetPageIdx = state.currentPage
val newLayout = layoutState.pages
val pageLayout = newLayout.getOrNull(targetPageIdx)
if (prevPages != null && prevPages.size == currentPages.size) { if (pageLayout != null) {
val anchorIndex = state.firstVisiblePage.coerceIn(currentPages.indices)
val oldY = prevPages[anchorIndex].y
val newY = currentPages[anchorIndex].y
val delta = newY - oldY
if (abs(delta) > 0.1f) {
val currentZoom = zoomAnimatable.value val currentZoom = zoomAnimatable.value
val shiftInScreenPixels = delta * currentZoom val targetPanY = headerHeightPx - (pageLayout.y * currentZoom)
val targetPanY = panYAnimatable.value - shiftInScreenPixels val zoomedDocHeight = layoutState.totalHeight * currentZoom
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
val finalPanY = targetPanY.coerceIn(minPanY, headerHeightPx)
scope.launch { panYAnimatable.snapTo(targetPanY) } Timber.tag("PdfZoomDiagnostics").i("Layout changed (Orientation/Size). Snapping to Page $targetPageIdx at PanY: $finalPanY")
panYAnimatable.snapTo(finalPanY)
} }
} }
previousLayoutPages = currentPages isInitialLayout = false
} }
fun clampValues( fun clampValues(
targetZoom: Float, targetPanX: Float, targetPanY: Float targetZoom: Float, targetPanX: Float, targetPanY: Float
): Triple<Float, Float, Float> { ): Triple<Float, Float, Float> {
val constrainedZoom = targetZoom.coerceIn(1f, 5f) val constrainedZoom = targetZoom.coerceIn(fitZoom, 5f)
val zoomedDocWidth = screenWidth * constrainedZoom val zoomedDocWidth = screenWidth * constrainedZoom
val zoomedDocHeight = totalDocHeight * constrainedZoom val zoomedDocHeight = totalDocHeight * constrainedZoom
val constrainedX = if (zoomedDocWidth < screenWidth) {
(screenWidth - zoomedDocWidth) / 2f
} else {
val maxPanX = 0f val maxPanX = 0f
val minPanX = -(zoomedDocWidth - screenWidth).coerceAtLeast(0f) val minPanX = -(zoomedDocWidth - screenWidth)
targetPanX.coerceIn(minPanX, maxPanX)
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
headerHeightPx
)
val constrainedX = targetPanX.coerceIn(minPanX, maxPanX)
val constrainedY = targetPanY.coerceIn(minPanY, headerHeightPx)
if (constrainedZoom > 1.01f) {
Timber.tag("PdfZoomIssue").v(
"Clamp: Zoom=$constrainedZoom, targetY=$targetPanY, finalY=$constrainedY, " +
"boundsY=[$minPanY, $headerHeightPx], zoomedHeight=$zoomedDocHeight"
)
} }
val minPanY = if (zoomedDocHeight < (screenHeight - headerHeightPx - footerHeightPx)) {
headerHeightPx
} else {
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
}
val constrainedY = targetPanY.coerceIn(minPanY, headerHeightPx)
return Triple(constrainedZoom, constrainedX, constrainedY) return Triple(constrainedZoom, constrainedX, constrainedY)
} }
@ -629,6 +650,7 @@ internal fun PdfVerticalReader(
val currentZoom = zoomAnimatable.value val currentZoom = zoomAnimatable.value
val zoomedDocHeight = totalDocHeight * currentZoom val zoomedDocHeight = totalDocHeight * currentZoom
val zoomedDocWidth = screenWidth * currentZoom
val isAnimating = zoomAnimatable.isRunning || panYAnimatable.isRunning || panXAnimatable.isRunning val isAnimating = zoomAnimatable.isRunning || panYAnimatable.isRunning || panXAnimatable.isRunning
@ -637,11 +659,21 @@ internal fun PdfVerticalReader(
val extraScrollForIme = if (isTextEditing) imeBottom.toFloat() else 0f val extraScrollForIme = if (isTextEditing) imeBottom.toFloat() else 0f
val minPanY = (screenHeight - effectiveFooterPx - zoomedDocHeight - extraScrollForIme).coerceAtMost(headerHeightPx) val minPanY = (screenHeight - effectiveFooterPx - zoomedDocHeight - extraScrollForIme).coerceAtMost(headerHeightPx)
val minPanX = -(screenWidth * currentZoom - screenWidth).coerceAtLeast(0f)
val minPanX: Float
val maxPanX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
minPanX = centeredX
maxPanX = centeredX
} else {
minPanX = -(zoomedDocWidth - screenWidth)
maxPanX = 0f
}
if (!isAnimating) { if (!isAnimating) {
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx) panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
panXAnimatable.updateBounds(lowerBound = minPanX, upperBound = 0f) panXAnimatable.updateBounds(lowerBound = minPanX, upperBound = maxPanX)
} else { } else {
panYAnimatable.updateBounds( panYAnimatable.updateBounds(
lowerBound = minOf(panYAnimatable.lowerBound ?: minPanY, minPanY), lowerBound = minOf(panYAnimatable.lowerBound ?: minPanY, minPanY),
@ -649,7 +681,7 @@ internal fun PdfVerticalReader(
) )
panXAnimatable.updateBounds( panXAnimatable.updateBounds(
lowerBound = minOf(panXAnimatable.lowerBound ?: minPanX, minPanX), lowerBound = minOf(panXAnimatable.lowerBound ?: minPanX, minPanX),
upperBound = 0f upperBound = maxOf(panXAnimatable.upperBound ?: maxPanX, maxPanX)
) )
} }
} }
@ -706,7 +738,12 @@ internal fun PdfVerticalReader(
val onDoubleTapToZoom: (Offset) -> Unit = { tapScreenOffset -> val onDoubleTapToZoom: (Offset) -> Unit = { tapScreenOffset ->
val currentZoom = zoomAnimatable.value val currentZoom = zoomAnimatable.value
val targetZoom = if (currentZoom < 1.1f) 2.5f else 1f
val targetZoom = when {
currentZoom < 0.95f -> 1f
currentZoom < 2.45f -> 2.5f
else -> fitZoom
}
val startPanX = panXAnimatable.value val startPanX = panXAnimatable.value
val startPanY = panYAnimatable.value val startPanY = panYAnimatable.value
@ -719,46 +756,41 @@ internal fun PdfVerticalReader(
val pivotContentX = (tapScreenOffset.x - startPanX) / currentZoom val pivotContentX = (tapScreenOffset.x - startPanX) / currentZoom
val pivotContentY = (tapScreenOffset.y - startPanY) / currentZoom val pivotContentY = (tapScreenOffset.y - startPanY) / currentZoom
val durationMillis = 400L val rawNextPanX = tapScreenOffset.x - (pivotContentX * targetZoom)
val startTimeNanos = withFrameNanos { it } val rawNextPanY = tapScreenOffset.y - (pivotContentY * targetZoom)
val durationNanos = durationMillis * 1_000_000L
while (true) { val (finalZoom, finalX, finalY) = clampCamera(targetZoom, rawNextPanX, rawNextPanY)
val now = withFrameNanos { it }
val elapsedNanos = now - startTimeNanos
val progress = (elapsedNanos.toFloat() / durationNanos).coerceIn(0f, 1f)
val easedProgress = FastOutSlowInEasing.transform(progress)
val nextZoom = androidx.compose.ui.util.lerp(currentZoom, targetZoom, easedProgress)
val rawNextPanX = tapScreenOffset.x - (pivotContentX * nextZoom)
val rawNextPanY = tapScreenOffset.y - (pivotContentY * nextZoom)
val (clampedZoom, clampedX, clampedY) = clampCamera(nextZoom, rawNextPanX, rawNextPanY)
panXAnimatable.updateBounds( panXAnimatable.updateBounds(
lowerBound = minOf(panXAnimatable.lowerBound ?: clampedX, clampedX), lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX),
upperBound = maxOf(panXAnimatable.upperBound ?: clampedX, clampedX) upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX)
) )
panYAnimatable.updateBounds( panYAnimatable.updateBounds(
lowerBound = minOf(panYAnimatable.lowerBound ?: clampedY, clampedY), lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY),
upperBound = maxOf(panYAnimatable.upperBound ?: clampedY, clampedY) upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY)
) )
zoomAnimatable.snapTo(clampedZoom) coroutineScope {
panXAnimatable.snapTo(clampedX) launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
panYAnimatable.snapTo(clampedY) launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
if (progress >= 1f) break
} }
onZoomChange(zoomAnimatable.value) onZoomChange(zoomAnimatable.value)
val finalZoom = zoomAnimatable.value val zoomedDocWidth = screenWidth * finalZoom
panXAnimatable.updateBounds( val finalMinX: Float
lowerBound = -(screenWidth * finalZoom - screenWidth).coerceAtLeast(0f), val finalMaxX: Float
upperBound = 0f if (zoomedDocWidth < screenWidth) {
) val centeredX = (screenWidth - zoomedDocWidth) / 2f
finalMinX = centeredX
finalMaxX = centeredX
} else {
finalMinX = -(zoomedDocWidth - screenWidth)
finalMaxX = 0f
}
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
val zDocH = totalDocHeight * finalZoom val zDocH = totalDocHeight * finalZoom
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx) val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx) panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx)
@ -1032,7 +1064,7 @@ internal fun PdfVerticalReader(
val oldZoom = accumulatedZoom val oldZoom = accumulatedZoom
val rawTargetZoom = oldZoom * effectiveZoomChange val rawTargetZoom = oldZoom * effectiveZoomChange
val constrainedZoom = rawTargetZoom.coerceIn(1f, 5f) val constrainedZoom = rawTargetZoom.coerceIn(fitZoom, 5f)
val prevCentroid = centroid - panChange val prevCentroid = centroid - panChange
val contentPivotX = (prevCentroid.x - accumulatedPanX) / oldZoom val contentPivotX = (prevCentroid.x - accumulatedPanX) / oldZoom
@ -1046,13 +1078,6 @@ internal fun PdfVerticalReader(
val rawNewPanX = centroid.x - (contentPivotX * constrainedZoom) val rawNewPanX = centroid.x - (contentPivotX * constrainedZoom)
val rawNewPanY = centroid.y - (contentPivotY * constrainedZoom) val rawNewPanY = centroid.y - (contentPivotY * constrainedZoom)
if (effectiveZoomChange > 1.0f) {
Timber.tag("PdfZoomIssue").d(
"PinchIn FIXED: ZoomFactor=$effectiveZoomChange, CentroidY=${centroid.y}, " +
"OldPanY=$accumulatedPanY, ResultRawPanY=$rawNewPanY"
)
}
val (finalZoom, finalX, finalY) = clampCamera( val (finalZoom, finalX, finalY) = clampCamera(
constrainedZoom, rawNewPanX, rawNewPanY constrainedZoom, rawNewPanX, rawNewPanY
) )
@ -1103,7 +1128,7 @@ internal fun PdfVerticalReader(
scope.launch { scope.launch {
isFlinging = true isFlinging = true
try { try {
if (accumulatedZoom !in 1f..5f) { if (accumulatedZoom !in fitZoom..5f) {
zoomAnimatable.animateTo( zoomAnimatable.animateTo(
finalZoom, animationSpec = tween(300) finalZoom, animationSpec = tween(300)
) )
@ -1111,8 +1136,18 @@ internal fun PdfVerticalReader(
onZoomChange(zoomAnimatable.targetValue) onZoomChange(zoomAnimatable.targetValue)
val zoomedDocWidth = screenWidth * finalZoom val zoomedDocWidth = screenWidth * finalZoom
val zoomedDocHeight = totalDocHeight * finalZoom val zoomedDocHeight = totalDocHeight * finalZoom
val maxPanX = 0f
val minPanX = -(zoomedDocWidth - screenWidth).coerceAtLeast(0f) val flingMinX: Float
val flingMaxX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
flingMinX = centeredX
flingMaxX = centeredX
} else {
flingMinX = -(zoomedDocWidth - screenWidth)
flingMaxX = 0f
}
val minPanY = val minPanY =
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost( (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
headerHeightPx headerHeightPx
@ -1121,11 +1156,9 @@ internal fun PdfVerticalReader(
Timber.tag(SCROLL_BOUNDS_TAG) Timber.tag(SCROLL_BOUNDS_TAG)
.d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight") .d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight")
Timber.tag(SCROLL_BOUNDS_TAG) Timber.tag(SCROLL_BOUNDS_TAG)
.d("- Fling bounds set to Y: [$minPanY, $headerHeightPx]") .d("- Fling bounds set to Y:[$minPanY, $headerHeightPx]")
panXAnimatable.updateBounds(minPanX, maxPanX) panXAnimatable.updateBounds(flingMinX, flingMaxX)
panYAnimatable.updateBounds( panYAnimatable.updateBounds(minPanY, headerHeightPx)
minPanY, headerHeightPx
)
coroutineScope { coroutineScope {
launch { launch {

View file

@ -2147,8 +2147,6 @@ fun PdfViewerScreen(
Timber.tag("PdfPositionDebug").i("UI: Restoration Start | Target: $targetPage | Mode: $displayMode | Total: $pageCount") Timber.tag("PdfPositionDebug").i("UI: Restoration Start | Target: $targetPage | Mode: $displayMode | Total: $pageCount")
try { try {
delay(200)
when (displayMode) { when (displayMode) {
DisplayMode.PAGINATION -> { DisplayMode.PAGINATION -> {
if (pagerState.currentPage != targetPage) { if (pagerState.currentPage != targetPage) {
@ -2157,14 +2155,11 @@ fun PdfViewerScreen(
initialScrollDone = true initialScrollDone = true
} }
DisplayMode.VERTICAL_SCROLL -> { DisplayMode.VERTICAL_SCROLL -> {
var retries = 0 while (verticalReaderState.snapToPageHandler == null) {
while (verticalReaderState.snapToPageHandler == null && retries < 20) { delay(16)
delay(50)
retries++
} }
Timber.tag("PdfPositionDebug").d("UI: Executing Vertical snapToPage($targetPage) after $retries retries") Timber.tag("PdfPositionDebug").d("UI: Executing Vertical snapToPage($targetPage)")
verticalReaderState.snapToPage(targetPage) verticalReaderState.snapToPage(targetPage)
delay(100)
initialScrollDone = true initialScrollDone = true
} }
} }