From ea317656120fe2a03ed52e310e9040b11034e3c1 Mon Sep 17 00:00:00 2001 From: Aryan Date: Thu, 26 Feb 2026 20:05:45 +0530 Subject: [PATCH] feat(pdf-viewer): implement pan lock to disable horizontal movement (#9) - Add `isScrollLocked` state to `PdfViewerScreen` to toggle panning behavior. - Replace "Import SVG" debug icon with a Lock/Unlock toggle in the Top App Bar. - Update `PdfVerticalReader` and `PdfPageComposable` to strictly zero out X-axis pan deltas and fling velocities when locked. - Maintain zoom and vertical scrolling functionality while panning is locked. --- .../com/aryan/reader/pdf/PdfPageComposable.kt | 24 ++++++++----- .../com/aryan/reader/pdf/PdfVerticalReader.kt | 9 +++-- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 36 +++++++------------ 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index d9db94c..65f5bbf 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -17,6 +17,7 @@ * * mail: epistemereader@gmail.com */ +// PdfPageComposable @file:Suppress( "RemoveRedundantQualifierName", "COMPOSE_APPLIER_CALL_MISMATCH", "UnusedVariable", "unused" ) @@ -434,6 +435,7 @@ internal fun PdfPageComposable( onTextBoxDragEnd: () -> Unit = {}, onDragPageTurn: (Int) -> Unit = {}, draggingBoxId: String? = null, + isScrollLocked: Boolean = false, isVisible: Boolean = true, ) { SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") } @@ -2422,7 +2424,8 @@ internal fun PdfPageComposable( isZoomEnabled, isVerticalScroll, isEditMode, - onTwoFingerSwipe + onTwoFingerSwipe, + isScrollLocked ) { if (!isZoomEnabled || isVerticalScroll || actualBitmapWidthPx == 0 || activeDraggingHandle != null) return@pointerInput @@ -2453,7 +2456,8 @@ internal fun PdfPageComposable( } if (!canceled) { - val panChange = event.calculatePan() + val rawPanChange = event.calculatePan() + val panChange = if (isScrollLocked) Offset(0f, rawPanChange.y) else rawPanChange val zoomChange = event.calculateZoom() if (scale > 1f) { @@ -2622,13 +2626,15 @@ internal fun PdfPageComposable( coroutineScope.launch { coroutineScope { launch { - Animatable(startX).animateDecay( - velocity.x, decay - ) { - val newX = value.coerceIn( - -maxOffsetX, maxOffsetX - ) - offset = offset.copy(x = newX) + if (!isScrollLocked) { + Animatable(startX).animateDecay( + velocity.x, decay + ) { + val newX = value.coerceIn( + -maxOffsetX, maxOffsetX + ) + offset = offset.copy(x = newX) + } } } launch { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index 986726b..4c12eb8 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -214,6 +214,7 @@ internal fun PdfVerticalReader( onTextBoxMoved: (String, Int, Rect) -> Unit = { _, _, _ -> }, isAutoScrollPlaying: Boolean = false, isAutoScrollTempPaused: Boolean = false, + isScrollLocked: Boolean = false, autoScrollSpeed: Float = 1.0f, onInteractionListener: () -> Unit = {} ) { @@ -837,7 +838,7 @@ internal fun PdfVerticalReader( }) } - .pointerInput(totalDocHeight, isEditMode, selectedTool) { + .pointerInput(totalDocHeight, isEditMode, selectedTool, isScrollLocked) { val tracker = VelocityTracker() val decay = exponentialDecay() val touchSlop = viewConfiguration.touchSlop @@ -916,7 +917,9 @@ internal fun PdfVerticalReader( } val zoomChange = event.calculateZoom() - val panChange = event.calculatePan() + val rawPanChange = event.calculatePan() + val panChange = if (isScrollLocked) Offset(0f, rawPanChange.y) else rawPanChange + val centroid = event.calculateCentroid(useCurrent = false) val panMagnitude = panChange.getDistance() val currentCentroidSize = event.calculateCentroidSize( @@ -1065,7 +1068,7 @@ internal fun PdfVerticalReader( coroutineScope { launch { val rawX = velocity.x * flingSensitivity - val flingX = if (abs(rawX) > minFlingVelocity) rawX + val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX else 0f if (flingX != 0f) panXAnimatable.animateDecay( diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index d4b0f27..479ecb5 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -102,6 +102,8 @@ import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Save @@ -588,6 +590,7 @@ fun PdfViewerScreen( var showBars by remember { mutableStateOf(true) } var isFullScreen by remember { mutableStateOf(false) } var documentPassword by remember { mutableStateOf(null) } + var isScrollLocked by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) } var isPasswordError by remember { mutableStateOf(false) } LocalView.current @@ -3208,6 +3211,7 @@ fun PdfViewerScreen( virtualPage = virtualPage, totalPages = totalDisplayPages, isDarkMode = isPdfDarkMode, + isScrollLocked = isScrollLocked, onScaleChanged = { newScale -> if (pagerState.currentPage == pageIndex) { currentPageScale = newScale @@ -3597,6 +3601,7 @@ fun PdfViewerScreen( state = verticalReaderState, pdfDocument = docHolder, isDarkMode = isPdfDarkMode, + isScrollLocked = isScrollLocked, totalPages = totalDisplayPages, pageAspectRatios = ratiosHolder, virtualPages = virtualPages, @@ -4016,6 +4021,14 @@ fun PdfViewerScreen( ) } + IconButton(onClick = { isScrollLocked = !isScrollLocked }) { + Icon( + imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = if (isScrollLocked) "Unlock Panning" else "Lock Panning", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = { isFullScreen = true }) { Icon( imageVector = Icons.Default.Fullscreen, @@ -4066,29 +4079,6 @@ fun PdfViewerScreen( tint = Color(0xFFE91E63) ) } - - IconButton(onClick = { - val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage - val demoAnnos = DemoAnnotationGenerator.generateDemoAnnotations(page) - - val existing = allAnnotations[page] ?: emptyList() - allAnnotations = allAnnotations + (page to (existing + demoAnnos)) - - demoAnnos.forEach { annot -> - undoStack.add(HistoryAction.Add(page, annot)) - } - redoStack.clear() - - coroutineScope.launch { - snackbarHostState.showSnackbar("Demo annotations injected!") - } - }) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = "Inject Demo Annotations", - tint = Color(0xFF2E7D32) - ) - } } Box {