Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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))
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,8 @@ private fun ContentArea(
fontFamily = fontFamily,
bgColor = bgColor,
textColor = textColor,
readerViewModel = readerViewModel
readerViewModel = readerViewModel,
isZoomable = isManhwa
)
} else {
ScrollingReaderView(
Expand Down Expand Up @@ -668,7 +669,8 @@ private fun PagedReaderView(
fontFamily: FontFamily,
bgColor: Color,
textColor: Color,
readerViewModel: ReaderViewModel
readerViewModel: ReaderViewModel,
isZoomable: Boolean
): Unit {
HorizontalPager(
state = pagerState,
Expand Down Expand Up @@ -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 -> {
Expand All @@ -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() }
)
}
}
Expand Down Expand Up @@ -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 -> {
Expand All @@ -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() }
)
}
}
Expand Down