1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
|
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<Float, Float> {
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<Float, Float>,
intrinsicSize: Pair<Float, Float>,
pan: Pair<Float, Float>,
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<Float, Float>,
intrinsicSize: Pair<Float, Float>
): 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)
}
}
}
}
|