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