@@ -4,8 +4,7 @@ import androidx.compose.animation.core.Animatable
44import androidx.compose.animation.core.VectorConverter
55import androidx.compose.animation.core.spring
66import androidx.compose.foundation.gestures.detectTapGestures
7- import androidx.compose.runtime.remember
8- import androidx.compose.runtime.rememberCoroutineScope
7+ import androidx.compose.runtime.*
98import androidx.compose.ui.Modifier
109import androidx.compose.ui.composed
1110import androidx.compose.ui.draw.clipToBounds
@@ -19,12 +18,24 @@ import kotlinx.coroutines.launch
1918 * Modifier that zooms in or out of Composable set to.
2019 * @param keys are used for [Modifier.pointerInput] to restart closure when any keys assigned
2120 * change
21+ * @param initialZoom initial value of zoom
22+ * @param minZoom minimum zoom value
23+ * @param maxZoom maximum zoom value
2224 * @param clip when set to true clips to parent bounds. Anything outside parent bounds is not
2325 * drawn
2426 * @param limitPan limits pan to bounds of parent Composable. Using this flag prevents creating
2527 * empty space on sides or edges of parent.
26- * @param minZoom minimum zoom value
27- * @param maxZoom maximum zoom value
28+ * @param consume flag to prevent other gestures such as scroll, drag or transform to get
29+ * event propagations
30+ * @param zoomEnabled when set to true zoom is enabled
31+ * @param panEnabled when set to true pan is enabled
32+ * @param rotationEnabled when set to true rotation is enabled
33+ * @param onGestureStart callback to to notify gesture has started and return current ZoomData
34+ * of this modifier
35+ * @param onGesture callback to notify about ongoing gesture and return current ZoomData
36+ * of this modifier
37+ * @param onGestureEnd callback to notify that gesture finished and return current ZoomData
38+ * of this modifier
2839 */
2940fun Modifier.zoom (
3041 vararg keys : Any? ,
@@ -34,9 +45,12 @@ fun Modifier.zoom(
3445 clip : Boolean = true,
3546 limitPan : Boolean = true,
3647 consume : Boolean = true,
37- onGestureStart : () -> Unit = {},
38- onChange : (Zoom ) -> Unit = {},
39- onGestureEnd : () -> Unit = {},
48+ zoomEnabled : Boolean = true,
49+ panEnabled : Boolean = true,
50+ rotationEnabled : Boolean = false,
51+ onGestureStart : (ZoomData ) -> Unit = {},
52+ onGesture : (ZoomData ) -> Unit = {},
53+ onGestureEnd : (ZoomData ) -> Unit = {},
4054) = composed(
4155 factory = {
4256
@@ -45,90 +59,159 @@ fun Modifier.zoom(
4559 val zoomMax = maxZoom.coerceAtLeast(1f )
4660 val zoomInitial = initialZoom.coerceIn(zoomMin, zoomMax)
4761
48-
4962 require(zoomMax >= zoomMin)
5063
51- val animatableOffset = remember {
64+ val animatablePan = remember {
5265 Animatable (Offset .Zero , Offset .VectorConverter )
5366 }
5467 val animatableZoom = remember { Animatable (zoomInitial) }
68+ val animatableRotation = remember { Animatable (0f ) }
5569
56- this .then(
57- (if (clip) Modifier .clipToBounds() else Modifier )
58- .graphicsLayer {
59- val zoom = animatableZoom.value
60- val translationX = animatableOffset.value.x
61- val translationY = animatableOffset.value.y
62- this .translationX = translationX
63- this .translationY = translationY
64- scaleX = zoom
65- scaleY = zoom
66-
67- onChange(
68- Zoom (
69- zoom = zoom,
70- translationX = translationX,
71- translationY
70+ var zoomLevel by remember { mutableStateOf(ZoomLevel .Min ) }
71+
72+ val boundPan = limitPan && ! rotationEnabled
73+ val clipToBounds = (clip || boundPan)
74+
75+
76+ val transformModifier = Modifier .pointerInput(keys) {
77+ detectTransformGestures(
78+ consume = consume,
79+ onGestureStart = {
80+ onGestureStart(
81+ ZoomData (
82+ zoom = animatableZoom.value,
83+ pan = animatablePan.value,
84+ rotation = animatableRotation.value
7285 )
7386 )
74- }
75- .pointerInput(keys) {
76- detectTransformGestures(
77- consume = consume,
78- onGestureStart = {
79- onGestureStart()
80- },
81- onGestureEnd = {
82- onGestureEnd()
83- },
84- onGesture = { _,
85- gesturePan: Offset ,
86- gestureZoom: Float ,
87- _,
88- _,
89- _ ->
90-
91- println (" 🔥 PointerInput size: $size " )
92-
93- var zoom = animatableZoom.value
94- val offset = animatableOffset.value
95-
96- zoom = (zoom * gestureZoom).coerceIn(zoomMin, zoomMax)
97- val newOffset = offset + gesturePan.times(zoom)
98-
99- val maxX = (size.width * (zoom - 1 ) / 2f ).coerceAtLeast(0f )
100- val maxY = (size.height * (zoom - 1 ) / 2f ).coerceAtLeast(0f )
101-
102- coroutineScope.launch {
103- animatableZoom.snapTo(zoom)
104- }
105- coroutineScope.launch {
106- animatableOffset.snapTo(
107- if (limitPan) {
108- Offset (
109- newOffset.x.coerceIn(- maxX, maxX),
110- newOffset.y.coerceIn(- maxY, maxY)
111- )
112- } else {
113- newOffset
114- }
115- )
116- }
87+ },
88+ onGestureEnd = {
89+ onGestureEnd(
90+ ZoomData (
91+ zoom = animatableZoom.value,
92+ pan = animatablePan.value,
93+ rotation = animatableRotation.value
94+ )
95+ )
96+ },
97+ onGesture = { centroid, gesturePan, gestureZoom, gestureRotate,
98+ _,
99+ _ ->
100+
101+ var zoom = animatableZoom.value
102+ val offset = animatablePan.value
103+ val rotation = if (rotationEnabled) {
104+ animatableRotation.value + gestureRotate
105+ } else {
106+ 0f
107+ }
108+
109+ if (zoomEnabled) {
110+ zoom = (zoom * gestureZoom).coerceIn(zoomMin, zoomMax)
111+ }
112+
113+ val newOffset = offset + gesturePan.times(zoom)
114+
115+ val maxX = (size.width * (zoom - 1 ) / 2f )
116+ .coerceAtLeast(0f )
117+ val maxY = (size.height * (zoom - 1 ) / 2f )
118+ .coerceAtLeast(0f )
119+
120+
121+ if (zoomEnabled) {
122+ coroutineScope.launch {
123+ animatableZoom.snapTo(zoom)
124+ }
125+ }
126+
127+ if (panEnabled) {
128+ coroutineScope.launch {
129+ animatablePan.snapTo(
130+ if (boundPan) {
131+ Offset (
132+ newOffset.x.coerceIn(- maxX, maxX),
133+ newOffset.y.coerceIn(- maxY, maxY)
134+ )
135+ } else {
136+ newOffset
137+ }
138+ )
139+ }
140+ }
141+
142+ if (rotationEnabled) {
143+ coroutineScope.launch {
144+ animatableRotation.snapTo(rotation)
117145 }
146+ }
147+
148+ onGesture(
149+ ZoomData (
150+ zoom = animatableZoom.value,
151+ pan = animatablePan.value,
152+ rotation = animatableRotation.value
153+ )
118154 )
119155 }
120- .pointerInput(keys) {
121- detectTapGestures(
122- onDoubleTap = {
123- coroutineScope.launch {
124- animatableOffset.animateTo(Offset .Zero , spring())
125- }
126- coroutineScope.launch {
127- animatableZoom.animateTo(zoomInitial, spring())
128- }
129- }
156+ )
157+ }
158+
159+ val tapModifier = Modifier .pointerInput(keys) {
160+ detectTapGestures(
161+ onDoubleTap = {
162+
163+ val (newZoomLevel, newZoom) = calculateZoom(
164+ zoomLevel = zoomLevel,
165+ initial = zoomInitial,
166+ min = minZoom,
167+ max = maxZoom
130168 )
169+
170+ zoomLevel = newZoomLevel
171+
172+ coroutineScope.launch {
173+ animatablePan.animateTo(Offset .Zero , spring())
174+ }
175+ coroutineScope.launch {
176+ animatableZoom.animateTo(newZoom, spring())
177+ }
178+ coroutineScope.launch {
179+ animatableRotation.animateTo(0f , spring())
180+ }
131181 }
182+ )
183+ }
184+
185+ val graphicsModifier = Modifier .graphicsLayer {
186+ val zoom = animatableZoom.value
187+
188+ // Set zoom
189+ scaleX = zoom
190+ scaleY = zoom
191+
192+ // Set pan
193+ val translationX = animatablePan.value.x
194+ val translationY = animatablePan.value.y
195+ this .translationX = translationX
196+ this .translationY = translationY
197+
198+ // Set rotation
199+ rotationZ = animatableRotation.value
200+ // TransformOrigin(0f, 0f).also { transformOrigin = it }
201+ onGesture(
202+ ZoomData (
203+ zoom = animatableZoom.value,
204+ pan = animatablePan.value,
205+ )
206+ )
207+ }
208+
209+ this .then(
210+ (if (clipToBounds) Modifier .clipToBounds() else Modifier )
211+ .then(transformModifier)
212+ .then(tapModifier)
213+ .then(graphicsModifier)
214+
132215 )
133216
134217 },
@@ -153,6 +236,7 @@ fun Modifier.zoom(
153236 initialZoom : Float = 1f,
154237 minZoom : Float = 1f,
155238 maxZoom : Float = 5f,
239+ rotate : Boolean = false,
156240 clip : Boolean = true,
157241 limitPan : Boolean = true,
158242
@@ -163,8 +247,9 @@ fun Modifier.zoom(
163247 maxZoom = maxZoom,
164248 clip = clip,
165249 limitPan = limitPan,
250+ rotationEnabled = rotate,
166251 consume = true ,
167252 onGestureStart = {},
168253 onGestureEnd = {},
169- onChange = {}
254+ onGesture = {}
170255)
0 commit comments