1 /*
<lambda>null2 * Copyright (C) 2024 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 com.android.wm.shell.back
18
19 import android.animation.Animator
20 import android.animation.AnimatorListenerAdapter
21 import android.animation.ValueAnimator
22 import android.content.Context
23 import android.content.res.Configuration
24 import android.graphics.Color
25 import android.graphics.Matrix
26 import android.graphics.PointF
27 import android.graphics.Rect
28 import android.graphics.RectF
29 import android.os.Handler
30 import android.os.RemoteException
31 import android.util.TimeUtils
32 import android.view.Choreographer
33 import android.view.Display
34 import android.view.IRemoteAnimationFinishedCallback
35 import android.view.IRemoteAnimationRunner
36 import android.view.RemoteAnimationTarget
37 import android.view.SurfaceControl
38 import android.view.animation.DecelerateInterpolator
39 import android.view.animation.Interpolator
40 import android.view.animation.Transformation
41 import android.window.BackEvent
42 import android.window.BackEvent.EDGE_LEFT
43 import android.window.BackEvent.EDGE_RIGHT
44 import android.window.BackMotionEvent
45 import android.window.BackNavigationInfo
46 import android.window.BackProgressAnimator
47 import android.window.IOnBackInvokedCallback
48 import com.android.internal.dynamicanimation.animation.FloatValueHolder
49 import com.android.internal.dynamicanimation.animation.SpringAnimation
50 import com.android.internal.dynamicanimation.animation.SpringForce
51 import com.android.internal.jank.Cuj
52 import com.android.internal.policy.ScreenDecorationsUtils
53 import com.android.internal.policy.SystemBarUtils
54 import com.android.internal.protolog.ProtoLog
55 import com.android.window.flags.Flags.enableMultidisplayTrackpadBackGesture
56 import com.android.window.flags.Flags.predictiveBackTimestampApi
57 import com.android.wm.shell.R
58 import com.android.wm.shell.RootTaskDisplayAreaOrganizer
59 import com.android.wm.shell.protolog.ShellProtoLogGroup
60 import com.android.wm.shell.shared.animation.Interpolators
61 import com.android.wm.shell.shared.annotations.ShellMainThread
62 import kotlin.math.abs
63 import kotlin.math.max
64 import kotlin.math.min
65
66 abstract class CrossActivityBackAnimation(
67 private val context: Context,
68 private val background: BackAnimationBackground,
69 private val rootTaskDisplayAreaOrganizer: RootTaskDisplayAreaOrganizer,
70 protected val transaction: SurfaceControl.Transaction,
71 @ShellMainThread handler: Handler,
72 ) : ShellBackAnimation() {
73
74 protected val startClosingRect = RectF()
75 protected val targetClosingRect = RectF()
76 protected val currentClosingRect = RectF()
77
78 protected val startEnteringRect = RectF()
79 protected val targetEnteringRect = RectF()
80 protected val currentEnteringRect = RectF()
81
82 protected val backAnimRect = Rect()
83 private val cropRect = Rect()
84 private val tempRectF = RectF()
85
86 private var cornerRadius = ScreenDecorationsUtils.getWindowCornerRadius(context)
87 private var statusbarHeight = SystemBarUtils.getStatusBarHeight(context)
88
89 private val backAnimationRunner =
90 BackAnimationRunner(
91 Callback(),
92 Runner(),
93 context,
94 Cuj.CUJ_PREDICTIVE_BACK_CROSS_ACTIVITY,
95 handler,
96 )
97 private val initialTouchPos = PointF()
98 private val transformMatrix = Matrix()
99 private val tmpFloat9 = FloatArray(9)
100 protected var enteringTarget: RemoteAnimationTarget? = null
101 protected var closingTarget: RemoteAnimationTarget? = null
102 private var triggerBack = false
103 private var finishCallback: IRemoteAnimationFinishedCallback? = null
104 private val progressAnimator = BackProgressAnimator()
105 protected val displayBoundsMargin =
106 context.resources.getDimension(R.dimen.cross_task_back_vertical_margin)
107
108 private val gestureInterpolator = Interpolators.BACK_GESTURE
109 private val verticalMoveInterpolator: Interpolator = DecelerateInterpolator()
110
111 private var scrimLayer: SurfaceControl? = null
112 private var maxScrimAlpha: Float = 0f
113
114 private var isLetterboxed = false
115 private var enteringHasSameLetterbox = false
116 private var leftLetterboxLayer: SurfaceControl? = null
117 private var rightLetterboxLayer: SurfaceControl? = null
118 private var letterboxColor: Int = 0
119
120 private val postCommitFlingScale = FloatValueHolder(SPRING_SCALE)
121 private var lastPostCommitFlingScale = SPRING_SCALE
122 private val postCommitFlingSpring = SpringForce(SPRING_SCALE)
123 .setStiffness(SpringForce.STIFFNESS_LOW)
124 .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY)
125 private var swipeEdge = EDGE_LEFT
126 protected var gestureProgress = 0f
127 private val velocityTracker = ProgressVelocityTracker()
128
129 /** Background color to be used during the animation, also see [getBackgroundColor] */
130 protected var customizedBackgroundColor = 0
131
132 /**
133 * Whether the entering target should be shifted vertically with the user gesture in pre-commit
134 */
135 abstract val allowEnteringYShift: Boolean
136
137 /**
138 * Subclasses must set the [startClosingRect] and [targetClosingRect] to define the movement
139 * of the closingTarget during pre-commit phase.
140 */
141 abstract fun preparePreCommitClosingRectMovement(@BackEvent.SwipeEdge swipeEdge: Int)
142
143 /**
144 * Subclasses must set the [startEnteringRect] and [targetEnteringRect] to define the movement
145 * of the enteringTarget during pre-commit phase.
146 */
147 abstract fun preparePreCommitEnteringRectMovement()
148
149 /**
150 * Subclasses must provide a duration (in ms) for the post-commit part of the animation
151 */
152 abstract fun getPostCommitAnimationDuration(): Long
153
154 /**
155 * Returns a base transformation to apply to the entering target during pre-commit. The system
156 * will apply the default animation on top of it.
157 */
158 protected open fun getPreCommitEnteringBaseTransformation(progress: Float): Transformation? =
159 null
160
161 override fun onConfigurationChanged(newConfiguration: Configuration) {
162 cornerRadius = ScreenDecorationsUtils.getWindowCornerRadius(context)
163 statusbarHeight = SystemBarUtils.getStatusBarHeight(context)
164 }
165
166 override fun getRunner() = backAnimationRunner
167
168 private fun getBackgroundColor(): Int =
169 when {
170 customizedBackgroundColor != 0 -> customizedBackgroundColor
171 isLetterboxed -> letterboxColor
172 enteringTarget != null -> enteringTarget!!.taskInfo.taskDescription!!.backgroundColor
173 else -> 0
174 }
175
176 protected open fun startBackAnimation(backMotionEvent: BackMotionEvent) {
177 if (enteringTarget == null || closingTarget == null) {
178 ProtoLog.d(
179 ShellProtoLogGroup.WM_SHELL_BACK_PREVIEW,
180 "Entering target or closing target is null."
181 )
182 return
183 }
184 swipeEdge = backMotionEvent.swipeEdge
185 triggerBack = backMotionEvent.triggerBack
186 initialTouchPos.set(backMotionEvent.touchX, backMotionEvent.touchY)
187
188 transaction.setAnimationTransaction()
189 isLetterboxed = closingTarget!!.taskInfo.appCompatTaskInfo.isTopActivityLetterboxed
190 enteringHasSameLetterbox =
191 isLetterboxed && closingTarget!!.localBounds.equals(enteringTarget!!.localBounds)
192
193 if (isLetterboxed && !enteringHasSameLetterbox) {
194 // Play animation with letterboxes, if closing and entering target have mismatching
195 // letterboxes
196 backAnimRect.set(closingTarget!!.windowConfiguration.bounds)
197 } else {
198 // otherwise play animation on localBounds only
199 backAnimRect.set(closingTarget!!.localBounds)
200 }
201 // Offset start rectangle to align task bounds.
202 backAnimRect.offsetTo(0, 0)
203
204 preparePreCommitClosingRectMovement(backMotionEvent.swipeEdge)
205 preparePreCommitEnteringRectMovement()
206
207 background.ensureBackground(
208 closingTarget!!.windowConfiguration.bounds,
209 getBackgroundColor(),
210 transaction,
211 statusbarHeight,
212 if (closingTarget!!.windowConfiguration.tasksAreFloating())
213 closingTarget!!.localBounds else null,
214 cornerRadius,
215 closingTarget!!.taskInfo.getDisplayId()
216 )
217 ensureScrimLayer()
218 if (isLetterboxed && enteringHasSameLetterbox) {
219 // crop left and right letterboxes
220 cropRect.set(
221 closingTarget!!.localBounds.left,
222 0,
223 closingTarget!!.localBounds.right,
224 closingTarget!!.windowConfiguration.bounds.height()
225 )
226 // and add fake letterbox square surfaces instead
227 ensureLetterboxes()
228 } else {
229 cropRect.set(backAnimRect)
230 }
231 applyTransaction()
232 }
233
234 private fun onGestureProgress(backEvent: BackEvent) {
235 val progress = gestureInterpolator.getInterpolation(backEvent.progress)
236 gestureProgress = progress
237 currentClosingRect.setInterpolatedRectF(startClosingRect, targetClosingRect, progress)
238 val yOffset = getYOffset(currentClosingRect, backEvent.touchY)
239 currentClosingRect.offset(0f, yOffset)
240 applyTransform(closingTarget?.leash, currentClosingRect, 1f)
241 currentEnteringRect.setInterpolatedRectF(startEnteringRect, targetEnteringRect, progress)
242 if (allowEnteringYShift) currentEnteringRect.offset(0f, yOffset)
243 val enteringTransformation = getPreCommitEnteringBaseTransformation(progress)
244 applyTransform(
245 enteringTarget?.leash,
246 currentEnteringRect,
247 enteringTransformation?.alpha ?: 1f,
248 enteringTransformation
249 )
250 applyTransaction()
251 background.customizeStatusBarAppearance(currentClosingRect.top.toInt())
252 if (predictiveBackTimestampApi()) {
253 velocityTracker.addPosition(backEvent.frameTimeMillis, progress)
254 }
255 }
256
257 private fun getYOffset(centeredRect: RectF, touchY: Float): Float {
258 val screenHeight = backAnimRect.height()
259 // Base the window movement in the Y axis on the touch movement in the Y axis.
260 val rawYDelta = touchY - initialTouchPos.y
261 val yDirection = (if (rawYDelta < 0) -1 else 1)
262 // limit yDelta interpretation to 1/2 of screen height in either direction
263 val deltaYRatio = min(screenHeight / 2f, abs(rawYDelta)) / (screenHeight / 2f)
264 val interpolatedYRatio: Float = verticalMoveInterpolator.getInterpolation(deltaYRatio)
265 // limit y-shift so surface never passes 8dp screen margin
266 val deltaY =
267 max(0f, (screenHeight - centeredRect.height()) / 2f - displayBoundsMargin) *
268 interpolatedYRatio *
269 yDirection
270 return deltaY
271 }
272
273 protected open fun onGestureCommitted(velocity: Float) {
274 if (
275 closingTarget?.leash == null ||
276 enteringTarget?.leash == null ||
277 !enteringTarget!!.leash.isValid ||
278 !closingTarget!!.leash.isValid
279 ) {
280 finishAnimation()
281 return
282 }
283
284 // kick off spring animation with the current velocity from the pre-commit phase, this
285 // affects the scaling of the closing and/or opening activity during post-commit
286
287 var startVelocity = if (predictiveBackTimestampApi()) {
288 // pronounce fling animation more for gestures
289 val velocityFactor = if (swipeEdge == EDGE_LEFT || swipeEdge == EDGE_RIGHT) 2f else 1f
290 velocity * SPRING_SCALE * (1f - MAX_SCALE) * velocityFactor
291 } else {
292 velocity * SPRING_SCALE
293 }
294 if (gestureProgress < 0.1f) {
295 startVelocity = startVelocity.coerceAtLeast(DEFAULT_FLING_VELOCITY)
296 }
297 val flingAnimation = SpringAnimation(postCommitFlingScale, SPRING_SCALE)
298 .setStartVelocity(-startVelocity.coerceIn(0f, MAX_FLING_VELOCITY))
299 .setStartValue(SPRING_SCALE)
300 .setSpring(postCommitFlingSpring)
301 flingAnimation.start()
302 // do an animation-frame immediately to prevent idle frame
303 flingAnimation.doAnimationFrame(
304 Choreographer.getInstance().lastFrameTimeNanos / TimeUtils.NANOS_PER_MS
305 )
306
307 val valueAnimator =
308 ValueAnimator.ofFloat(1f, 0f).setDuration(getPostCommitAnimationDuration())
309 valueAnimator.addUpdateListener { animation: ValueAnimator ->
310 val progress = animation.animatedFraction
311 onPostCommitProgress(progress)
312 if (progress > 1 - BackAnimationConstants.UPDATE_SYSUI_FLAGS_THRESHOLD) {
313 background.resetStatusBarCustomization()
314 }
315 }
316 valueAnimator.addListener(
317 object : AnimatorListenerAdapter() {
318 override fun onAnimationEnd(animation: Animator) {
319 background.resetStatusBarCustomization()
320 finishAnimation()
321 }
322 }
323 )
324 valueAnimator.start()
325 }
326
327 protected open fun onPostCommitProgress(linearProgress: Float) {
328 scrimLayer?.let { transaction.setAlpha(it, maxScrimAlpha * (1f - linearProgress)) }
329 }
330
331 protected open fun finishAnimation() {
332 enteringTarget?.let {
333 if (it.leash != null && it.leash.isValid) {
334 transaction.setCornerRadius(it.leash, 0f)
335 if (!triggerBack) transaction.setAlpha(it.leash, 0f)
336 it.leash.release()
337 }
338 enteringTarget = null
339 }
340
341 closingTarget?.leash?.release()
342 closingTarget = null
343
344 background.removeBackground(transaction)
345 applyTransaction()
346 transformMatrix.reset()
347 initialTouchPos.set(0f, 0f)
348 try {
349 finishCallback?.onAnimationFinished()
350 } catch (e: RemoteException) {
351 e.printStackTrace()
352 }
353 finishCallback = null
354 removeScrimLayer()
355 removeLetterbox()
356 isLetterboxed = false
357 enteringHasSameLetterbox = false
358 lastPostCommitFlingScale = SPRING_SCALE
359 gestureProgress = 0f
360 triggerBack = false
361 velocityTracker.resetTracking()
362 }
363
364 protected fun applyTransform(
365 leash: SurfaceControl?,
366 rect: RectF,
367 alpha: Float,
368 baseTransformation: Transformation? = null,
369 flingMode: FlingMode = FlingMode.NO_FLING
370 ) {
371 if (leash == null || !leash.isValid) return
372 tempRectF.set(rect)
373 if (flingMode != FlingMode.NO_FLING) {
374 lastPostCommitFlingScale = min(
375 postCommitFlingScale.value / SPRING_SCALE,
376 if (flingMode == FlingMode.FLING_BOUNCE) 1f else lastPostCommitFlingScale
377 )
378 // apply an additional scale to the closing target to account for fling velocity
379 tempRectF.scaleCentered(lastPostCommitFlingScale)
380 }
381 val scale = tempRectF.width() / backAnimRect.width()
382 val matrix = baseTransformation?.matrix ?: transformMatrix.apply { reset() }
383 val scalePivotX =
384 if (isLetterboxed && enteringHasSameLetterbox) {
385 closingTarget!!.localBounds.left.toFloat()
386 } else {
387 0f
388 }
389 matrix.postScale(scale, scale, scalePivotX, 0f)
390 matrix.postTranslate(tempRectF.left, tempRectF.top)
391 transaction
392 .setAlpha(leash, alpha)
393 .setMatrix(leash, matrix, tmpFloat9)
394 .setCrop(leash, cropRect)
395 .setCornerRadius(leash, cornerRadius)
396 }
397
398 protected fun applyTransaction() {
399 transaction.setFrameTimelineVsync(Choreographer.getInstance().vsyncId)
400 transaction.apply()
401 }
402
403 private fun ensureScrimLayer() {
404 if (scrimLayer != null) return
405 val isDarkTheme: Boolean = isDarkMode(context)
406 val scrimBuilder =
407 SurfaceControl.Builder()
408 .setName("Cross-Activity back animation scrim")
409 .setCallsite("CrossActivityBackAnimation")
410 .setColorLayer()
411 .setOpaque(false)
412 .setHidden(false)
413
414 if (enableMultidisplayTrackpadBackGesture()) {
415 rootTaskDisplayAreaOrganizer.attachToDisplayArea(
416 closingTarget!!.taskInfo.getDisplayId(), scrimBuilder)
417 } else {
418 rootTaskDisplayAreaOrganizer.attachToDisplayArea(Display.DEFAULT_DISPLAY, scrimBuilder)
419 }
420 scrimLayer = scrimBuilder.build()
421 val colorComponents = floatArrayOf(0f, 0f, 0f)
422 maxScrimAlpha = if (isDarkTheme) MAX_SCRIM_ALPHA_DARK else MAX_SCRIM_ALPHA_LIGHT
423 val scrimCrop =
424 if (isLetterboxed) {
425 closingTarget!!.windowConfiguration.bounds
426 } else {
427 closingTarget!!.localBounds
428 }
429 transaction
430 .setColor(scrimLayer, colorComponents)
431 .setAlpha(scrimLayer!!, maxScrimAlpha)
432 .setCrop(scrimLayer!!, scrimCrop)
433 .setRelativeLayer(scrimLayer!!, closingTarget!!.leash, -1)
434 .show(scrimLayer)
435 }
436
437 private fun removeScrimLayer() {
438 if (removeLayer(scrimLayer)) applyTransaction()
439 scrimLayer = null
440 }
441
442 /**
443 * Adds two "fake" letterbox square surfaces to the left and right of the localBounds of the
444 * closing target
445 */
446 private fun ensureLetterboxes() {
447 closingTarget?.let { t ->
448 if (t.localBounds.left != 0 && leftLetterboxLayer == null) {
449 val bounds =
450 Rect(
451 0,
452 t.windowConfiguration.bounds.top,
453 t.localBounds.left,
454 t.windowConfiguration.bounds.bottom
455 )
456 leftLetterboxLayer = ensureLetterbox(bounds)
457 }
458 if (
459 t.localBounds.right != t.windowConfiguration.bounds.right &&
460 rightLetterboxLayer == null
461 ) {
462 val bounds =
463 Rect(
464 t.localBounds.right,
465 t.windowConfiguration.bounds.top,
466 t.windowConfiguration.bounds.right,
467 t.windowConfiguration.bounds.bottom
468 )
469 rightLetterboxLayer = ensureLetterbox(bounds)
470 }
471 }
472 }
473
474 private fun ensureLetterbox(bounds: Rect): SurfaceControl {
475 val letterboxBuilder =
476 SurfaceControl.Builder()
477 .setName("Cross-Activity back animation letterbox")
478 .setCallsite("CrossActivityBackAnimation")
479 .setColorLayer()
480 .setOpaque(true)
481 .setHidden(false)
482
483 if (enableMultidisplayTrackpadBackGesture()) {
484 rootTaskDisplayAreaOrganizer.attachToDisplayArea(
485 closingTarget!!.taskInfo.getDisplayId(), letterboxBuilder)
486 } else {
487 rootTaskDisplayAreaOrganizer.attachToDisplayArea(
488 Display.DEFAULT_DISPLAY, letterboxBuilder)
489 }
490 val layer = letterboxBuilder.build()
491 val colorComponents =
492 floatArrayOf(
493 Color.red(letterboxColor) / 255f,
494 Color.green(letterboxColor) / 255f,
495 Color.blue(letterboxColor) / 255f
496 )
497 transaction
498 .setColor(layer, colorComponents)
499 .setCrop(layer, bounds)
500 .setRelativeLayer(layer, closingTarget!!.leash, 1)
501 .show(layer)
502 return layer
503 }
504
505 private fun removeLetterbox() {
506 if (removeLayer(leftLetterboxLayer) || removeLayer(rightLetterboxLayer)) applyTransaction()
507 leftLetterboxLayer = null
508 rightLetterboxLayer = null
509 }
510
511 private fun removeLayer(layer: SurfaceControl?): Boolean {
512 layer?.let {
513 if (it.isValid) {
514 transaction.remove(it)
515 return true
516 }
517 }
518 return false
519 }
520
521 override fun prepareNextAnimation(
522 animationInfo: BackNavigationInfo.CustomAnimationInfo?,
523 letterboxColor: Int
524 ): Boolean {
525 this.letterboxColor = letterboxColor
526 return false
527 }
528
529 private inner class Callback : IOnBackInvokedCallback.Default() {
530 override fun onBackStarted(backMotionEvent: BackMotionEvent) {
531 // in case we're still animating an onBackCancelled event, let's remove the finish-
532 // callback from the progress animator to prevent calling finishAnimation() before
533 // restarting a new animation
534 progressAnimator.removeOnBackCancelledFinishCallback()
535
536 startBackAnimation(backMotionEvent)
537 progressAnimator.onBackStarted(backMotionEvent) { backEvent: BackEvent ->
538 onGestureProgress(backEvent)
539 }
540 }
541
542 override fun onBackProgressed(backEvent: BackMotionEvent) {
543 triggerBack = backEvent.triggerBack
544 progressAnimator.onBackProgressed(backEvent)
545 }
546
547 override fun onBackCancelled() {
548 triggerBack = false
549 progressAnimator.onBackCancelled { finishAnimation() }
550 }
551
552 override fun onBackInvoked() {
553 triggerBack = true
554 progressAnimator.reset()
555 if (predictiveBackTimestampApi()) {
556 onGestureCommitted(velocityTracker.calculateVelocity())
557 } else {
558 onGestureCommitted(progressAnimator.velocity)
559 }
560 }
561 }
562
563 private inner class Runner : IRemoteAnimationRunner.Default() {
564 override fun onAnimationStart(
565 transit: Int,
566 apps: Array<RemoteAnimationTarget>,
567 wallpapers: Array<RemoteAnimationTarget>?,
568 nonApps: Array<RemoteAnimationTarget>?,
569 finishedCallback: IRemoteAnimationFinishedCallback
570 ) {
571 ProtoLog.d(
572 ShellProtoLogGroup.WM_SHELL_BACK_PREVIEW,
573 "Start back to activity animation."
574 )
575 for (a in apps) {
576 when (a.mode) {
577 RemoteAnimationTarget.MODE_CLOSING -> closingTarget = a
578 RemoteAnimationTarget.MODE_OPENING -> enteringTarget = a
579 }
580 }
581 finishCallback = finishedCallback
582 }
583
584 override fun onAnimationCancelled() {
585 finishAnimation()
586 }
587 }
588
589 companion object {
590 /** Max scale of the closing window. */
591 internal const val MAX_SCALE = 0.9f
592 private const val MAX_SCRIM_ALPHA_DARK = 0.8f
593 private const val MAX_SCRIM_ALPHA_LIGHT = 0.2f
594 private const val SPRING_SCALE = 100f
595 private const val MAX_FLING_VELOCITY = 1000f
596 private const val DEFAULT_FLING_VELOCITY = 120f
597 }
598
599 enum class FlingMode {
600 NO_FLING,
601
602 /**
603 * This is used for the closing target in custom cross-activity back animations. When the
604 * back gesture is flung, the closing target shrinks a bit further with a spring motion.
605 */
606 FLING_SHRINK,
607
608 /**
609 * This is used for the closing and opening target in the default cross-activity back
610 * animation. When the back gesture is flung, the closing and opening targets shrink a
611 * bit further and then bounce back with a spring motion.
612 */
613 FLING_BOUNCE
614 }
615 }
616
isDarkModenull617 private fun isDarkMode(context: Context): Boolean {
618 return context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
619 Configuration.UI_MODE_NIGHT_YES
620 }
621
setInterpolatedRectFnull622 internal fun RectF.setInterpolatedRectF(start: RectF, target: RectF, progress: Float) {
623 require(!(progress < 0 || progress > 1)) { "Progress value must be between 0 and 1" }
624 left = start.left + (target.left - start.left) * progress
625 top = start.top + (target.top - start.top) * progress
626 right = start.right + (target.right - start.right) * progress
627 bottom = start.bottom + (target.bottom - start.bottom) * progress
628 }
629
scaleCenterednull630 internal fun RectF.scaleCentered(
631 scale: Float,
632 pivotX: Float = left + width() / 2,
633 pivotY: Float = top + height() / 2
634 ) {
635 offset(-pivotX, -pivotY) // move pivot to origin
636 scale(scale)
637 offset(pivotX, pivotY) // Move back to the original position
638 }
639