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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.ImageViewer
import com.vaultdrop.mobile.ui.document.content.PdfFocusViewer import com.vaultdrop.mobile.ui.document.content.PdfFocusViewer
import com.vaultdrop.mobile.ui.document.content.TextFocusViewer import com.vaultdrop.mobile.ui.document.content.TextFocusViewer
import com.vaultdrop.mobile.ui.document.content.ZoomableContent
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -294,12 +297,17 @@ private fun PdfFullscreenReader(
) { ) {
var pageCount by remember(file.resourceId) { mutableIntStateOf(0) } var pageCount by remember(file.resourceId) { mutableIntStateOf(0) }
val pagerState = rememberPagerState(pageCount = { pageCount }) val pagerState = rememberPagerState(pageCount = { pageCount })
var zoom by remember(file.resourceId) { mutableFloatStateOf(1f) }
FocusScaffold( FocusScaffold(
file = file, file = file,
onExitFullscreen = onExitFullscreen, onExitFullscreen = onExitFullscreen,
modifier = modifier, modifier = modifier,
position = if (pageCount > 0) "${pagerState.currentPage + 1} / $pageCount" else null, position = if (pageCount > 0) "${pagerState.currentPage + 1} / $pageCount" else null,
) {
ZoomableContent(
modifier = Modifier.fillMaxSize(),
onScaleChanged = { zoom = it },
) { ) {
PdfFocusViewer( PdfFocusViewer(
file = file, file = file,
@@ -308,11 +316,13 @@ private fun PdfFullscreenReader(
pagerState = pagerState, pagerState = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onPageCountChanged = { pageCount = it }, 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 @Composable
private fun ImageFullscreenReader( private fun ImageFullscreenReader(
file: FileEntity, file: FileEntity,
@@ -330,6 +340,7 @@ private fun ImageFullscreenReader(
onExitFullscreen = onExitFullscreen, onExitFullscreen = onExitFullscreen,
modifier = modifier, modifier = modifier,
) { ) {
ZoomableContent(modifier = Modifier.fillMaxSize()) {
ImageViewer( ImageViewer(
contentResolver = contentResolver, contentResolver = contentResolver,
uri = uri, uri = uri,
@@ -338,6 +349,7 @@ private fun ImageFullscreenReader(
) )
} }
} }
}
/** Texte focus : lecture immersive fond sombre, texte clair, sélectionnable. */ /** Texte focus : lecture immersive fond sombre, texte clair, sélectionnable. */
@Composable @Composable
@@ -448,6 +460,13 @@ private fun FileEntity.canFocus(): Boolean =
private val FOCUS_CATEGORIES = setOf(FileCategory.PDF, FileCategory.IMAGE, FileCategory.TEXT) 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 { private fun formatDateTime(millis: Long): String {
val locale = Locale.getDefault() val locale = Locale.getDefault()
val dateTime = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDateTime() val dateTime = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDateTime()
@@ -134,6 +134,7 @@ fun PdfFocusViewer(
pagerState: PagerState, pagerState: PagerState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onPageCountChanged: ((Int) -> Unit)? = null, onPageCountChanged: ((Int) -> Unit)? = null,
renderScale: Float = 1f,
) { ) {
val uri = file.uri val uri = file.uri
if (uri == null) { if (uri == null) {
@@ -184,6 +185,7 @@ fun PdfFocusViewer(
page = page, page = page,
maxWidthPx = maxWidthPx, maxWidthPx = maxWidthPx,
maxHeightPx = maxHeightPx, maxHeightPx = maxHeightPx,
renderScale = renderScale,
) )
} }
} }
@@ -197,12 +199,13 @@ private fun PdfFocusPageItem(
page: Int, page: Int,
maxWidthPx: Int, maxWidthPx: Int,
maxHeightPx: Int, maxHeightPx: Int,
renderScale: Float = 1f,
) { ) {
var bitmap by remember(page) { mutableStateOf<Bitmap?>(null) } var bitmap by remember(page) { mutableStateOf<Bitmap?>(null) }
var failed by remember(page) { mutableStateOf(false) } var failed by remember(page) { mutableStateOf(false) }
LaunchedEffect(document, page, maxWidthPx, maxHeightPx) { LaunchedEffect(document, page, maxWidthPx, maxHeightPx, renderScale) {
failed = false failed = false
bitmap = document.bitmap(page, maxWidthPx, maxHeightPx) bitmap = document.bitmap(page, maxWidthPx, maxHeightPx, renderScale)
if (bitmap == null) failed = true if (bitmap == null) failed = true
} }
@@ -305,7 +308,9 @@ private class PdfDocumentState(
private val uri: String, private val uri: String,
) { ) {
private var renderer: PdfRenderer? = null 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() private val renderMutex = Mutex()
val pageCount: Int get() = renderer?.pageCount ?: 0 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? = * Bitmap de la page (mise à l'échelle pour tenir dans les limites), grossie
withContext(Dispatchers.IO) { * par [targetScale] (1 = résolution écran, >1 = rendu plus net pour le zoom).
cache.get(page) ?: renderMutex.withLock { */
cache.get(page) ?: runCatching { renderPage(page, maxWidthPx, maxHeightPx) } 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() .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 current = renderer ?: error("document non ouvert")
val pdfPage = current.openPage(page) val pdfPage = current.openPage(page)
try { 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 // borné par MAX_SCALE pour ne pas exploser la mémoire des pages
// vectorielles très grandes. // vectorielles très grandes.
val scale = minOf( val baseFit = minOf(
MAX_SCALE,
maxWidthPx.toFloat() / pdfPage.width, maxWidthPx.toFloat() / pdfPage.width,
maxHeightPx.toFloat() / pdfPage.height, maxHeightPx.toFloat() / pdfPage.height,
) )
val scale = minOf(MAX_SCALE, baseFit * targetScale)
val width = (pdfPage.width * scale).toInt().coerceAtLeast(1) val width = (pdfPage.width * scale).toInt().coerceAtLeast(1)
val height = (pdfPage.height * scale).toInt().coerceAtLeast(1) val height = (pdfPage.height * scale).toInt().coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
@@ -362,11 +376,15 @@ private class PdfDocumentState(
renderer = null renderer = null
} }
/** Clé de cache : page + résolution de rendu demandée. */
private data class PageKey(val page: Int, val scale: Float)
companion object { 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. */ /** 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