1 /*
<lambda>null2 * Copyright 2022 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.layout
18
19 import android.graphics.Insets
20 import android.os.Build
21 import android.os.CancellationSignal
22 import android.view.View
23 import android.view.ViewConfiguration
24 import android.view.WindowInsetsAnimationControlListener
25 import android.view.WindowInsetsAnimationController
26 import androidx.annotation.RequiresApi
27 import androidx.compose.animation.core.Animatable
28 import androidx.compose.animation.core.FloatDecayAnimationSpec
29 import androidx.compose.animation.core.animateDecay
30 import androidx.compose.runtime.Composable
31 import androidx.compose.runtime.DisposableEffect
32 import androidx.compose.runtime.remember
33 import androidx.compose.ui.Modifier
34 import androidx.compose.ui.composed
35 import androidx.compose.ui.geometry.Offset
36 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
37 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
38 import androidx.compose.ui.input.nestedscroll.nestedScroll
39 import androidx.compose.ui.platform.LocalDensity
40 import androidx.compose.ui.platform.LocalLayoutDirection
41 import androidx.compose.ui.platform.LocalView
42 import androidx.compose.ui.platform.debugInspectorInfo
43 import androidx.compose.ui.unit.Density
44 import androidx.compose.ui.unit.LayoutDirection
45 import androidx.compose.ui.unit.Velocity
46 import androidx.compose.ui.util.fastRoundToInt
47 import androidx.compose.ui.util.packFloats
48 import androidx.compose.ui.util.unpackFloat1
49 import androidx.compose.ui.util.unpackFloat2
50 import kotlin.math.abs
51 import kotlin.math.exp
52 import kotlin.math.ln
53 import kotlin.math.sign
54 import kotlinx.coroutines.CancellableContinuation
55 import kotlinx.coroutines.CancellationException
56 import kotlinx.coroutines.ExperimentalCoroutinesApi
57 import kotlinx.coroutines.Job
58 import kotlinx.coroutines.coroutineScope
59 import kotlinx.coroutines.launch
60 import kotlinx.coroutines.suspendCancellableCoroutine
61
62 /**
63 * Controls the soft keyboard as a nested scrolling on Android [R][Build.VERSION_CODES.R] and later.
64 * This allows the user to drag the soft keyboard up and down.
65 *
66 * After scrolling, the IME will animate either to the fully shown or fully hidden position,
67 * depending on the position and fling.
68 *
69 * @sample androidx.compose.foundation.layout.samples.windowInsetsNestedScrollDemo
70 */
71 @ExperimentalLayoutApi
72 fun Modifier.imeNestedScroll(): Modifier {
73 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
74 return this
75 }
76 return composed(debugInspectorInfo { name = "imeNestedScroll" }) {
77 val nestedScrollConnection =
78 rememberWindowInsetsConnection(
79 WindowInsetsHolder.current().ime,
80 WindowInsetsSides.Bottom
81 )
82 nestedScroll(nestedScrollConnection)
83 }
84 }
85
86 /**
87 * Returns a [NestedScrollConnection] that can be used with [WindowInsets] on Android
88 * [R][Build.VERSION_CODES.R] and later.
89 *
90 * The [NestedScrollConnection] can be used when a developer wants to control a [WindowInsets],
91 * either directly animating it or allowing the user to manually manipulate it. User interactions
92 * will result in the [WindowInsets] animating either hidden or shown, depending on its current
93 * position and the fling velocity received in [NestedScrollConnection.onPreFling] and
94 * [NestedScrollConnection.onPostFling].
95 *
96 * @param windowInsets The insets to be changed by the scroll effect
97 * @param side The side of the [windowInsets] that is to be affected. Can only be one of
98 * [WindowInsetsSides.Left], [WindowInsetsSides.Top], [WindowInsetsSides.Right],
99 * [WindowInsetsSides.Bottom], [WindowInsetsSides.Start], [WindowInsetsSides.End].
100 */
101 @ExperimentalLayoutApi
102 @Composable
rememberWindowInsetsConnectionnull103 internal fun rememberWindowInsetsConnection(
104 windowInsets: AndroidWindowInsets,
105 side: WindowInsetsSides
106 ): NestedScrollConnection {
107 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
108 return DoNothingNestedScrollConnection
109 }
110 val layoutDirection = LocalLayoutDirection.current
111 val sideCalculator = SideCalculator.chooseCalculator(side, layoutDirection)
112 val view = LocalView.current
113 val density = LocalDensity.current
114 val connection =
115 remember(windowInsets, view, sideCalculator, density) {
116 WindowInsetsNestedScrollConnection(windowInsets, view, sideCalculator, density)
117 }
118 DisposableEffect(connection) { onDispose { connection.dispose() } }
119 return connection
120 }
121
122 /** A [NestedScrollConnection] that does nothing, for versions before R. */
123 private object DoNothingNestedScrollConnection : NestedScrollConnection
124
125 /**
126 * Used in place of the standard Job cancellation pathway to avoid reflective javaClass.simpleName
127 * lookups to build the exception message and stack trace collection. Remove if these are changed in
128 * kotlinx.coroutines.
129 */
130 private class WindowInsetsAnimationCancelledException :
131 CancellationException("Window insets animation cancelled") {
fillInStackTracenull132 override fun fillInStackTrace(): Throwable {
133 // Avoid null.clone() on Android <= 6.0 when accessing stackTrace
134 stackTrace = emptyArray()
135 return this
136 }
137 }
138
139 @OptIn(ExperimentalCoroutinesApi::class, ExperimentalLayoutApi::class)
140 @RequiresApi(Build.VERSION_CODES.R)
141 private class WindowInsetsNestedScrollConnection(
142 val windowInsets: AndroidWindowInsets,
143 val view: View,
144 val sideCalculator: SideCalculator,
145 val density: Density
146 ) : NestedScrollConnection, WindowInsetsAnimationControlListener {
147
148 /**
149 * The [WindowInsetsAnimationController] is only available once the insets are starting to be
150 * manipulated. This is used to set the current insets position.
151 */
152 private var animationController: WindowInsetsAnimationController? = null
153
154 /**
155 * `true` when we've requested a [WindowInsetsAnimationController] so that we don't ask for one
156 * when we've already asked for one. This should be `false` until we've made a request or when
157 * we've cleared [animationController] after it is finished.
158 */
159 private var isControllerRequested = false
160
161 /**
162 * We never need to cancel the animation because we always control it directly instead of using
163 * the [WindowInsetsAnimationController] to animate its value.
164 */
165 private val cancellationSignal = CancellationSignal()
166
167 /**
168 * Because touch motion has finer granularity than integers, we capture the fractions of
169 * integers here so that we can keep the finger more in line with the touch. Without this, we'd
170 * accumulate error.
171 */
172 private var partialConsumption = 0f
173
174 /**
175 * The [Job] that is launched to animate the insets during a fling. This can be canceled when
176 * the user touches the screen.
177 */
178 private var animationJob: Job? = null
179
180 /**
181 * Request an animation controller because it is `null`. If one has already been requested, this
182 * method does nothing.
183 */
requestAnimationControllernull184 private fun requestAnimationController() {
185 if (!isControllerRequested) {
186 isControllerRequested = true
187 view.windowInsetsController?.controlWindowInsetsAnimation(
188 windowInsets.type, // type
189 -1, // durationMillis
190 null, // interpolator
191 cancellationSignal,
192 this
193 )
194 }
195 }
196
197 private var continuation: CancellableContinuation<WindowInsetsAnimationController?>? = null
198
199 /** Allows us to suspend, waiting for the animation controller to be returned. */
getAnimationControllernull200 private suspend fun getAnimationController(): WindowInsetsAnimationController? =
201 animationController
202 ?: suspendCancellableCoroutine { continuation ->
203 this.continuation = continuation
204 requestAnimationController()
205 }
206
207 /** Handle the dragging that hides the WindowInsets. */
onPreScrollnull208 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset =
209 scroll(available, sideCalculator.hideMotion(available.x, available.y))
210
211 /** Handle the dragging that exposes the WindowInsets. */
212 override fun onPostScroll(
213 consumed: Offset,
214 available: Offset,
215 source: NestedScrollSource
216 ): Offset = scroll(available, sideCalculator.showMotion(available.x, available.y))
217
218 /** Scrolls [scrollAmount] and returns the consumed amount of [available]. */
219 private fun scroll(available: Offset, scrollAmount: Float): Offset {
220 animationJob?.let {
221 it.cancel(WindowInsetsAnimationCancelledException())
222 animationJob = null
223 }
224
225 val animationController = animationController
226
227 if (
228 scrollAmount == 0f ||
229 (windowInsets.isVisible == (scrollAmount > 0f) && animationController == null)
230 ) {
231 // No motion in the right direction or this is already fully shown/hidden.
232 return Offset.Zero
233 }
234
235 if (animationController == null) {
236 partialConsumption = 0f
237 // The animation controller isn't ready yet. Just consume the scroll.
238 requestAnimationController()
239 return sideCalculator.consumedOffsets(available)
240 }
241
242 val hidden = sideCalculator.valueOf(animationController.hiddenStateInsets)
243 val shown = sideCalculator.valueOf(animationController.shownStateInsets)
244 val currentInsets = animationController.currentInsets
245 val current = sideCalculator.valueOf(currentInsets)
246
247 val target = if (scrollAmount > 0f) shown else hidden
248
249 if (current == target) {
250 // This is already correct, so nothing to consume
251 partialConsumption = 0f
252 return Offset.Zero
253 }
254
255 val total = current + scrollAmount + partialConsumption
256 val next = total.fastRoundToInt().coerceIn(hidden, shown)
257 partialConsumption = total - total.fastRoundToInt()
258
259 if (next != current) {
260 animationController.setInsetsAndAlpha(
261 sideCalculator.adjustInsets(currentInsets, next),
262 1f, // alpha
263 0f, // progress
264 )
265 }
266 return sideCalculator.consumedOffsets(available)
267 }
268
269 /** Handle flinging toward hiding the insets. */
onPreFlingnull270 override suspend fun onPreFling(available: Velocity): Velocity =
271 fling(available, sideCalculator.hideMotion(available.x, available.y), false)
272
273 /** Handle flinging toward showing the insets. */
274 override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity =
275 fling(available, sideCalculator.showMotion(available.x, available.y), true)
276
277 /**
278 * Handle flinging by [flingAmount] and return the consumed velocity of [available].
279 * [towardShown] should be `true` when the intended motion is to show the insets or `false` if
280 * to hide them. We always handle flinging toward the insets if the [flingAmount] is `0` so that
281 * the insets animate to a fully-shown or fully-hidden state.
282 */
283 private suspend fun fling(
284 available: Velocity,
285 flingAmount: Float,
286 towardShown: Boolean
287 ): Velocity {
288 animationJob?.cancel(WindowInsetsAnimationCancelledException())
289 animationJob = null
290 partialConsumption = 0f
291
292 if (
293 (flingAmount == 0f && !towardShown) ||
294 (animationController == null && windowInsets.isVisible == towardShown)
295 ) {
296 // Either there's no motion to hide or we're certain that
297 // the inset is already correct.
298 return Velocity.Zero
299 }
300
301 val animationController = getAnimationController() ?: return Velocity.Zero
302
303 val hidden = sideCalculator.valueOf(animationController.hiddenStateInsets)
304 val shown = sideCalculator.valueOf(animationController.shownStateInsets)
305 val currentInsets = animationController.currentInsets
306 val current = sideCalculator.valueOf(currentInsets)
307
308 if ((flingAmount <= 0 && current == hidden) || (flingAmount >= 0 && current == shown)) {
309 // We've already reached the destination
310 animationController.finish(current == shown)
311 this@WindowInsetsNestedScrollConnection.animationController = null
312 return Velocity.Zero
313 }
314
315 // Let's see if the velocity is enough to get open
316 val spec = SplineBasedFloatDecayAnimationSpec(density)
317 val distance = current + spec.flingDistance(flingAmount)
318
319 val endPercent = (distance - hidden) / (shown - hidden)
320 val targetShown = endPercent > 0.5f
321 val target = if (targetShown) shown else hidden
322
323 if (distance > shown || distance < hidden) {
324 var endVelocity = 0f
325 // This is enough to reach hidden or shown state, so we can use the Android
326 // spline animation.
327 coroutineScope {
328 animationJob = launch {
329 animateDecay(
330 initialValue = current.toFloat(),
331 initialVelocity = flingAmount,
332 animationSpec = spec
333 ) { value, velocity ->
334 if (value in hidden.toFloat()..shown.toFloat()) {
335 adjustInsets(value)
336 } else {
337 // We've reached the end
338 endVelocity = velocity
339 animationController.finish(targetShown)
340 this@WindowInsetsNestedScrollConnection.animationController = null
341 animationJob?.cancel(WindowInsetsAnimationCancelledException())
342 }
343 }
344 }
345 animationJob?.join()
346 animationJob = null
347 }
348 return sideCalculator.consumedVelocity(available, endVelocity)
349 } else {
350 // This fling won't make it to the end, so animate to shown or hidden state using
351 // a spring animation
352 coroutineScope {
353 animationJob = launch {
354 val animatedValue = Animatable(current.toFloat())
355 animatedValue.animateTo(target.toFloat(), initialVelocity = flingAmount) {
356 adjustInsets(value)
357 }
358 animationController.finish(targetShown)
359 this@WindowInsetsNestedScrollConnection.animationController = null
360 }
361 }
362 return sideCalculator.consumedVelocity(available, 0f)
363 }
364 }
365
366 /** Change the inset's side to [inset]. */
adjustInsetsnull367 private fun adjustInsets(inset: Float) {
368 animationController?.let {
369 val currentInsets = it.currentInsets
370 val nextInsets = sideCalculator.adjustInsets(currentInsets, inset.fastRoundToInt())
371 it.setInsetsAndAlpha(
372 nextInsets,
373 1f, // alpha
374 0f, // progress
375 )
376 }
377 }
378
379 /** Called after [requestAnimationController] and the [animationController] is ready. */
onReadynull380 override fun onReady(controller: WindowInsetsAnimationController, types: Int) {
381 animationController = controller
382 isControllerRequested = false
383 continuation?.resume(controller) {}
384 continuation = null
385 }
386
disposenull387 fun dispose() {
388 continuation?.resume(null) {}
389 animationJob?.cancel()
390 val animationController = animationController
391 if (animationController != null) {
392 // We don't want to leave the insets in a partially open or closed state, so finish
393 // the animation
394 val visible = animationController.currentInsets != animationController.hiddenStateInsets
395 animationController.finish(visible)
396 }
397 }
398
onFinishednull399 override fun onFinished(controller: WindowInsetsAnimationController) {
400 animationEnded()
401 }
402
onCancellednull403 override fun onCancelled(controller: WindowInsetsAnimationController?) {
404 animationEnded()
405 }
406
407 /** The controlled animation has been terminated. */
animationEndednull408 private fun animationEnded() {
409 if (animationController?.isReady == true) {
410 animationController?.finish(windowInsets.isVisible)
411 }
412 animationController = null
413
414 // The animation controller may not have been given to us, so we have to cancel animations
415 // waiting for it.
416 continuation?.resume(null) {}
417 continuation = null
418
419 // Cancel any animation that's running.
420 animationJob?.cancel(WindowInsetsAnimationCancelledException())
421 animationJob = null
422
423 partialConsumption = 0f
424 isControllerRequested = false
425 }
426 }
427
428 /**
429 * This interface allows logic for the specific side (left, top, right, bottom) to be extracted from
430 * the logic controlling showing and hiding insets. For example, an inset at the top will show when
431 * dragging down, while an inset at the bottom will hide when dragging down.
432 */
433 @RequiresApi(Build.VERSION_CODES.R)
434 private interface SideCalculator {
435 /** Returns the insets value for the side that this [SideCalculator] is associated with. */
valueOfnull436 fun valueOf(insets: Insets): Int
437
438 /**
439 * Returns the motion, adjusted for side direction, that the [x], and [y] grant. A positive
440 * result indicates that it is in the direction of opening the insets on that side and a
441 * negative result indicates a closing of the insets on that side.
442 */
443 fun motionOf(x: Float, y: Float): Float
444
445 /**
446 * The motion of [x], [y] that indicates showing more of the insets on the side or `0` if no
447 * motion is given to showing more insets.
448 */
449 fun showMotion(x: Float, y: Float): Float = motionOf(x, y).coerceAtLeast(0f)
450
451 /**
452 * The motion of [x], [y] that indicates showing less of the insets on the side or `0` if no
453 * motion is given to showing less insets.
454 */
455 fun hideMotion(x: Float, y: Float): Float = motionOf(x, y).coerceAtMost(0f)
456
457 /**
458 * Takes all values of [oldInsets], except for this side and replaces this side with [newValue].
459 */
460 fun adjustInsets(oldInsets: Insets, newValue: Int): Insets
461
462 /** Returns the [Offset] that consumes [available] in the direction of this side. */
463 fun consumedOffsets(available: Offset): Offset
464
465 /** Returns the [Velocity] that consumes [available] in the direction of this side. */
466 fun consumedVelocity(available: Velocity, remaining: Float): Velocity
467
468 companion object {
469 /**
470 * Returns a [SideCalculator] for [side] and the given [layoutDirection]. This only works
471 * for one side and no combination of sides.
472 */
473 fun chooseCalculator(side: WindowInsetsSides, layoutDirection: LayoutDirection) =
474 when (side) {
475 WindowInsetsSides.Left -> LeftSideCalculator
476 WindowInsetsSides.Top -> TopSideCalculator
477 WindowInsetsSides.Right -> RightSideCalculator
478 WindowInsetsSides.Bottom -> BottomSideCalculator
479 WindowInsetsSides.Start ->
480 if (layoutDirection == LayoutDirection.Ltr) {
481 LeftSideCalculator
482 } else {
483 RightSideCalculator
484 }
485 WindowInsetsSides.End ->
486 if (layoutDirection == LayoutDirection.Ltr) {
487 RightSideCalculator
488 } else {
489 LeftSideCalculator
490 }
491 else -> error("Only Left, Top, Right, Bottom, Start and End are allowed")
492 }
493
494 private val LeftSideCalculator =
495 object : SideCalculator {
496 override fun valueOf(insets: Insets): Int = insets.left
497
498 override fun motionOf(x: Float, y: Float): Float = x
499
500 override fun adjustInsets(oldInsets: Insets, newValue: Int): Insets =
501 Insets.of(newValue, oldInsets.top, oldInsets.right, oldInsets.bottom)
502
503 override fun consumedOffsets(available: Offset): Offset = Offset(available.x, 0f)
504
505 override fun consumedVelocity(available: Velocity, remaining: Float): Velocity =
506 Velocity(available.x - remaining, 0f)
507 }
508
509 private val TopSideCalculator =
510 object : SideCalculator {
511 override fun valueOf(insets: Insets): Int = insets.top
512
513 override fun motionOf(x: Float, y: Float): Float = y
514
515 override fun adjustInsets(oldInsets: Insets, newValue: Int): Insets =
516 Insets.of(oldInsets.left, newValue, oldInsets.right, oldInsets.bottom)
517
518 override fun consumedOffsets(available: Offset): Offset = Offset(0f, available.y)
519
520 override fun consumedVelocity(available: Velocity, remaining: Float): Velocity =
521 Velocity(0f, available.y - remaining)
522 }
523
524 private val RightSideCalculator =
525 object : SideCalculator {
526 override fun valueOf(insets: Insets): Int = insets.right
527
528 override fun motionOf(x: Float, y: Float): Float = -x
529
530 override fun adjustInsets(oldInsets: Insets, newValue: Int): Insets =
531 Insets.of(oldInsets.left, oldInsets.top, newValue, oldInsets.bottom)
532
533 override fun consumedOffsets(available: Offset): Offset = Offset(available.x, 0f)
534
535 override fun consumedVelocity(available: Velocity, remaining: Float): Velocity =
536 Velocity(available.x + remaining, 0f)
537 }
538
539 private val BottomSideCalculator =
540 object : SideCalculator {
541 override fun valueOf(insets: Insets): Int = insets.bottom
542
543 override fun motionOf(x: Float, y: Float): Float = -y
544
545 override fun adjustInsets(oldInsets: Insets, newValue: Int): Insets =
546 Insets.of(oldInsets.left, oldInsets.top, oldInsets.right, newValue)
547
548 override fun consumedOffsets(available: Offset): Offset = Offset(0f, available.y)
549
550 override fun consumedVelocity(available: Velocity, remaining: Float): Velocity =
551 Velocity(0f, available.y + remaining)
552 }
553 }
554 }
555
556 // SplineBasedFloatDecayAnimationSpec is in animation:animation library, which depends on
557 // foundation-layout, so I've copied it below, but a bit trimmed to only have what is needed.
558
559 // These constants are copied from the Android spline decay rate
560 private const val Inflection = 0.35f // Tension lines cross at (Inflection, 1)
561 private val PlatformFlingScrollFriction = ViewConfiguration.getScrollFriction()
562 private const val GravityEarth = 9.80665f
563 private const val InchesPerMeter = 39.37f
564 private val DecelerationRate = ln(0.78) / ln(0.9)
565 private val DecelMinusOne = DecelerationRate - 1.0
566 private const val StartTension = 0.5f
567 private const val EndTension = 1.0f
568 private const val P1 = StartTension * Inflection
569 private const val P2 = 1.0f - EndTension * (1.0f - Inflection)
570
571 private class SplineBasedFloatDecayAnimationSpec(density: Density) : FloatDecayAnimationSpec {
572
573 override val absVelocityThreshold: Float
574 get() = 0f
575
576 /** A density-specific coefficient adjusted to physical values. */
577 private val magicPhysicalCoefficient: Float =
578 GravityEarth * InchesPerMeter * density.density * 160f * 0.84f
579
getSplineDecelerationnull580 private fun getSplineDeceleration(velocity: Float): Double =
581 AndroidFlingSpline.deceleration(
582 velocity,
583 PlatformFlingScrollFriction * magicPhysicalCoefficient
584 )
585
586 /** Compute the distance of a fling in units given an initial [velocity] of units/second */
587 fun flingDistance(velocity: Float): Float {
588 val l = getSplineDeceleration(velocity)
589 return (PlatformFlingScrollFriction *
590 magicPhysicalCoefficient *
591 exp(DecelerationRate / DecelMinusOne * l))
592 .toFloat() * sign(velocity)
593 }
594
getTargetValuenull595 override fun getTargetValue(initialValue: Float, initialVelocity: Float): Float =
596 initialValue + flingDistance(initialVelocity)
597
598 @Suppress("MethodNameUnits")
599 override fun getValueFromNanos(
600 playTimeNanos: Long,
601 initialValue: Float,
602 initialVelocity: Float
603 ): Float {
604 val duration = getDurationNanos(0f, initialVelocity)
605 val splinePos = if (duration > 0) playTimeNanos / duration.toFloat() else 1f
606 val distance = flingDistance(initialVelocity)
607 return initialValue +
608 distance * AndroidFlingSpline.flingPosition(splinePos).distanceCoefficient
609 }
610
611 @Suppress("MethodNameUnits")
getDurationNanosnull612 override fun getDurationNanos(initialValue: Float, initialVelocity: Float): Long {
613 val l = getSplineDeceleration(initialVelocity)
614 return (1_000_000_000.0 * exp(l / DecelMinusOne)).toLong()
615 }
616
617 @Suppress("MethodNameUnits")
getVelocityFromNanosnull618 override fun getVelocityFromNanos(
619 playTimeNanos: Long,
620 initialValue: Float,
621 initialVelocity: Float
622 ): Float {
623 val duration = getDurationNanos(0f, initialVelocity)
624 val splinePos = if (duration > 0L) playTimeNanos / duration.toFloat() else 1f
625 val distance = flingDistance(initialVelocity)
626 return AndroidFlingSpline.flingPosition(splinePos).velocityCoefficient * distance /
627 duration * 1_000_000_000.0f
628 }
629 }
630
631 private object AndroidFlingSpline {
632 private const val NbSamples = 100
633 private val SplinePositions = FloatArray(NbSamples + 1)
634 private val SplineTimes = FloatArray(NbSamples + 1)
635
<lambda>null636 init {
637 var xMin = 0.0f
638 var yMin = 0.0f
639 for (i in 0 until NbSamples) {
640 val alpha = i.toFloat() / NbSamples
641 var xMax = 1.0f
642 var x: Float
643 var tx: Float
644 var coef: Float
645 while (true) {
646 x = xMin + (xMax - xMin) / 2.0f
647 coef = 3.0f * x * (1.0f - x)
648 tx = coef * ((1.0f - x) * P1 + x * P2) + x * x * x
649 if (abs(tx - alpha) < 1E-5) break
650 if (tx > alpha) xMax = x else xMin = x
651 }
652 SplinePositions[i] = coef * ((1.0f - x) * StartTension + x) + x * x * x
653 var yMax = 1.0f
654 var y: Float
655 var dy: Float
656 while (true) {
657 y = yMin + (yMax - yMin) / 2.0f
658 coef = 3.0f * y * (1.0f - y)
659 dy = coef * ((1.0f - y) * StartTension + y) + y * y * y
660 if (abs(dy - alpha) < 1E-5) break
661 if (dy > alpha) yMax = y else yMin = y
662 }
663 SplineTimes[i] = coef * ((1.0f - y) * P1 + y * P2) + y * y * y
664 }
665 SplineTimes[NbSamples] = 1.0f
666 SplinePositions[NbSamples] = SplineTimes[NbSamples]
667 }
668
669 /**
670 * Compute an instantaneous fling position along the scroller spline.
671 *
672 * @param time progress through the fling animation from 0-1
673 */
flingPositionnull674 fun flingPosition(time: Float): FlingResult {
675 val index = (NbSamples * time).toInt()
676 var distanceCoef = 1f
677 var velocityCoef = 0f
678 if (index < NbSamples) {
679 val tInf = index.toFloat() / NbSamples
680 val tSup = (index + 1).toFloat() / NbSamples
681 val dInf = SplinePositions[index]
682 val dSup = SplinePositions[index + 1]
683 velocityCoef = (dSup - dInf) / (tSup - tInf)
684 distanceCoef = dInf + (time - tInf) * velocityCoef
685 }
686 return FlingResult(packFloats(distanceCoef, velocityCoef))
687 }
688
689 /** The rate of deceleration along the spline motion given [velocity] and [friction]. */
decelerationnull690 fun deceleration(velocity: Float, friction: Float): Double =
691 ln(Inflection * abs(velocity) / friction.toDouble())
692
693 /** Result coefficients of a scroll computation */
694 @JvmInline
695 value class FlingResult(private val packedValue: Long) {
696 /** Linear distance traveled from 0-1, from source (0) to destination (1) */
697 val distanceCoefficient: Float
698 get() = unpackFloat1(packedValue)
699
700 /**
701 * Instantaneous velocity coefficient at this point in the fling expressed in total distance
702 * per unit time
703 */
704 val velocityCoefficient: Float
705 get() = unpackFloat2(packedValue)
706 }
707 }
708