11package com.darkrockstudios.app.securecamera.viewphoto
22
3+ import androidx.compose.animation.core.animateOffsetAsState
34import androidx.compose.foundation.Image
45import androidx.compose.foundation.gestures.detectTapGestures
56import androidx.compose.foundation.gestures.detectTransformGestures
67import androidx.compose.runtime.*
78import androidx.compose.ui.Modifier
89import androidx.compose.ui.draw.clipToBounds
910import androidx.compose.ui.geometry.Offset
11+ import androidx.compose.ui.geometry.Size
1012import androidx.compose.ui.graphics.ImageBitmap
1113import androidx.compose.ui.graphics.graphicsLayer
14+ import androidx.compose.ui.input.pointer.PointerEventType
1215import androidx.compose.ui.input.pointer.pointerInput
1316import androidx.compose.ui.layout.ContentScale
17+ import androidx.compose.ui.layout.onSizeChanged
1418
1519@Stable
1620class ImageViewerState internal constructor(
17- scale : Float = 1f ,
18- offset : Offset = Offset .Zero ,
19- val minScale : Float = 1f ,
20- val maxScale : Float = 5f ,
21+ scale : Float = 1f ,
22+ offset : Offset = Offset .Zero ,
23+ val minScale : Float = 1f ,
24+ val maxScale : Float = 5f ,
2125) {
22- var scale by mutableStateOf(scale)
23- var offset by mutableStateOf(offset)
26+ var scale by mutableStateOf(scale)
27+ var offset by mutableStateOf(offset)
28+ var isGestureInProgress by mutableStateOf(false )
29+ var containerSize by mutableStateOf(Size .Zero )
30+ var imageSize by mutableStateOf(Size .Zero )
2431
25- fun reset () {
26- scale = 1f
27- offset = Offset .Zero
28- }
32+ fun reset () {
33+ scale = 1f
34+ offset = Offset .Zero
35+ }
36+
37+ fun calculateConstrainedOffset (currentOffset : Offset , scale : Float ): Offset {
38+ if (scale <= 1f ) {
39+ return Offset .Zero
40+ }
41+
42+ val scaledImageWidth = imageSize.width * scale
43+ val scaledImageHeight = imageSize.height * scale
44+
45+ val maxX = maxOf(0f , scaledImageWidth - containerSize.width)
46+ val maxY = maxOf(0f , scaledImageHeight - containerSize.height)
47+
48+ // This allows panning the image so that its edge aligns with the container edge
49+ return Offset (
50+ x = currentOffset.x.coerceIn(- maxX / 2 , maxX / 2 ),
51+ y = currentOffset.y.coerceIn(- maxY / 2 , maxY / 2 )
52+ )
53+ }
2954}
3055
3156/* *
3257 * Call this from your Composable to keep the state across recompositions.
3358 */
3459@Composable
3560fun rememberImageViewerState (
36- initialScale : Float = 1f,
37- initialOffset : Offset = Offset .Zero ,
38- minScale : Float = 1f,
39- maxScale : Float = 5f,
61+ initialScale : Float = 1f,
62+ initialOffset : Offset = Offset .Zero ,
63+ minScale : Float = 1f,
64+ maxScale : Float = 5f,
4065): ImageViewerState = remember {
41- ImageViewerState (
42- scale = initialScale,
43- offset = initialOffset,
44- minScale = minScale,
45- maxScale = maxScale,
46- )
66+ ImageViewerState (
67+ scale = initialScale,
68+ offset = initialOffset,
69+ minScale = minScale,
70+ maxScale = maxScale,
71+ )
4772}
4873
4974/* *
5075 * A zoomable / pannable image-viewer composable.
5176 *
52- * @param bitmap The photo to display.
53- * @param state State returned from [rememberImageViewerState].
54- * @param modifier Extra modifiers (size, background, etc.).
77+ * @param bitmap The photo to display.
78+ * @param state State returned from [rememberImageViewerState].
79+ * @param modifier Extra modifiers (size, background, etc.).
5580 */
5681@Composable
5782fun ImageViewer (
58- bitmap : ImageBitmap ,
59- state : ImageViewerState ,
60- modifier : Modifier = Modifier ,
83+ bitmap : ImageBitmap ,
84+ state : ImageViewerState ,
85+ modifier : Modifier = Modifier ,
6186) {
62- Image (
63- bitmap = bitmap,
64- contentDescription = null ,
65- contentScale = ContentScale .Fit ,
66- modifier = modifier
67- .clipToBounds()
68- .pointerInput(state) {
69- detectTransformGestures { _, pan, zoom, _ ->
70- // ----- Zoom -----
71- val newScale = (state.scale * zoom).coerceIn(state.minScale, state.maxScale)
72-
73- // ----- Pan (don’t forget current scale) -----
74- val newOffset = state.offset + pan
75-
76- state.scale = newScale
77- state.offset = newOffset
78- }
79- }
80- .pointerInput(Unit ) {
81- detectTapGestures(
82- onDoubleTap = { state.reset() }
83- )
84- }
85- .graphicsLayer {
86- scaleX = state.scale
87- scaleY = state.scale
88- translationX = state.offset.x
89- translationY = state.offset.y
90- }
91- )
92- }
87+ LaunchedEffect (bitmap) {
88+ state.imageSize = Size (bitmap.width.toFloat(), bitmap.height.toFloat())
89+ }
90+
91+ var isGestureInProgress by remember { mutableStateOf(false ) }
92+
93+ // Animate offset when gesture ends
94+ val animatedOffset by animateOffsetAsState(
95+ targetValue = if (isGestureInProgress) {
96+ println (" Animating, gesture in progress" )
97+ state.offset
98+ } else {
99+ println (" Animating, gesture NOT in progress" )
100+ state.calculateConstrainedOffset(state.offset, state.scale)
101+ }
102+ ) {
103+ state.offset = it
104+ }
105+
106+ state.isGestureInProgress = isGestureInProgress
107+
108+ Image (
109+ bitmap = bitmap,
110+ contentDescription = null ,
111+ contentScale = ContentScale .Fit ,
112+ modifier = modifier
113+ .clipToBounds()
114+ .onSizeChanged { size ->
115+ state.containerSize = Size (size.width.toFloat(), size.height.toFloat())
116+ }
117+ // Track touch events to detect when gesture starts and ends
118+ .pointerInput(Unit ) {
119+ awaitPointerEventScope {
120+ while (true ) {
121+ val event = awaitPointerEvent()
122+ when (event.type) {
123+ PointerEventType .Press -> {
124+ println (" Press" )
125+ isGestureInProgress = true
126+ }
127+
128+ PointerEventType .Release -> {
129+ println (" Release" )
130+ isGestureInProgress = false
131+ }
132+ }
133+ }
134+ }
135+ }
136+ // Handle transform gestures (pan and zoom)
137+ .pointerInput(state) {
138+ detectTransformGestures { _, pan, zoom, _ ->
139+ val newScale = (state.scale * zoom).coerceIn(state.minScale, state.maxScale)
140+
141+ state.scale = newScale
142+ state.offset = state.offset + pan
143+ }
144+ }
145+ .pointerInput(Unit ) {
146+ detectTapGestures(
147+ onDoubleTap = { state.reset() }
148+ )
149+ }
150+ .graphicsLayer {
151+ scaleX = state.scale
152+ scaleY = state.scale
153+
154+ translationX = if (isGestureInProgress) state.offset.x else animatedOffset.x
155+ translationY = if (isGestureInProgress) state.offset.y else animatedOffset.y
156+ }
157+ )
158+ }
0 commit comments