zoom on documents
This commit is contained in:
+20
-1
@@ -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()
|
||||
|
||||
+33
-15
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+151
@@ -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
|
||||
Reference in New Issue
Block a user