-
Notifications
You must be signed in to change notification settings - Fork 28
/
ZoomableState.kt
554 lines (500 loc) · 20.2 KB
/
ZoomableState.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
package me.saket.telephoto.zoomable
import androidx.annotation.FloatRange
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.AnimationVector
import androidx.compose.animation.core.Spring.StiffnessMediumLow
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateDecay
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.spring
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.MutatePriority
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.ScaleFactor
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.util.lerp
import me.saket.telephoto.zoomable.ContentZoom.Companion.ZoomDeltaEpsilon
import me.saket.telephoto.zoomable.ZoomableContentLocation.SameAsLayoutBounds
import me.saket.telephoto.zoomable.internal.MutatePriorities
import me.saket.telephoto.zoomable.internal.TransformableState
import me.saket.telephoto.zoomable.internal.Zero
import me.saket.telephoto.zoomable.internal.ZoomableSavedState
import me.saket.telephoto.zoomable.internal.calculateTopLeftToOverlapWith
import me.saket.telephoto.zoomable.internal.div
import me.saket.telephoto.zoomable.internal.maxScale
import me.saket.telephoto.zoomable.internal.roundToIntSize
import me.saket.telephoto.zoomable.internal.times
import me.saket.telephoto.zoomable.internal.unaryMinus
import me.saket.telephoto.zoomable.internal.withZoomAndTranslate
import kotlin.math.abs
/**
* Create a [ZoomableState] that can be used with [Modifier.zoomable].
*
* @param zoomSpec See [ZoomSpec.maxZoomFactor] and [ZoomSpec.preventOverOrUnderZoom].
*
* @param autoApplyTransformations Determines whether the resulting scale and translation of pan and zoom
* gestures should be automatically applied to by [Modifier.zoomable] to its content. This can be disabled
* if your content prefers applying the transformations in a bespoke manner.
*/
@Composable
fun rememberZoomableState(
zoomSpec: ZoomSpec = ZoomSpec(),
autoApplyTransformations: Boolean = true,
): ZoomableState {
val state = rememberSaveable(saver = ZoomableState.Saver) {
ZoomableState(
autoApplyTransformations = autoApplyTransformations
)
}.also {
it.zoomSpec = zoomSpec
it.layoutDirection = LocalLayoutDirection.current
}
if (state.isReadyToInteract) {
LaunchedEffect(
state.contentLayoutSize,
state.contentAlignment,
state.contentScale,
state.layoutDirection,
state.rawTransformation == null,
) {
state.refreshContentTransformation()
}
}
return state
}
@Stable
class ZoomableState internal constructor(
initialTransformation: RawTransformation? = null,
autoApplyTransformations: Boolean = true,
) {
/**
* Transformations that should be applied to [Modifier.zoomable]'s content.
*
* See [ZoomableContentTransformation].
*/
val contentTransformation: ZoomableContentTransformation by derivedStateOf {
rawTransformation.let {
if (it != null) {
val scale = it.zoom.finalZoom()
val canContentBeShown = scale != ScaleFactor.Zero
ZoomableContentTransformation(
isSpecified = canContentBeShown,
contentSize = it.contentSize,
scale = scale,
offset = -it.offset * it.zoom,
)
} else {
ZoomableContentTransformation(
isSpecified = false,
contentSize = Size.Unspecified,
scale = ScaleFactor.Zero, // Effectively hide content until an initial zoom value is calculated.,
offset = Offset.Zero,
)
}
}
}
/**
* Determines whether the resulting scale and translation of pan and zoom gestures
* should be automatically applied to by [Modifier.zoomable] to its content. This can
* be disabled if your content prefers applying the transformations in a bespoke manner.
* */
var autoApplyTransformations: Boolean by mutableStateOf(autoApplyTransformations)
/**
* Single source of truth for your content's aspect ratio. If you're using `Modifier.zoomable()`
* with `Image()` or other composables that also accept [ContentScale], they should not be used
* to avoid any conflicts.
*
* A visual guide of the various scale values can be found
* [here](https://developer.android.com/jetpack/compose/graphics/images/customize#content-scale).
*/
var contentScale: ContentScale by mutableStateOf(ContentScale.Fit)
/**
* Alignment of the content.
*
* When the content is zoomed, it is scaled with respect to this alignment until it
* is large enough to fill all available space. After that, they're scaled uniformly.
* */
var contentAlignment: Alignment by mutableStateOf(Alignment.Center)
/**
* The content's current zoom as a fraction of its min and max allowed zoom factors.
*
* @return A value between 0 and 1, where 0 indicates that the content is fully zoomed out,
* 1 indicates that the content is fully zoomed in, and `null` indicates that an initial zoom
* value hasn't been calculated yet and the content is hidden. A `null` value could be safely
* treated the same as 0, but [Modifier.zoomable] leaves that decision up to you.
*/
@get:FloatRange(from = 0.0, to = 1.0)
val zoomFraction: Float? by derivedStateOf {
rawTransformation?.let {
val min = zoomSpec.range.minZoom(it.zoom.baseZoom)
val max = zoomSpec.range.maxZoom(it.zoom.baseZoom)
val current = it.zoom.finalZoom().maxScale.coerceIn(min, max)
when {
current == min && min == max -> 1f // Content can't zoom.
else -> ((current - min) / (max - min)).coerceIn(0f, 1f)
}
}
}
internal var rawTransformation: RawTransformation? by mutableStateOf(initialTransformation)
internal var zoomSpec by mutableStateOf(ZoomSpec())
internal var layoutDirection: LayoutDirection by mutableStateOf(LayoutDirection.Ltr)
/**
* Raw size of the zoomable content without any scaling applied.
* Used to ensure that the content does not pan/zoom outside its limits.
*/
private var unscaledContentLocation: ZoomableContentLocation by mutableStateOf(SameAsLayoutBounds)
/**
* Layout bounds of the zoomable content in the UI hierarchy, without any scaling applied.
*/
internal var contentLayoutSize by mutableStateOf(Size.Zero)
private val unscaledContentBounds by derivedStateOf {
unscaledContentLocation.location(
layoutSize = contentLayoutSize,
direction = layoutDirection
)
}
/** Whether sufficient information is available about the content to start listening to pan & zoom gestures. */
internal val isReadyToInteract: Boolean by derivedStateOf {
unscaledContentLocation.isSpecified
&& contentLayoutSize.minDimension != 0f // Protects against division by zero errors.
}
@Suppress("NAME_SHADOWING")
internal val transformableState = TransformableState { zoomDelta, panDelta, _, centroid ->
// This is the minimum scale needed to position the content
// within its layout bounds w.r.t. its content scale.
val baseZoom = contentScale.computeScaleFactor(
srcSize = unscaledContentBounds.size,
dstSize = contentLayoutSize,
)
val oldZoom = ContentZoom(
baseZoom = baseZoom,
userZoom = rawTransformation?.zoom?.userZoom ?: 1f
)
val isZoomingOut = zoomDelta < 1f
val isZoomingIn = zoomDelta > 1f
// Apply elasticity if content is being over/under-zoomed.
val isAtMaxZoom = oldZoom.isAtMaxZoom(zoomSpec.range)
val isAtMinZoom = oldZoom.isAtMinZoom(zoomSpec.range)
val zoomDelta = when {
!zoomSpec.preventOverOrUnderZoom -> zoomDelta
isZoomingIn && isAtMaxZoom -> 1f + zoomDelta / 250
isZoomingOut && isAtMinZoom -> 1f - zoomDelta / 250
else -> zoomDelta
}
val newZoom = ContentZoom(
baseZoom = baseZoom,
userZoom = oldZoom.userZoom * zoomDelta
).let {
if (zoomSpec.preventOverOrUnderZoom && (isAtMinZoom || isAtMaxZoom)) {
// Apply a hard-stop after a limit.
it.coercedIn(
range = zoomSpec.range,
leewayPercentForMinZoom = 0.1f,
leewayPercentForMaxZoom = 0.4f
)
} else {
it
}
}
val oldOffset = rawTransformation.let {
if (it != null) {
it.offset
} else {
val defaultAlignmentOffset = contentAlignment.align(
size = (unscaledContentBounds.size * baseZoom).roundToIntSize(),
space = contentLayoutSize.roundToIntSize(),
layoutDirection = layoutDirection
)
// Take the content's top-left into account because it may not start at 0,0.
unscaledContentBounds.topLeft + (-defaultAlignmentOffset.toOffset() / oldZoom)
}
}
rawTransformation = RawTransformation(
offset = oldOffset
.retainCentroidPositionAfterZoom(
centroid = centroid,
panDelta = panDelta,
oldZoom = oldZoom,
newZoom = newZoom
)
.coerceWithinBounds(proposedZoom = newZoom),
zoom = newZoom,
lastCentroid = centroid,
contentSize = unscaledContentLocation.size(contentLayoutSize),
)
}
internal fun canConsumePanChange(panDelta: Offset): Boolean {
val current = rawTransformation
?: return false // Content is probably not ready yet. Ignore this gesture.
val panDeltaWithZoom = panDelta / current.zoom
val newOffset = (current.offset - panDeltaWithZoom)
val newOffsetWithinBounds = newOffset.coerceWithinBounds(proposedZoom = current.zoom)
val consumedPan = panDeltaWithZoom - (newOffsetWithinBounds - newOffset)
val isHorizontalPan = abs(panDeltaWithZoom.x) > abs(panDeltaWithZoom.y)
return (if (isHorizontalPan) abs(consumedPan.x) else abs(consumedPan.y)) > ZoomDeltaEpsilon
}
/**
* Translate this offset such that the visual position of [centroid]
* remains the same after applying [panDelta] and [newZoom].
*/
private fun Offset.retainCentroidPositionAfterZoom(
centroid: Offset,
panDelta: Offset = Offset.Zero,
oldZoom: ContentZoom,
newZoom: ContentZoom,
): Offset {
// Copied from androidx samples:
// https://github.com/androidx/androidx/blob/643b1cfdd7dfbc5ccce1ad951b6999df049678b3/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/TransformGestureSamples.kt#L87
//
// For natural zooming and rotating, the centroid of the gesture
// should be the fixed point where zooming and rotating occurs.
//
// We compute where the centroid was (in the pre-transformed coordinate
// space), and then compute where it will be after this delta.
//
// We then compute what the new offset should be to keep the centroid
// visually stationary for rotating and zooming, and also apply the pan.
//
// This is comparable to performing a pre-translate + scale + post-translate on
// a Matrix.
//
// I found this maths difficult to understand, so here's another explanation in
// Ryan Harter's words:
//
// The basic idea is that to scale around an arbitrary point, you translate so that
// that point is in the center, then you rotate, then scale, then move everything back.
//
// Note to self: these values are divided by zoom because that's how the final offset
// for UI is calculated: -offset * zoom.
//
// Move the centroid to the center
// of panned content(?)
// | Scale
// | | Move back
// | | (+ new translation)
// | | |
// _____________|_________________ ________|_________ ________|_________
return (this + centroid / oldZoom) - (centroid / newZoom + panDelta / oldZoom)
}
private fun Offset.coerceWithinBounds(proposedZoom: ContentZoom): Offset {
val scaledTopLeft = unscaledContentBounds.topLeft * proposedZoom
// Note to self: (-offset * zoom) is the final value used for displaying the content composable.
return withZoomAndTranslate(zoom = -proposedZoom.finalZoom(), translate = scaledTopLeft) {
val expectedDrawRegion = Rect(offset = it, size = unscaledContentBounds.size * proposedZoom)
expectedDrawRegion.calculateTopLeftToOverlapWith(contentLayoutSize, contentAlignment, layoutDirection)
}
}
private operator fun Offset.div(zoom: ContentZoom): Offset = div(zoom.finalZoom())
private operator fun Offset.times(zoom: ContentZoom): Offset = times(zoom.finalZoom())
private operator fun Size.times(zoom: ContentZoom): Size = times(zoom.finalZoom())
/** See [ZoomableContentLocation]. */
suspend fun setContentLocation(location: ZoomableContentLocation) {
unscaledContentLocation = location
// Refresh content transformation synchronously so that the result is available immediately.
// Otherwise, the old position will be used with this new size and cause a flicker.
if (isReadyToInteract) {
refreshContentTransformation()
}
}
/** Reset content to its minimum zoom and zero offset. */
suspend fun resetZoom(withAnimation: Boolean = true) {
if (withAnimation) {
smoothlyToggleZoom(
shouldZoomIn = false,
centroid = Offset.Zero,
)
} else {
rawTransformation = null
}
}
/**
* Update the content's position. This is called when values
* such as [contentScale] and [contentAlignment] are updated.
*/
internal suspend fun refreshContentTransformation() {
check(isReadyToInteract)
transformableState.transform(MutatePriority.PreventUserInput) {
transformBy(/* default values */)
}
}
internal suspend fun handleDoubleTapZoomTo(centroid: Offset) {
val start = rawTransformation ?: return
smoothlyToggleZoom(
shouldZoomIn = !start.zoom.isAtMaxZoom(zoomSpec.range),
centroid = centroid
)
}
private suspend fun smoothlyToggleZoom(
shouldZoomIn: Boolean,
centroid: Offset
) {
val start = rawTransformation ?: return
val targetZoomFactor = if (shouldZoomIn) {
zoomSpec.range.maxZoom(baseZoom = start.zoom.baseZoom)
} else {
zoomSpec.range.minZoom(baseZoom = start.zoom.baseZoom)
}
val targetZoom = start.zoom.copy(
userZoom = targetZoomFactor / (start.zoom.baseZoom.maxScale)
)
val targetOffset = start.offset
.retainCentroidPositionAfterZoom(
centroid = centroid,
oldZoom = start.zoom,
newZoom = targetZoom,
)
.coerceWithinBounds(proposedZoom = targetZoom)
transformableState.transform(MutatePriority.UserInput) {
AnimationState(initialValue = 0f).animateTo(
targetValue = 1f,
// Without a low visibility threshold, spring() makes a huge
// jump on its last frame causing a few frames to be dropped.
animationSpec = spring(stiffness = StiffnessMediumLow, visibilityThreshold = 0.0001f)
) {
val animatedZoom = start.zoom.copy(
userZoom = lerp(
start = start.zoom.userZoom,
stop = targetZoom.userZoom,
fraction = value
)
)
// For animating the offset, it is necessary to interpolate between values that the UI
// will see (i.e., -offset * zoom). Otherwise, a curve animation is produced if only the
// offset is used because the zoom and the offset values animate at different scales.
val animatedOffsetForUi = lerp(
start = (-start.offset * start.zoom),
stop = (-targetOffset * targetZoom),
fraction = value
)
rawTransformation = rawTransformation!!.copy(
offset = (-animatedOffsetForUi) / animatedZoom,
zoom = animatedZoom,
lastCentroid = centroid,
)
}
}
}
internal fun isZoomOutsideRange(): Boolean {
val currentZoom = rawTransformation!!.zoom
val userZoomWithinBounds = currentZoom.coercedIn(zoomSpec.range)
return abs(currentZoom.userZoom - userZoomWithinBounds.userZoom) > ZoomDeltaEpsilon
}
internal suspend fun smoothlySettleZoomOnGestureEnd() {
val start = rawTransformation!!
val userZoomWithinBounds = start.zoom.coercedIn(zoomSpec.range).userZoom
transformableState.transform(MutatePriority.Default) {
var previous = start.zoom.userZoom
AnimationState(initialValue = previous).animateTo(
targetValue = userZoomWithinBounds,
animationSpec = spring()
) {
transformBy(
centroid = start.lastCentroid,
zoomChange = if (previous == 0f) 1f else value / previous,
)
previous = this.value
}
}
}
internal suspend fun fling(velocity: Velocity, density: Density) {
val start = rawTransformation!!
transformableState.transform(MutatePriorities.FlingAnimation) {
var previous = start.offset
AnimationState(
typeConverter = Offset.VectorConverter,
initialValue = previous,
initialVelocityVector = AnimationVector(velocity.x, velocity.y)
).animateDecay(splineBasedDecay(density)) {
transformBy(
centroid = start.lastCentroid,
panChange = value - previous
)
previous = value
}
}
}
companion object {
internal val Saver = Saver<ZoomableState, ZoomableSavedState>(
save = { ZoomableSavedState(it.rawTransformation) },
restore = { ZoomableState(initialTransformation = it.gestureTransformation()) }
)
}
}
/** An intermediate, non-normalized model used for generating [ZoomableContentTransformation]. */
internal data class RawTransformation(
val offset: Offset,
val zoom: ContentZoom,
val lastCentroid: Offset,
val contentSize: Size,
)
internal data class ContentZoom(
/**
* The initial scale needed to fit [Modifier.zoomable]'s content with
* respect to [ZoomableState.contentScale].
* */
val baseZoom: ScaleFactor,
/** Zoom applied by user using gestures. */
val userZoom: Float,
) {
fun finalZoom(): ScaleFactor {
return baseZoom * userZoom
}
fun coercedIn(
range: ZoomRange,
leewayPercentForMinZoom: Float = 0f,
leewayPercentForMaxZoom: Float = leewayPercentForMinZoom,
): ContentZoom {
val minUserZoom = range.minZoom(baseZoom) / baseZoom.maxScale
val maxUserZoom = range.maxZoom(baseZoom) / baseZoom.maxScale
return copy(
baseZoom = baseZoom,
userZoom = userZoom.coerceIn(
minimumValue = minUserZoom * (1 - leewayPercentForMinZoom),
maximumValue = maxUserZoom * (1 + leewayPercentForMaxZoom),
)
)
}
fun isAtMinZoom(range: ZoomRange): Boolean {
return finalZoom().maxScale - range.minZoom(baseZoom = baseZoom) < ZoomDeltaEpsilon
}
fun isAtMaxZoom(range: ZoomRange): Boolean {
return range.maxZoom(baseZoom) - finalZoom().maxScale < ZoomDeltaEpsilon
}
companion object {
/** Differences below this value are ignored when comparing two zoom values. */
const val ZoomDeltaEpsilon = 0.01f
}
}
internal data class ZoomRange(
private val minZoomAsRatioOfBaseZoom: Float = 1f,
private val maxZoomAsRatioOfSize: Float,
) {
internal fun minZoom(baseZoom: ScaleFactor): Float {
return minZoomAsRatioOfBaseZoom * baseZoom.maxScale
}
internal fun maxZoom(baseZoom: ScaleFactor): Float {
// Note to self: the max zoom factor can be less than the min zoom
// factor if the content is scaled-up by default. This can be tested
// by setting contentScale = CenterCrop.
return maxOf(maxZoomAsRatioOfSize, minZoom(baseZoom))
}
}