package org.terst.feedreader.ui import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import kotlin.math.min /** Fitted (1x) up to this; past ~6x an article image is nothing but its own pixels. */ const val IMAGE_ZOOM_MAX_SCALE = 6f private const val IMAGE_ZOOM_DOUBLE_TAP_SCALE = 3f // An un-zoomed image has nothing to pan to, so a drag on one is read as "put this away" // instead. Far enough that an ordinary sloppy tap can't trigger it by accident. private val SWIPE_DISMISS_DISTANCE = 120.dp /** Applies a pinch to the current zoom level, clamped to fitted-size..[IMAGE_ZOOM_MAX_SCALE]. */ fun imageZoomScale(currentScale: Float, gestureZoom: Float): Float = (currentScale * gestureZoom).coerceIn(1f, IMAGE_ZOOM_MAX_SCALE) /** Double-tap toggles: zoomed at all means "put it back," otherwise zoom in. */ fun imageZoomDoubleTapScale(currentScale: Float): Float = if (currentScale > 1f) 1f else IMAGE_ZOOM_DOUBLE_TAP_SCALE /** * Keeps a pan within the part of the scaled image that's actually off-screen, so the image * can never be dragged away from under the viewport and leave empty space. At fitted size * nothing hangs off either edge, so there's nowhere to pan and the offset pins to 0. */ fun clampImagePanOffset(offset: Float, viewportSize: Float, contentSize: Float, scale: Float): Float { val overhang = ((contentSize * scale - viewportSize) / 2f).coerceAtLeast(0f) return offset.coerceIn(-overhang, overhang) } /** * Where the image actually ends up inside the viewport under ContentScale.Fit -- scaled up or * down to touch the nearer pair of edges, letterboxed on the other axis. [clampImagePanOffset] * needs this rather than the viewport: bounding a pan by the viewport would let a wide photo be * dragged up into the empty black bar above it. * * Falls back to the viewport's own size while the image's dimensions are still unknown -- Coil * can only report them once the image has decoded, and a pan has to be bounded by something in * the meantime. */ fun fittedImageSize( viewportWidth: Float, viewportHeight: Float, imageWidth: Float, imageHeight: Float ): Pair { val known = { size: Float -> size.isFinite() && size > 0f } if (!known(imageWidth) || !known(imageHeight)) return viewportWidth to viewportHeight val fit = min(viewportWidth / imageWidth, viewportHeight / imageHeight) return imageWidth * fit to imageHeight * fit } /** How the viewer is currently showing the image: zoom level, plus where it's been dragged to. */ data class ImageZoomState(val scale: Float = 1f, val offsetX: Float = 0f, val offsetY: Float = 0f) /** * The viewer's state after one step of a transform gesture -- the whole pinch-and-pan * calculation in one place, rather than spread across the gesture callback. * * It lives here, as a function of nothing but its arguments, because the callback can't safely * close over anything computed during composition: a `pointerInput(Unit)` block is created once * and keeps running with that first lambda, so a pan bound computed outside it stayed frozen at * its first-composition value -- a zero-size viewport holding a not-yet-decoded image, whose * only valid offset is 0. That's the whole reason a zoomed image wouldn't pan. Everything the * math needs is a parameter, so the callback has to read it fresh each time. */ fun imageZoomTransform( state: ImageZoomState, viewportSize: Pair, intrinsicSize: Pair, pan: Pair, gestureZoom: Float ): ImageZoomState { val scale = imageZoomScale(state.scale, gestureZoom) val (fittedWidth, fittedHeight) = fittedImageSize(viewportSize.first, viewportSize.second, intrinsicSize.first, intrinsicSize.second) return ImageZoomState( scale = scale, offsetX = clampImagePanOffset(state.offsetX + pan.first, viewportSize.first, fittedWidth, scale), offsetY = clampImagePanOffset(state.offsetY + pan.second, viewportSize.second, fittedHeight, scale) ) } /** Double-tap: toggle the zoom level, re-clamping the existing pan to whatever it becomes. */ fun imageZoomDoubleTap( state: ImageZoomState, viewportSize: Pair, intrinsicSize: Pair ): ImageZoomState = imageZoomTransform( state = state, viewportSize = viewportSize, intrinsicSize = intrinsicSize, pan = 0f to 0f, gestureZoom = imageZoomDoubleTapScale(state.scale) / state.scale ) /** * Full-screen viewer for a tapped article image: pinch to zoom, drag to pan while zoomed, * double-tap to toggle between fitted and zoomed, and dismiss with the system back gesture, * the close button, or a swipe down while un-zoomed. * * Coil's AsyncImage, the same loader the article list's thumbnails already use, so an image * the user just scrolled past is usually served straight from its cache. The zoom itself is a * graphicsLayer transform rather than a re-decode, which keeps a pinch smooth and costs * nothing until it's used. */ @Composable fun ImageZoomDialog(imageUrl: String, onDismiss: () -> Unit) { // usePlatformDefaultWidth would cap this at the platform's narrow AlertDialog width -- // same reason YouTubePlayerDialog opts out. A photo viewer wants the whole screen. Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { var zoom by remember(imageUrl) { mutableStateOf(ImageZoomState()) } var viewport by remember { mutableStateOf(IntSize.Zero) } var dismissDragY by remember { mutableStateOf(0f) } // The image's own decoded dimensions, reported by Coil once it has one to report. // Panning is bounded by where the image actually is, not by the whole viewport -- // ContentScale.Fit letterboxes anything that isn't exactly the screen's aspect ratio. var intrinsic by remember(imageUrl) { mutableStateOf(0f to 0f) } // Deliberately a function called from inside the gesture callbacks, not a value computed // out here: see imageZoomTransform's comment -- the pointerInput blocks below are created // once and keep the lambda they were created with, so anything computed at composition // time would still be reading an empty viewport long after the image had been laid out. fun viewportSize() = viewport.width.toFloat() to viewport.height.toFloat() Box( modifier = Modifier .fillMaxSize() .background(Color.Black) .onSizeChanged { viewport = it } .pointerInput(Unit) { detectTapGestures( onDoubleTap = { zoom = imageZoomDoubleTap(zoom, viewportSize(), intrinsic) } ) } .pointerInput(Unit) { val dismissDistancePx = SWIPE_DISMISS_DISTANCE.toPx() detectTransformGestures { _, pan, gestureZoom, _ -> val next = imageZoomTransform(zoom, viewportSize(), intrinsic, pan.x to pan.y, gestureZoom) if (next.scale > 1f) { // Zoomed in: a drag is a pan, and can't also be a dismiss -- otherwise // panning down to the bottom of a tall image would close the viewer. dismissDragY = 0f } else { dismissDragY += pan.y if (dismissDragY > dismissDistancePx) onDismiss() } zoom = next } } ) { AsyncImage( model = imageUrl, contentDescription = null, contentScale = ContentScale.Fit, onSuccess = { intrinsic = it.painter.intrinsicSize.width to it.painter.intrinsicSize.height }, modifier = Modifier .fillMaxSize() .graphicsLayer { scaleX = zoom.scale scaleY = zoom.scale translationX = zoom.offsetX translationY = zoom.offsetY } ) IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp)) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) } } } }