From 875bddb3f0864935dad236190dabbb45239a13e9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:50:25 +0000 Subject: [PATCH] feat: Add pinch-to-zoom and double-tap zoom support for Reader images - Create ZoomableBox component for gesture handling - Integrate ZoomableBox into ReaderImageView - Enable zoom for Manga/Manhwa content in ReaderScreen - Support edge scrolling behavior when zoomed in Co-authored-by: Aatricks <113598245+Aatricks@users.noreply.github.com> --- .../ui/components/ReaderImageView.kt | 65 +++++---- .../novelscraper/ui/components/ZoomableBox.kt | 128 ++++++++++++++++++ .../novelscraper/ui/screens/ReaderScreen.kt | 22 ++- 3 files changed, 186 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/io/aatricks/novelscraper/ui/components/ZoomableBox.kt diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/components/ReaderImageView.kt b/app/src/main/java/io/aatricks/novelscraper/ui/components/ReaderImageView.kt index d322377..0bd8437 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/components/ReaderImageView.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/components/ReaderImageView.kt @@ -39,7 +39,9 @@ fun ReaderImageView( backgroundColor: Color = Color.Black, width: Int = 0, height: Int = 0, - side: ContentElement.Image.Side = ContentElement.Image.Side.FULL + side: ContentElement.Image.Side = ContentElement.Image.Side.FULL, + enableZoom: Boolean = false, + onTap: (() -> Unit)? = null ) { val aspectRatioModifier = Modifier.imageAspectRatio(side, width, height) val imageModifier = Modifier @@ -85,17 +87,23 @@ fun ReaderImageView( .wrapContentHeight(), contentAlignment = Alignment.Center ) { - AsyncImage( - model = imageRequest, - contentDescription = altText, - modifier = imageModifier, - contentScale = contentScale, - onSuccess = { isLoading = false }, - onError = { - isError = true - isLoading = false - } - ) + ZoomableBox( + modifier = Modifier.matchParentSize(), + enableZoom = enableZoom, + onTap = onTap + ) { + AsyncImage( + model = imageRequest, + contentDescription = altText, + modifier = imageModifier, + contentScale = contentScale, + onSuccess = { isLoading = false }, + onError = { + isError = true + isLoading = false + } + ) + } if (isLoading && !cachedFile.exists()) { CircularProgressIndicator( @@ -139,18 +147,29 @@ fun ReaderImageView( .background(backgroundColor), contentAlignment = Alignment.Center ) { - when { - isLoading -> CircularProgressIndicator(color = Color.Gray, modifier = Modifier.size(32.dp).padding(16.dp)) - hasError -> Text(text = altText ?: "Image unavailable", color = Color.Gray, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(16.dp)) - imageData != null -> imageData?.let { bitmap -> - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = altText, - modifier = imageModifier, - contentScale = contentScale - ) + ZoomableBox( + modifier = Modifier.matchParentSize(), + enableZoom = enableZoom, + onTap = onTap + ) { + if (imageData != null) { + imageData?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = altText, + modifier = imageModifier, + contentScale = contentScale + ) + } } } + + if (isLoading) { + CircularProgressIndicator(color = Color.Gray, modifier = Modifier.size(32.dp).padding(16.dp)) + } + if (hasError) { + Text(text = altText ?: "Image unavailable", color = Color.Gray, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(16.dp)) + } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/components/ZoomableBox.kt b/app/src/main/java/io/aatricks/novelscraper/ui/components/ZoomableBox.kt new file mode 100644 index 0000000..bd4dfc3 --- /dev/null +++ b/app/src/main/java/io/aatricks/novelscraper/ui/components/ZoomableBox.kt @@ -0,0 +1,128 @@ +package io.aatricks.novelscraper.ui.components + +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.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach +import kotlin.math.abs + +@Composable +fun ZoomableBox( + modifier: Modifier = Modifier, + minScale: Float = 1f, + maxScale: Float = 3f, + enableZoom: Boolean = false, + onTap: (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit +) { + var scale by remember { mutableFloatStateOf(minScale) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + var size by remember { mutableStateOf(IntSize.Zero) } + + fun clampOffset(value: Float, contentSize: Float, currentScale: Float): Float { + val scaledContentSize = contentSize * currentScale + if (scaledContentSize <= contentSize) return 0f + + val maxOffset = (scaledContentSize - contentSize) / 2f + return value.coerceIn(-maxOffset, maxOffset) + } + + if (enableZoom) { + Box( + modifier = modifier + .onSizeChanged { size = it } + .clipToBounds() + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + if (scale > minScale) { + scale = minScale + offsetX = 0f + offsetY = 0f + } else { + scale = 2f + } + }, + onTap = { onTap?.invoke() } + ) + } + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown() + do { + val event = awaitPointerEvent() + val canceled = event.changes.fastAny { it.isConsumed } + if (!canceled) { + val zoomChange = event.calculateZoom() + val panChange = event.calculatePan() + val isZooming = zoomChange != 1f + + if (isZooming || scale > minScale) { + val newScale = (scale * zoomChange).coerceIn(minScale, maxScale) + val isScaling = newScale != scale + scale = newScale + + val width = size.width.toFloat() + val height = size.height.toFloat() + + // Apply pan relative to current scale + val targetX = offsetX + panChange.x + val targetY = offsetY + panChange.y + + val newOffsetX = clampOffset(targetX, width, scale) + val newOffsetY = clampOffset(targetY, height, scale) + + val hasPannedX = abs(newOffsetX - offsetX) > 0.1f + val hasPannedY = abs(newOffsetY - offsetY) > 0.1f + + offsetX = newOffsetX + offsetY = newOffsetY + + // Consumption Logic: + // 1. If we are actively scaling (pinching), consume. + // 2. If we effectively panned (moved within bounds), consume. + // 3. If we tried to pan but were clamped (hit edge), DO NOT consume (let parent scroll). + + if (isScaling || isZooming || hasPannedX || hasPannedY) { + event.changes.fastForEach { + if (it.positionChanged()) it.consume() + } + } + } + } + } while (event.changes.fastAny { it.pressed }) + } + } + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offsetX + translationY = offsetY + } + ) { + content() + } + } else { + Box( + modifier = modifier.pointerInput(Unit) { + detectTapGestures(onTap = { onTap?.invoke() }) + }, + content = content + ) + } +} diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt b/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt index 4e6ab02..db67a10 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt @@ -599,7 +599,8 @@ private fun ContentArea( fontFamily = fontFamily, bgColor = bgColor, textColor = textColor, - readerViewModel = readerViewModel + readerViewModel = readerViewModel, + isZoomable = isManhwa ) } else { ScrollingReaderView( @@ -668,7 +669,8 @@ private fun PagedReaderView( fontFamily: FontFamily, bgColor: Color, textColor: Color, - readerViewModel: ReaderViewModel + readerViewModel: ReaderViewModel, + isZoomable: Boolean ): Unit { HorizontalPager( state = pagerState, @@ -710,7 +712,9 @@ private fun PagedReaderView( backgroundColor = bgColor, width = el.width, height = el.height, - side = el.side + side = el.side, + enableZoom = isZoomable, + onTap = { readerViewModel.toggleControls() } ) } is ContentElement.ImageGroup -> { @@ -731,7 +735,9 @@ private fun PagedReaderView( backgroundColor = bgColor, width = img.width, height = img.height, - side = img.side + side = img.side, + enableZoom = isZoomable, + onTap = { readerViewModel.toggleControls() } ) } } @@ -795,7 +801,9 @@ private fun ScrollingReaderView( backgroundColor = bgColor, width = element.width, height = element.height, - side = element.side + side = element.side, + enableZoom = isManhwa, + onTap = { readerViewModel.toggleControls() } ) } is ContentElement.ImageGroup -> { @@ -813,7 +821,9 @@ private fun ScrollingReaderView( backgroundColor = bgColor, width = img.width, height = img.height, - side = img.side + side = img.side, + enableZoom = isManhwa, + onTap = { readerViewModel.toggleControls() } ) } }