zoom on documents
This commit is contained in:
+33
-14
@@ -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,6 +297,7 @@ 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,
|
||||||
@@ -301,18 +305,24 @@ private fun PdfFullscreenReader(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
position = if (pageCount > 0) "${pagerState.currentPage + 1} / $pageCount" else null,
|
position = if (pageCount > 0) "${pagerState.currentPage + 1} / $pageCount" else null,
|
||||||
) {
|
) {
|
||||||
PdfFocusViewer(
|
ZoomableContent(
|
||||||
file = file,
|
|
||||||
contentResolver = LocalContext.current.contentResolver,
|
|
||||||
onOpenExternalFailed = onOpenExternalFailed,
|
|
||||||
pagerState = pagerState,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
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
|
@Composable
|
||||||
private fun ImageFullscreenReader(
|
private fun ImageFullscreenReader(
|
||||||
file: FileEntity,
|
file: FileEntity,
|
||||||
@@ -330,12 +340,14 @@ private fun ImageFullscreenReader(
|
|||||||
onExitFullscreen = onExitFullscreen,
|
onExitFullscreen = onExitFullscreen,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
ImageViewer(
|
ZoomableContent(modifier = Modifier.fillMaxSize()) {
|
||||||
contentResolver = contentResolver,
|
ImageViewer(
|
||||||
uri = uri,
|
contentResolver = contentResolver,
|
||||||
contentDescription = file.name,
|
uri = uri,
|
||||||
modifier = Modifier.fillMaxSize(),
|
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)
|
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()
|
||||||
|
|||||||
+35
-17
@@ -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(
|
||||||
.getOrNull()
|
page: Int,
|
||||||
?.also { cache.put(page, it) }
|
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 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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