zoom on documents

This commit is contained in:
m
2026-09-12 11:06:59 +02:00
parent e52624aed7
commit 9d44703400
3 changed files with 219 additions and 31 deletions
@@ -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,12 +297,17 @@ 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,
onExitFullscreen = onExitFullscreen,
modifier = modifier,
position = if (pageCount > 0) "${pagerState.currentPage + 1} / $pageCount" else null,
) {
ZoomableContent(
modifier = Modifier.fillMaxSize(),
onScaleChanged = { zoom = it },
) {
PdfFocusViewer(
file = file,
@@ -308,11 +316,13 @@ private fun PdfFullscreenReader(
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,6 +340,7 @@ private fun ImageFullscreenReader(
onExitFullscreen = onExitFullscreen,
modifier = modifier,
) {
ZoomableContent(modifier = Modifier.fillMaxSize()) {
ImageViewer(
contentResolver = contentResolver,
uri = uri,
@@ -338,6 +349,7 @@ private fun ImageFullscreenReader(
)
}
}
}
/** Texte focus : lecture immersive fond sombre, texte clair, sélectionnable. */
@Composable
@@ -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()
@@ -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<Bitmap?>(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<Int, Bitmap>(MAX_CACHED_PAGES)
private val cache = object : LruCache<PageKey, Bitmap>(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) }
/**
* 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(page, it) }
?.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
}
}
@@ -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