1 /*
<lambda>null2  * Copyright 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.graphics.shapes.testcompose
18 
19 import androidx.compose.foundation.gestures.detectTransformGestures
20 import androidx.compose.foundation.layout.Box
21 import androidx.compose.foundation.layout.BoxScope
22 import androidx.compose.foundation.layout.fillMaxSize
23 import androidx.compose.material.icons.Icons
24 import androidx.compose.material.icons.filled.Pinch
25 import androidx.compose.material3.Button
26 import androidx.compose.material3.Icon
27 import androidx.compose.material3.Text
28 import androidx.compose.runtime.Composable
29 import androidx.compose.runtime.MutableState
30 import androidx.compose.runtime.mutableFloatStateOf
31 import androidx.compose.runtime.mutableStateOf
32 import androidx.compose.runtime.remember
33 import androidx.compose.ui.Modifier
34 import androidx.compose.ui.draw.alpha
35 import androidx.compose.ui.geometry.Offset
36 import androidx.compose.ui.graphics.TransformOrigin
37 import androidx.compose.ui.graphics.graphicsLayer
38 import androidx.compose.ui.input.pointer.pointerInput
39 import androidx.compose.ui.text.style.TextAlign
40 import kotlin.math.PI
41 import kotlin.math.cos
42 import kotlin.math.sin
43 
44 data class PanZoomRotateBoxState(
45     var zoom: MutableState<Float> = mutableFloatStateOf(START_ZOOM),
46     var offset: MutableState<Offset> = mutableStateOf(START_OFFSET),
47     var angle: MutableState<Float> = mutableFloatStateOf(START_ANGLE),
48 ) {
49     fun reset() {
50         zoom.value = START_ZOOM
51         offset.value = START_OFFSET
52         angle.value = START_ANGLE
53     }
54 
55     fun hasChanged() =
56         zoom.value != START_ZOOM || offset.value != START_OFFSET || angle.value != START_ANGLE
57 
58     fun mapOut(p: Offset): Offset {
59         return ((p.rotate(angle.value.toRadians()) - offset.value) * zoom.value)
60     }
61 }
62 
63 // Wrap the content in a box that adds a gesture detector and a transform layer.
64 // This lets the content be scaled/rotated/panned.
65 // Small note that AFAIK, the center of the gestures on the emulator are at the center of the
66 // screen, so for this to work on emulator the center of the screen needs to be inside this
67 // component.
68 @Composable
PanZoomRotateBoxnull69 fun PanZoomRotateBox(
70     modifier: Modifier = Modifier,
71     state: PanZoomRotateBoxState = remember { PanZoomRotateBoxState() },
72     allowRotation: Boolean = true,
73     allowZoom: Boolean = true,
74     allowPan: Boolean = true,
75     showInteraction: Boolean = true,
76     content: @Composable BoxScope.() -> Unit,
77 ) {
<lambda>null78     with(state) {
79         Box(modifier = modifier) {
80             Box(
81                 Modifier.fillMaxSize()
82                     .pointerInput(Unit) {
83                         detectTransformGestures(
84                             onGesture = { centroid, pan, gestureZoom, gestureRotate ->
85                                 val actualRotation = if (allowRotation) gestureRotate else 0f
86                                 val oldScale = zoom.value
87                                 val newScale = zoom.value * if (allowZoom) gestureZoom else 1f
88 
89                                 // For natural zooming and rotating, the centroid of the gesture
90                                 // should
91                                 // be the fixed point where zooming and rotating occurs.
92                                 // We compute where the centroid was (in the pre-transformed
93                                 // coordinate
94                                 // space), and then compute where it will be after this delta.
95                                 // We then compute what the new offset should be to keep the
96                                 // centroid
97                                 // visually stationary for rotating and zooming, and also apply the
98                                 // pan.
99                                 offset.value =
100                                     (offset.value + centroid / oldScale).rotate(
101                                         actualRotation.toRadians()
102                                     ) -
103                                         (centroid / newScale +
104                                             (if (allowPan) pan else Offset.Zero) / oldScale)
105                                 zoom.value = newScale
106                                 angle.value += actualRotation
107                             }
108                         )
109                     }
110                     .graphicsLayer {
111                         translationX = -offset.value.x * zoom.value
112                         translationY = -offset.value.y * zoom.value
113                         scaleX = zoom.value
114                         scaleY = zoom.value
115                         rotationZ = angle.value
116                         transformOrigin = TransformOrigin(0f, 0f)
117                     },
118                 content = content,
119             )
120             if (showInteraction && hasChanged()) {
121                 Button(onClick = { state.reset() }) {
122                     Text("Reset View", textAlign = TextAlign.Center)
123                 }
124             } else if (showInteraction) {
125                 Icon(Icons.Default.Pinch, "Zoom in", Modifier.alpha(0.2f))
126             }
127         }
128     }
129 }
130 
toRadiansnull131 internal fun Float.toRadians() = this * PI.toFloat() / 180f
132 
133 private fun Offset.rotate90() = Offset(-y, x)
134 
135 internal fun directionVector(angleRadians: Float) = Offset(cos(angleRadians), sin(angleRadians))
136 
137 private fun Offset.rotate(angleRadians: Float): Offset {
138     val vec = directionVector(angleRadians)
139     return vec * x + vec.rotate90() * y
140 }
141 
142 private const val START_ZOOM = 1f
143 private const val START_ANGLE = 0f
144 private val START_OFFSET = Offset.Zero
145