1 /*
<lambda>null2 * Copyright 2021 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.compose.foundation.gestures
18
19 import androidx.compose.animation.core.AnimationSpec
20 import androidx.compose.animation.core.AnimationState
21 import androidx.compose.animation.core.AnimationVector
22 import androidx.compose.animation.core.AnimationVector1D
23 import androidx.compose.animation.core.AnimationVector2D
24 import androidx.compose.animation.core.AnimationVector4D
25 import androidx.compose.animation.core.Spring
26 import androidx.compose.animation.core.SpringSpec
27 import androidx.compose.animation.core.TwoWayConverter
28 import androidx.compose.animation.core.VectorConverter
29 import androidx.compose.animation.core.VectorizedAnimationSpec
30 import androidx.compose.animation.core.VectorizedFiniteAnimationSpec
31 import androidx.compose.animation.core.animateTo
32 import androidx.compose.foundation.MutatePriority
33 import androidx.compose.foundation.MutatorMutex
34 import androidx.compose.foundation.internal.JvmDefaultWithCompatibility
35 import androidx.compose.foundation.internal.requirePrecondition
36 import androidx.compose.runtime.Composable
37 import androidx.compose.runtime.mutableStateOf
38 import androidx.compose.runtime.remember
39 import androidx.compose.runtime.rememberUpdatedState
40 import androidx.compose.ui.geometry.Offset
41 import kotlinx.coroutines.coroutineScope
42
43 /**
44 * State of [transformable]. Allows for a granular control of how different gesture transformations
45 * are consumed by the user as well as to write custom transformation methods using [transform]
46 * suspend function.
47 */
48 @JvmDefaultWithCompatibility
49 interface TransformableState {
50 /**
51 * Call this function to take control of transformations and gain the ability to send transform
52 * events via [TransformScope.transformBy]. All actions that change zoom, pan or rotation values
53 * must be performed within a [transform] block (even if they don't call any other methods on
54 * this object) in order to guarantee that mutual exclusion is enforced.
55 *
56 * If [transform] is called from elsewhere with the [transformPriority] higher or equal to
57 * ongoing transform, ongoing transform will be canceled.
58 */
59 suspend fun transform(
60 transformPriority: MutatePriority = MutatePriority.Default,
61 block: suspend TransformScope.() -> Unit
62 )
63
64 /**
65 * Whether this [TransformableState] is currently transforming by gesture or programmatically or
66 * not.
67 */
68 val isTransformInProgress: Boolean
69 }
70
71 /** Scope used for suspending transformation operations */
72 @JvmDefaultWithCompatibility
73 interface TransformScope {
74 /**
75 * Attempts to transform by [zoomChange] in relative multiplied value, by [panChange] in pixels
76 * and by [rotationChange] in degrees.
77 *
78 * @param zoomChange scale factor multiplier change for zoom
79 * @param panChange panning offset change, in [Offset] pixels
80 * @param rotationChange change of the rotation in degrees
81 */
transformBynull82 fun transformBy(
83 zoomChange: Float = 1f,
84 panChange: Offset = Offset.Zero,
85 rotationChange: Float = 0f
86 )
87 }
88
89 /**
90 * Default implementation of [TransformableState] interface that contains necessary information
91 * about the ongoing transformations and provides smooth transformation capabilities.
92 *
93 * This is the simplest way to set up a [transformable] modifier. When constructing this
94 * [TransformableState], you must provide a [onTransformation] lambda, which will be invoked
95 * whenever pan, zoom or rotation happens (by gesture input or any [TransformableState.transform]
96 * call) with the deltas from the previous event.
97 *
98 * @param onTransformation callback invoked when transformation occurs. The callback receives the
99 * change from the previous event. It's relative scale multiplier for zoom, [Offset] in pixels for
100 * pan and degrees for rotation. Callers should update their state in this lambda.
101 */
102 fun TransformableState(
103 onTransformation: (zoomChange: Float, panChange: Offset, rotationChange: Float) -> Unit
104 ): TransformableState = DefaultTransformableState(onTransformation)
105
106 /**
107 * Create and remember default implementation of [TransformableState] interface that contains
108 * necessary information about the ongoing transformations and provides smooth transformation
109 * capabilities.
110 *
111 * This is the simplest way to set up a [transformable] modifier. When constructing this
112 * [TransformableState], you must provide a [onTransformation] lambda, which will be invoked
113 * whenever pan, zoom or rotation happens (by gesture input or any [TransformableState.transform]
114 * call) with the deltas from the previous event.
115 *
116 * @param onTransformation callback invoked when transformation occurs. The callback receives the
117 * change from the previous event. It's relative scale multiplier for zoom, [Offset] in pixels for
118 * pan and degrees for rotation. Callers should update their state in this lambda.
119 */
120 @Composable
121 fun rememberTransformableState(
122 onTransformation: (zoomChange: Float, panChange: Offset, rotationChange: Float) -> Unit
123 ): TransformableState {
124 val lambdaState = rememberUpdatedState(onTransformation)
125 return remember { TransformableState { z, p, r -> lambdaState.value.invoke(z, p, r) } }
126 }
127
128 /**
129 * Animate zoom by a ratio of [zoomFactor] over the current size and suspend until its finished.
130 *
131 * @param zoomFactor ratio over the current size by which to zoom. For example, if [zoomFactor] is
132 * `3f`, zoom will be increased 3 fold from the current value.
133 * @param animationSpec [AnimationSpec] to be used for animation
134 */
animateZoomBynull135 suspend fun TransformableState.animateZoomBy(
136 zoomFactor: Float,
137 animationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow)
138 ) {
139 requirePrecondition(zoomFactor > 0) { "zoom value should be greater than 0" }
140 var previous = 1f
141 transform {
142 AnimationState(initialValue = previous).animateTo(zoomFactor, animationSpec) {
143 val scaleFactor = if (previous == 0f) 1f else this.value / previous
144 transformBy(zoomChange = scaleFactor)
145 previous = this.value
146 }
147 }
148 }
149
150 /**
151 * Animate rotate by a ratio of [degrees] clockwise and suspend until its finished.
152 *
153 * @param degrees the degrees by which to rotate clockwise
154 * @param animationSpec [AnimationSpec] to be used for animation
155 */
animateRotateBynull156 suspend fun TransformableState.animateRotateBy(
157 degrees: Float,
158 animationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow)
159 ) {
160 var previous = 0f
161 transform {
162 AnimationState(initialValue = previous).animateTo(degrees, animationSpec) {
163 val delta = this.value - previous
164 transformBy(rotationChange = delta)
165 previous = this.value
166 }
167 }
168 }
169
170 /**
171 * Animate pan by [offset] Offset in pixels and suspend until its finished
172 *
173 * @param offset offset to pan, in pixels
174 * @param animationSpec [AnimationSpec] to be used for pan animation
175 */
animatePanBynull176 suspend fun TransformableState.animatePanBy(
177 offset: Offset,
178 animationSpec: AnimationSpec<Offset> = SpringSpec(stiffness = Spring.StiffnessLow)
179 ) {
180 var previous = Offset.Zero
181 transform {
182 AnimationState(typeConverter = Offset.VectorConverter, initialValue = previous).animateTo(
183 offset,
184 animationSpec
185 ) {
186 val delta = this.value - previous
187 transformBy(panChange = delta)
188 previous = this.value
189 }
190 }
191 }
192
193 /**
194 * Animate zoom, pan, and rotation simultaneously and suspend until the animation is finished.
195 *
196 * Zoom is animated by a ratio of [zoomFactor] over the current size. Pan is animated by [panOffset]
197 * in pixels. Rotation is animated by the value of [rotationDegrees] clockwise. Any of these
198 * parameters can be set to a no-op value that will result in no animation of that parameter. The
199 * no-op values are the following: `1f` for [zoomFactor], `Offset.Zero` for [panOffset], and `0f`
200 * for [rotationDegrees].
201 *
202 * @sample androidx.compose.foundation.samples.TransformableAnimateBySample
203 * @param zoomFactor ratio over the current size by which to zoom. For example, if [zoomFactor] is
204 * `3f`, zoom will be increased 3 fold from the current value.
205 * @param panOffset offset to pan, in pixels
206 * @param rotationDegrees the degrees by which to rotate clockwise
207 * @param zoomAnimationSpec [AnimationSpec] to be used for animating zoom
208 * @param panAnimationSpec [AnimationSpec] to be used for animating offset
209 * @param rotationAnimationSpec [AnimationSpec] to be used for animating rotation
210 */
animateBynull211 suspend fun TransformableState.animateBy(
212 zoomFactor: Float,
213 panOffset: Offset,
214 rotationDegrees: Float,
215 zoomAnimationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow),
216 panAnimationSpec: AnimationSpec<Offset> = SpringSpec(stiffness = Spring.StiffnessLow),
217 rotationAnimationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow)
218 ) {
219 requirePrecondition(zoomFactor > 0) { "zoom value should be greater than 0" }
220 var previousState = AnimationData(zoom = 1f, offset = Offset.Zero, degrees = 0f)
221 val targetState = AnimationData(zoomFactor, panOffset, rotationDegrees)
222 val animationSpec =
223 DelegatingAnimationSpec(zoomAnimationSpec, panAnimationSpec, rotationAnimationSpec)
224 transform {
225 AnimationState(
226 typeConverter = AnimationDataConverter,
227 initialValue = previousState,
228 initialVelocity = ZeroAnimationVelocity
229 )
230 .animateTo(targetState, animationSpec) {
231 transformBy(
232 zoomChange =
233 if (previousState.zoom == 0f) 1f else value.zoom / previousState.zoom,
234 rotationChange = value.degrees - previousState.degrees,
235 panChange = value.offset - previousState.offset
236 )
237 previousState = value
238 }
239 }
240 }
241
242 private val ZeroAnimationVelocity = AnimationData(zoom = 0f, offset = Offset.Zero, degrees = 0f)
243
244 private class DelegatingAnimationSpec(
245 private val zoomAnimationSpec: AnimationSpec<Float>,
246 private val offsetAnimationSpec: AnimationSpec<Offset>,
247 private val rotationAnimationSpec: AnimationSpec<Float>
248 ) : AnimationSpec<AnimationData> {
vectorizenull249 override fun <V : AnimationVector> vectorize(
250 converter: TwoWayConverter<AnimationData, V>
251 ): VectorizedAnimationSpec<V> {
252 val vectorizedZoomAnimationSpec = zoomAnimationSpec.vectorize(Float.VectorConverter)
253 val vectorizedOffsetAnimationSpec = offsetAnimationSpec.vectorize(Offset.VectorConverter)
254 val vectorizedRotationAnimationSpec = rotationAnimationSpec.vectorize(Float.VectorConverter)
255 return object : VectorizedFiniteAnimationSpec<V> {
256 override fun getDurationNanos(
257 initialValue: V,
258 targetValue: V,
259 initialVelocity: V
260 ): Long {
261 val initialAnimationData = converter.convertFromVector(initialValue)
262 val targetAnimationData = converter.convertFromVector(targetValue)
263 val initialVelocityAnimationData = converter.convertFromVector(initialVelocity)
264
265 return maxOf(
266 vectorizedZoomAnimationSpec.getDurationNanos(
267 initialAnimationData.zoomVector(),
268 targetAnimationData.zoomVector(),
269 initialVelocityAnimationData.zoomVector()
270 ),
271 vectorizedOffsetAnimationSpec.getDurationNanos(
272 initialAnimationData.offsetVector(),
273 targetAnimationData.offsetVector(),
274 initialVelocityAnimationData.offsetVector()
275 ),
276 vectorizedRotationAnimationSpec.getDurationNanos(
277 initialAnimationData.degreesVector(),
278 targetAnimationData.degreesVector(),
279 initialVelocityAnimationData.degreesVector()
280 )
281 )
282 }
283
284 override fun getVelocityFromNanos(
285 playTimeNanos: Long,
286 initialValue: V,
287 targetValue: V,
288 initialVelocity: V
289 ): V {
290 val initialAnimationData = converter.convertFromVector(initialValue)
291 val targetAnimationData = converter.convertFromVector(targetValue)
292 val initialVelocityAnimationData = converter.convertFromVector(initialVelocity)
293
294 val zoomVelocity =
295 vectorizedZoomAnimationSpec.getVelocityFromNanos(
296 playTimeNanos,
297 initialAnimationData.zoomVector(),
298 targetAnimationData.zoomVector(),
299 initialVelocityAnimationData.zoomVector()
300 )
301 val offsetVelocity =
302 vectorizedOffsetAnimationSpec.getVelocityFromNanos(
303 playTimeNanos,
304 initialAnimationData.offsetVector(),
305 targetAnimationData.offsetVector(),
306 initialVelocityAnimationData.offsetVector()
307 )
308 val rotationVelocity =
309 vectorizedRotationAnimationSpec.getVelocityFromNanos(
310 playTimeNanos,
311 initialAnimationData.degreesVector(),
312 targetAnimationData.degreesVector(),
313 initialVelocityAnimationData.degreesVector()
314 )
315
316 return packToAnimationVector(zoomVelocity, offsetVelocity, rotationVelocity)
317 }
318
319 override fun getValueFromNanos(
320 playTimeNanos: Long,
321 initialValue: V,
322 targetValue: V,
323 initialVelocity: V
324 ): V {
325 val initialAnimationData = converter.convertFromVector(initialValue)
326 val targetAnimationData = converter.convertFromVector(targetValue)
327 val initialVelocityAnimationData = converter.convertFromVector(initialVelocity)
328
329 val zoomValue =
330 vectorizedZoomAnimationSpec.getValueFromNanos(
331 playTimeNanos,
332 initialAnimationData.zoomVector(),
333 targetAnimationData.zoomVector(),
334 initialVelocityAnimationData.zoomVector()
335 )
336 val offsetValue =
337 vectorizedOffsetAnimationSpec.getValueFromNanos(
338 playTimeNanos,
339 initialAnimationData.offsetVector(),
340 targetAnimationData.offsetVector(),
341 initialVelocityAnimationData.offsetVector()
342 )
343 val rotationValue =
344 vectorizedRotationAnimationSpec.getValueFromNanos(
345 playTimeNanos,
346 initialAnimationData.degreesVector(),
347 targetAnimationData.degreesVector(),
348 initialVelocityAnimationData.degreesVector()
349 )
350
351 return packToAnimationVector(zoomValue, offsetValue, rotationValue)
352 }
353
354 private fun AnimationData.zoomVector() =
355 Float.VectorConverter.convertToVector(this.zoom)
356
357 private fun AnimationData.offsetVector() =
358 Offset.VectorConverter.convertToVector(Offset(this.offset.x, this.offset.y))
359
360 private fun AnimationData.degreesVector() =
361 Float.VectorConverter.convertToVector(this.degrees)
362
363 private fun packToAnimationVector(
364 zoom: AnimationVector1D,
365 offset: AnimationVector2D,
366 rotation: AnimationVector1D
367 ): V =
368 converter.convertToVector(
369 AnimationData(zoom.value, Offset(offset.v1, offset.v2), rotation.value)
370 )
371 }
372 }
373 }
374
375 private object AnimationDataConverter : TwoWayConverter<AnimationData, AnimationVector4D> {
376 override val convertToVector: (AnimationData) -> AnimationVector4D
<lambda>null377 get() = { AnimationVector4D(it.zoom, it.offset.x, it.offset.y, it.degrees) }
378
379 override val convertFromVector: (AnimationVector4D) -> AnimationData
<lambda>null380 get() = { AnimationData(zoom = it.v1, offset = Offset(it.v2, it.v3), degrees = it.v4) }
381 }
382
383 private data class AnimationData(val zoom: Float, val offset: Offset, val degrees: Float)
384
385 /**
386 * Zoom without animation by a ratio of [zoomFactor] over the current size and suspend until it's
387 * set.
388 *
389 * @param zoomFactor ratio over the current size by which to zoom
390 */
<lambda>null391 suspend fun TransformableState.zoomBy(zoomFactor: Float) = transform {
392 transformBy(zoomFactor, Offset.Zero, 0f)
393 }
394
395 /**
396 * Rotate without animation by a [degrees] degrees and suspend until it's set.
397 *
398 * @param degrees degrees by which to rotate
399 */
<lambda>null400 suspend fun TransformableState.rotateBy(degrees: Float) = transform {
401 transformBy(1f, Offset.Zero, degrees)
402 }
403
404 /**
405 * Pan without animation by a [offset] Offset in pixels and suspend until it's set.
406 *
407 * @param offset offset in pixels by which to pan
408 */
<lambda>null409 suspend fun TransformableState.panBy(offset: Offset) = transform { transformBy(1f, offset, 0f) }
410
411 /**
412 * Stop and suspend until any ongoing [TransformableState.transform] with priority
413 * [terminationPriority] or lower is terminated.
414 *
415 * @param terminationPriority transformation that runs with this priority or lower will be stopped
416 */
stopTransformationnull417 suspend fun TransformableState.stopTransformation(
418 terminationPriority: MutatePriority = MutatePriority.Default
419 ) {
420 this.transform(terminationPriority) {
421 // do nothing, just lock the mutex so other scroll actors are cancelled
422 }
423 }
424
425 private class DefaultTransformableState(
426 val onTransformation: (zoomChange: Float, panChange: Offset, rotationChange: Float) -> Unit
427 ) : TransformableState {
428
429 private val transformScope: TransformScope =
430 object : TransformScope {
transformBynull431 override fun transformBy(zoomChange: Float, panChange: Offset, rotationChange: Float) =
432 onTransformation(zoomChange, panChange, rotationChange)
433 }
434
435 private val transformMutex = MutatorMutex()
436
437 private val isTransformingState = mutableStateOf(false)
438
439 override suspend fun transform(
440 transformPriority: MutatePriority,
441 block: suspend TransformScope.() -> Unit
442 ): Unit = coroutineScope {
443 transformMutex.mutateWith(transformScope, transformPriority) {
444 isTransformingState.value = true
445 try {
446 block()
447 } finally {
448 isTransformingState.value = false
449 }
450 }
451 }
452
453 override val isTransformInProgress: Boolean
454 get() = isTransformingState.value
455 }
456