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.
This commit is contained in:
Aryan 2026-02-26 20:05:45 +05:30 committed by GitHub
parent f2daebe0a5
commit ea31765612
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 34 additions and 35 deletions

View file

@ -17,6 +17,7 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// PdfPageComposable
@file:Suppress( @file:Suppress(
"RemoveRedundantQualifierName", "COMPOSE_APPLIER_CALL_MISMATCH", "UnusedVariable", "unused" "RemoveRedundantQualifierName", "COMPOSE_APPLIER_CALL_MISMATCH", "UnusedVariable", "unused"
) )
@ -434,6 +435,7 @@ internal fun PdfPageComposable(
onTextBoxDragEnd: () -> Unit = {}, onTextBoxDragEnd: () -> Unit = {},
onDragPageTurn: (Int) -> Unit = {}, onDragPageTurn: (Int) -> Unit = {},
draggingBoxId: String? = null, draggingBoxId: String? = null,
isScrollLocked: Boolean = false,
isVisible: Boolean = true, isVisible: Boolean = true,
) { ) {
SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") } SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") }
@ -2422,7 +2424,8 @@ internal fun PdfPageComposable(
isZoomEnabled, isZoomEnabled,
isVerticalScroll, isVerticalScroll,
isEditMode, isEditMode,
onTwoFingerSwipe onTwoFingerSwipe,
isScrollLocked
) { ) {
if (!isZoomEnabled || isVerticalScroll || actualBitmapWidthPx == 0 || activeDraggingHandle != null) return@pointerInput if (!isZoomEnabled || isVerticalScroll || actualBitmapWidthPx == 0 || activeDraggingHandle != null) return@pointerInput
@ -2453,7 +2456,8 @@ internal fun PdfPageComposable(
} }
if (!canceled) { if (!canceled) {
val panChange = event.calculatePan() val rawPanChange = event.calculatePan()
val panChange = if (isScrollLocked) Offset(0f, rawPanChange.y) else rawPanChange
val zoomChange = event.calculateZoom() val zoomChange = event.calculateZoom()
if (scale > 1f) { if (scale > 1f) {
@ -2622,13 +2626,15 @@ internal fun PdfPageComposable(
coroutineScope.launch { coroutineScope.launch {
coroutineScope { coroutineScope {
launch { launch {
Animatable(startX).animateDecay( if (!isScrollLocked) {
velocity.x, decay Animatable(startX).animateDecay(
) { velocity.x, decay
val newX = value.coerceIn( ) {
-maxOffsetX, maxOffsetX val newX = value.coerceIn(
) -maxOffsetX, maxOffsetX
offset = offset.copy(x = newX) )
offset = offset.copy(x = newX)
}
} }
} }
launch { launch {

View file

@ -214,6 +214,7 @@ internal fun PdfVerticalReader(
onTextBoxMoved: (String, Int, Rect) -> Unit = { _, _, _ -> }, onTextBoxMoved: (String, Int, Rect) -> Unit = { _, _, _ -> },
isAutoScrollPlaying: Boolean = false, isAutoScrollPlaying: Boolean = false,
isAutoScrollTempPaused: Boolean = false, isAutoScrollTempPaused: Boolean = false,
isScrollLocked: Boolean = false,
autoScrollSpeed: Float = 1.0f, autoScrollSpeed: Float = 1.0f,
onInteractionListener: () -> Unit = {} onInteractionListener: () -> Unit = {}
) { ) {
@ -837,7 +838,7 @@ internal fun PdfVerticalReader(
}) })
} }
.pointerInput(totalDocHeight, isEditMode, selectedTool) { .pointerInput(totalDocHeight, isEditMode, selectedTool, isScrollLocked) {
val tracker = VelocityTracker() val tracker = VelocityTracker()
val decay = exponentialDecay<Float>() val decay = exponentialDecay<Float>()
val touchSlop = viewConfiguration.touchSlop val touchSlop = viewConfiguration.touchSlop
@ -916,7 +917,9 @@ internal fun PdfVerticalReader(
} }
val zoomChange = event.calculateZoom() 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 centroid = event.calculateCentroid(useCurrent = false)
val panMagnitude = panChange.getDistance() val panMagnitude = panChange.getDistance()
val currentCentroidSize = event.calculateCentroidSize( val currentCentroidSize = event.calculateCentroidSize(
@ -1065,7 +1068,7 @@ internal fun PdfVerticalReader(
coroutineScope { coroutineScope {
launch { launch {
val rawX = velocity.x * flingSensitivity val rawX = velocity.x * flingSensitivity
val flingX = if (abs(rawX) > minFlingVelocity) rawX val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX
else 0f else 0f
if (flingX != 0f) panXAnimatable.animateDecay( if (flingX != 0f) panXAnimatable.animateDecay(

View file

@ -102,6 +102,8 @@ import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp 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.Menu
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
@ -588,6 +590,7 @@ fun PdfViewerScreen(
var showBars by remember { mutableStateOf(true) } var showBars by remember { mutableStateOf(true) }
var isFullScreen by remember { mutableStateOf(false) } var isFullScreen by remember { mutableStateOf(false) }
var documentPassword by remember { mutableStateOf<String?>(null) } var documentPassword by remember { mutableStateOf<String?>(null) }
var isScrollLocked by remember { mutableStateOf(false) }
var showPasswordDialog by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) }
var isPasswordError by remember { mutableStateOf(false) } var isPasswordError by remember { mutableStateOf(false) }
LocalView.current LocalView.current
@ -3208,6 +3211,7 @@ fun PdfViewerScreen(
virtualPage = virtualPage, virtualPage = virtualPage,
totalPages = totalDisplayPages, totalPages = totalDisplayPages,
isDarkMode = isPdfDarkMode, isDarkMode = isPdfDarkMode,
isScrollLocked = isScrollLocked,
onScaleChanged = { newScale -> onScaleChanged = { newScale ->
if (pagerState.currentPage == pageIndex) { if (pagerState.currentPage == pageIndex) {
currentPageScale = newScale currentPageScale = newScale
@ -3597,6 +3601,7 @@ fun PdfViewerScreen(
state = verticalReaderState, state = verticalReaderState,
pdfDocument = docHolder, pdfDocument = docHolder,
isDarkMode = isPdfDarkMode, isDarkMode = isPdfDarkMode,
isScrollLocked = isScrollLocked,
totalPages = totalDisplayPages, totalPages = totalDisplayPages,
pageAspectRatios = ratiosHolder, pageAspectRatios = ratiosHolder,
virtualPages = virtualPages, 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 }) { IconButton(onClick = { isFullScreen = true }) {
Icon( Icon(
imageVector = Icons.Default.Fullscreen, imageVector = Icons.Default.Fullscreen,
@ -4066,29 +4079,6 @@ fun PdfViewerScreen(
tint = Color(0xFFE91E63) 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 { Box {