From 9d44703400771ddf5b16990b6646e67efbbd5e91 Mon Sep 17 00:00:00 2001 From: m Date: Sat, 12 Sep 2026 11:06:59 +0200 Subject: [PATCH] zoom on documents --- .../ui/document/DocumentViewerScreen.kt | 47 ++++-- .../ui/document/content/PdfPageViewer.kt | 52 ++++-- .../ui/document/content/ZoomableContent.kt | 151 ++++++++++++++++++ 3 files changed, 219 insertions(+), 31 deletions(-) create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/ZoomableContent.kt diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt index 2d5805a..60b548c 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt @@ -40,6 +40,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -65,6 +67,7 @@ import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer import com.vaultdrop.mobile.ui.document.content.ImageViewer import com.vaultdrop.mobile.ui.document.content.PdfFocusViewer import com.vaultdrop.mobile.ui.document.content.TextFocusViewer +import com.vaultdrop.mobile.ui.document.content.ZoomableContent import kotlinx.coroutines.launch import java.time.Instant import java.time.ZoneId @@ -294,6 +297,7 @@ private fun PdfFullscreenReader( ) { var pageCount by remember(file.resourceId) { mutableIntStateOf(0) } val pagerState = rememberPagerState(pageCount = { pageCount }) + var zoom by remember(file.resourceId) { mutableFloatStateOf(1f) } FocusScaffold( file = file, @@ -301,18 +305,24 @@ private fun PdfFullscreenReader( modifier = modifier, position = if (pageCount > 0) "${pagerState.currentPage + 1} / $pageCount" else null, ) { - PdfFocusViewer( - file = file, - contentResolver = LocalContext.current.contentResolver, - onOpenExternalFailed = onOpenExternalFailed, - pagerState = pagerState, + ZoomableContent( modifier = Modifier.fillMaxSize(), - onPageCountChanged = { pageCount = it }, - ) + onScaleChanged = { zoom = it }, + ) { + PdfFocusViewer( + file = file, + contentResolver = LocalContext.current.contentResolver, + onOpenExternalFailed = onOpenExternalFailed, + pagerState = pagerState, + modifier = Modifier.fillMaxSize(), + onPageCountChanged = { pageCount = it }, + renderScale = pdfRenderScale(zoom), + ) + } } } -/** Image focus : l'image remplit l'écran (fit) sur fond noir. */ +/** Image focus : l'image remplit l'écran (fit), pincable sur fond noir. */ @Composable private fun ImageFullscreenReader( file: FileEntity, @@ -330,12 +340,14 @@ private fun ImageFullscreenReader( onExitFullscreen = onExitFullscreen, modifier = modifier, ) { - ImageViewer( - contentResolver = contentResolver, - uri = uri, - contentDescription = file.name, - modifier = Modifier.fillMaxSize(), - ) + ZoomableContent(modifier = Modifier.fillMaxSize()) { + ImageViewer( + contentResolver = contentResolver, + uri = uri, + contentDescription = file.name, + modifier = Modifier.fillMaxSize(), + ) + } } } @@ -448,6 +460,13 @@ private fun FileEntity.canFocus(): Boolean = private val FOCUS_CATEGORIES = setOf(FileCategory.PDF, FileCategory.IMAGE, FileCategory.TEXT) +/** Résolution de rendu PDF en fonction du zoom : re-rendu par paliers pour rester net. */ +private fun pdfRenderScale(zoom: Float): Float = when { + zoom >= 2.5f -> 3f + zoom >= 1.5f -> 2f + else -> 1f +} + private fun formatDateTime(millis: Long): String { val locale = Locale.getDefault() val dateTime = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDateTime() diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt index aa56386..b0686d1 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt @@ -134,6 +134,7 @@ fun PdfFocusViewer( pagerState: PagerState, modifier: Modifier = Modifier, onPageCountChanged: ((Int) -> Unit)? = null, + renderScale: Float = 1f, ) { val uri = file.uri if (uri == null) { @@ -184,6 +185,7 @@ fun PdfFocusViewer( page = page, maxWidthPx = maxWidthPx, maxHeightPx = maxHeightPx, + renderScale = renderScale, ) } } @@ -197,12 +199,13 @@ private fun PdfFocusPageItem( page: Int, maxWidthPx: Int, maxHeightPx: Int, + renderScale: Float = 1f, ) { var bitmap by remember(page) { mutableStateOf(null) } var failed by remember(page) { mutableStateOf(false) } - LaunchedEffect(document, page, maxWidthPx, maxHeightPx) { + LaunchedEffect(document, page, maxWidthPx, maxHeightPx, renderScale) { failed = false - bitmap = document.bitmap(page, maxWidthPx, maxHeightPx) + bitmap = document.bitmap(page, maxWidthPx, maxHeightPx, renderScale) if (bitmap == null) failed = true } @@ -305,7 +308,9 @@ private class PdfDocumentState( private val uri: String, ) { private var renderer: PdfRenderer? = null - private val cache = LruCache(MAX_CACHED_PAGES) + private val cache = object : LruCache(CACHE_MAX_KILOBYTES) { + override fun sizeOf(key: PageKey, value: Bitmap): Int = value.byteCount / 1024 + } private val renderMutex = Mutex() val pageCount: Int get() = renderer?.pageCount ?: 0 @@ -323,28 +328,37 @@ private class PdfDocumentState( } } - /** Bitmap de la page (mise à l'échelle pour tenir dans les limites). */ - suspend fun bitmap(page: Int, maxWidthPx: Int, maxHeightPx: Int): Bitmap? = - withContext(Dispatchers.IO) { - cache.get(page) ?: renderMutex.withLock { - cache.get(page) ?: runCatching { renderPage(page, maxWidthPx, maxHeightPx) } - .getOrNull() - ?.also { cache.put(page, it) } - } + /** + * Bitmap de la page (mise à l'échelle pour tenir dans les limites), grossie + * par [targetScale] (1 = résolution écran, >1 = rendu plus net pour le zoom). + */ + suspend fun bitmap( + page: Int, + maxWidthPx: Int, + maxHeightPx: Int, + targetScale: Float = 1f, + ): Bitmap? = withContext(Dispatchers.IO) { + val key = PageKey(page, targetScale) + cache.get(key) ?: renderMutex.withLock { + cache.get(key) ?: runCatching { renderPage(page, maxWidthPx, maxHeightPx, targetScale) } + .getOrNull() + ?.also { cache.put(key, it) } } + } - private fun renderPage(page: Int, maxWidthPx: Int, maxHeightPx: Int): Bitmap { + private fun renderPage(page: Int, maxWidthPx: Int, maxHeightPx: Int, targetScale: Float): Bitmap { val current = renderer ?: error("document non ouvert") val pdfPage = current.openPage(page) try { - // Ajuste le rendu à la résolution cible (passe au strict besoin), + // Ajuste le rendu à la résolution cible (passe au strict besoin) : + // fit dans la zone, grossi de targetScale pour rester net au zoom, // borné par MAX_SCALE pour ne pas exploser la mémoire des pages // vectorielles très grandes. - val scale = minOf( - MAX_SCALE, + val baseFit = minOf( maxWidthPx.toFloat() / pdfPage.width, maxHeightPx.toFloat() / pdfPage.height, ) + val scale = minOf(MAX_SCALE, baseFit * targetScale) val width = (pdfPage.width * scale).toInt().coerceAtLeast(1) val height = (pdfPage.height * scale).toInt().coerceAtLeast(1) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) @@ -362,11 +376,15 @@ private class PdfDocumentState( renderer = null } + /** Clé de cache : page + résolution de rendu demandée. */ + private data class PageKey(val page: Int, val scale: Float) + companion object { - private const val MAX_CACHED_PAGES = 6 + /** Budget mémoire du cache (≈1/8 du tas) — le coût unitaire est le byteCount du bitmap. */ + private val CACHE_MAX_KILOBYTES = (Runtime.getRuntime().maxMemory() / 8 / 1024).toInt() /** Borne du ratio de rendu (résolution écran) appliquée à la page source. */ - private const val MAX_SCALE = 2f + private const val MAX_SCALE = 3f } } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/ZoomableContent.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/ZoomableContent.kt new file mode 100644 index 0000000..b6212dc --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/ZoomableContent.kt @@ -0,0 +1,151 @@ +package com.vaultdrop.mobile.ui.document.content + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.launch +import kotlin.math.abs + +/** + * Contenu pincable : zoom (et pan) par geste de deux doigts. + * + * Le geste est détecté à la main (plutôt que `detectTransformGestures`) pour + * ne consommer les événements que lorsqu'il y a une vraie manipulation : tant + * que `scale == 1` et qu'aucun pinch n'est amorcé, rien n'est consommé, donc + * les gestes parents (swipe de page, tap-pour-quitter) restent fonctionnels. + * Dès qu'un pinch est détecté ou que le contenu est zoomé, les événements sont + * consommés (le zoom/pan pilote alors la zone, et le swipe de page est bloqué). + * + * Le zoom est ancré au centroïde des doigts, le pan est borné pour que le + * contenu ne sorte jamais de la zone. Au relâchement, un zoom < 1 rebondit + * vers 1 et un offset hors limites est recentré (animation ressort). + */ +@Composable +fun ZoomableContent( + modifier: Modifier = Modifier, + onScaleChanged: ((Float) -> Unit)? = null, + content: @Composable () -> Unit, +) { + var viewport by remember { mutableStateOf(IntSize.Zero) } + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + val scope = rememberCoroutineScope() + + fun clampX(value: Float): Float { + if (scale <= 1f || viewport.width == 0) return 0f + val max = (scale - 1f) * viewport.width / 2f + return value.coerceIn(-max, max) + } + + fun clampY(value: Float): Float { + if (scale <= 1f || viewport.height == 0) return 0f + val max = (scale - 1f) * viewport.height / 2f + return value.coerceIn(-max, max) + } + + Box( + modifier = modifier + .onSizeChanged { viewport = it } + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offsetX + translationY = offsetY + transformOrigin = TransformOrigin(0f, 0f) + } + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + var pinching = false + while (true) { + val event = awaitPointerEvent() + if (!event.changes.any { it.pressed }) break + // Un parent (pager, tap…) a déjà pris le geste : on ne touche à rien. + if (event.changes.any { it.isConsumed }) continue + + val zoom = event.calculateZoom() + val pan = event.calculatePan() + val currentScale = scale + if (!pinching && + abs(zoom - 1f) > ZOOM_DELTA && + event.changes.count { it.pressed } >= 2 + ) { + pinching = true + } + + if (pinching || currentScale > 1f) { + val centroid = event.calculateCentroid() + val nextScale = (currentScale * zoom).coerceIn(MIN_ZOOM, MAX_ZOOM) + val delta = currentScale - nextScale + offsetX = clampX(offsetX + delta * centroid.x + pan.x) + offsetY = clampY(offsetY + delta * centroid.y + pan.y) + scale = nextScale + onScaleChanged?.invoke(nextScale) + event.changes.forEach { if (!it.isConsumed) it.consume() } + } + } + + // Fin du geste : rebond vers 1 si zoom < 1, sinon recentrage. + if (scale < 1f) { + scope.launch { + val anim = Animatable(scale) + anim.animateTo(1f, spring()) + scale = anim.value + } + scope.launch { + val animX = Animatable(offsetX) + animX.animateTo(0f, spring()) + offsetX = animX.value + } + scope.launch { + val animY = Animatable(offsetY) + animY.animateTo(0f, spring()) + offsetY = animY.value + } + } else { + val targetX = clampX(offsetX) + val targetY = clampY(offsetY) + if (targetX != offsetX || targetY != offsetY) { + scope.launch { + val animX = Animatable(offsetX) + animX.animateTo(targetX, spring()) + offsetX = animX.value + } + scope.launch { + val animY = Animatable(offsetY) + animY.animateTo(targetY, spring()) + offsetY = animY.value + } + } + } + } + }, + ) { + content() + } +} + +private const val MAX_ZOOM = 4f + +private const val MIN_ZOOM = 0.8f + +private const val ZOOM_DELTA = 0.01f \ No newline at end of file