1 package com.android.systemui.navigationbar.gestural
2 
3 import android.content.Context
4 import android.content.res.Configuration
5 import android.graphics.Canvas
6 import android.graphics.Paint
7 import android.graphics.Path
8 import android.graphics.RectF
9 import android.util.MathUtils.min
10 import android.view.View
11 import androidx.dynamicanimation.animation.FloatPropertyCompat
12 import androidx.dynamicanimation.animation.SpringAnimation
13 import androidx.dynamicanimation.animation.SpringForce
14 import com.android.internal.util.LatencyTracker
15 import com.android.settingslib.Utils
16 import com.android.systemui.navigationbar.gestural.BackPanelController.DelayedOnAnimationEndListener
17 
18 private const val TAG = "BackPanel"
19 private const val DEBUG = false
20 
21 class BackPanel(context: Context, private val latencyTracker: LatencyTracker) : View(context) {
22 
23     var arrowsPointLeft = false
24         set(value) {
25             if (field != value) {
26                 invalidate()
27                 field = value
28             }
29         }
30 
31     // Arrow color and shape
32     private val arrowPath = Path()
33     private val arrowPaint = Paint()
34 
35     // Arrow background color and shape
36     private var arrowBackgroundRect = RectF()
37     private var arrowBackgroundPaint = Paint()
38 
39     // True if the panel is currently on the left of the screen
40     var isLeftPanel = false
41 
42     /** Used to track back arrow latency from [android.view.MotionEvent.ACTION_DOWN] to [onDraw] */
43     private var trackingBackArrowLatency = false
44 
45     /** The length of the arrow measured horizontally. Used for animating [arrowPath] */
46     private var arrowLength =
47         AnimatedFloat(
48             name = "arrowLength",
49             minimumVisibleChange = SpringAnimation.MIN_VISIBLE_CHANGE_PIXELS
50         )
51 
52     /**
53      * The height of the arrow measured vertically from its center to its top (i.e. half the total
54      * height). Used for animating [arrowPath]
55      */
56     var arrowHeight =
57         AnimatedFloat(
58             name = "arrowHeight",
59             minimumVisibleChange = SpringAnimation.MIN_VISIBLE_CHANGE_ROTATION_DEGREES
60         )
61 
62     val backgroundWidth =
63         AnimatedFloat(
64             name = "backgroundWidth",
65             minimumVisibleChange = SpringAnimation.MIN_VISIBLE_CHANGE_PIXELS,
66             minimumValue = 0f,
67         )
68 
69     val backgroundHeight =
70         AnimatedFloat(
71             name = "backgroundHeight",
72             minimumVisibleChange = SpringAnimation.MIN_VISIBLE_CHANGE_PIXELS,
73             minimumValue = 0f,
74         )
75 
76     /**
77      * Corners of the background closer to the edge of the screen (where the arrow appeared from).
78      * Used for animating [arrowBackgroundRect]
79      */
80     val backgroundEdgeCornerRadius = AnimatedFloat("backgroundEdgeCornerRadius")
81 
82     /**
83      * Corners of the background further from the edge of the screens (toward the direction the
84      * arrow is being dragged). Used for animating [arrowBackgroundRect]
85      */
86     val backgroundFarCornerRadius = AnimatedFloat("backgroundFarCornerRadius")
87 
88     var scale =
89         AnimatedFloat(
90             name = "scale",
91             minimumVisibleChange = SpringAnimation.MIN_VISIBLE_CHANGE_SCALE,
92             minimumValue = 0f
93         )
94 
95     val scalePivotX =
96         AnimatedFloat(
97             name = "scalePivotX",
98             minimumVisibleChange = SpringAnimation.MIN_VISIBLE_CHANGE_PIXELS,
99             minimumValue = backgroundWidth.pos / 2,
100         )
101 
102     /**
103      * Left/right position of the background relative to the canvas. Also corresponds with the
104      * background's margin relative to the screen edge. The arrow will be centered within the
105      * background.
106      */
107     var horizontalTranslation = AnimatedFloat(name = "horizontalTranslation")
108 
109     var arrowAlpha =
110         AnimatedFloat(
111             name = "arrowAlpha",
112             minimumVisibleChange = SpringAnimation.MIN_VISIBLE_CHANGE_ALPHA,
113             minimumValue = 0f,
114             maximumValue = 1f
115         )
116 
117     val backgroundAlpha =
118         AnimatedFloat(
119             name = "backgroundAlpha",
120             minimumVisibleChange = SpringAnimation.MIN_VISIBLE_CHANGE_ALPHA,
121             minimumValue = 0f,
122             maximumValue = 1f
123         )
124 
125     private val allAnimatedFloat =
126         setOf(
127             arrowLength,
128             arrowHeight,
129             backgroundWidth,
130             backgroundEdgeCornerRadius,
131             backgroundFarCornerRadius,
132             scalePivotX,
133             scale,
134             horizontalTranslation,
135             arrowAlpha,
136             backgroundAlpha
137         )
138 
139     /**
140      * Canvas vertical translation. How far up/down the arrow and background appear relative to the
141      * canvas.
142      */
143     var verticalTranslation = AnimatedFloat("verticalTranslation")
144 
145     /** Use for drawing debug info. Can only be set if [DEBUG]=true */
146     var drawDebugInfo: ((canvas: Canvas) -> Unit)? = null
147         set(value) {
148             if (DEBUG) field = value
149         }
150 
updateArrowPaintnull151     internal fun updateArrowPaint(arrowThickness: Float) {
152         arrowPaint.strokeWidth = arrowThickness
153 
154         val isDeviceInNightTheme =
155             resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
156                 Configuration.UI_MODE_NIGHT_YES
157 
158         arrowPaint.color =
159             Utils.getColorAttrDefaultColor(
160                 context,
161                 if (isDeviceInNightTheme) {
162                     com.android.internal.R.attr.materialColorOnSecondaryContainer
163                 } else {
164                     com.android.internal.R.attr.materialColorOnSecondaryFixed
165                 }
166             )
167 
168         arrowBackgroundPaint.color =
169             Utils.getColorAttrDefaultColor(
170                 context,
171                 if (isDeviceInNightTheme) {
172                     com.android.internal.R.attr.materialColorSecondaryContainer
173                 } else {
174                     com.android.internal.R.attr.materialColorSecondaryFixedDim
175                 }
176             )
177     }
178 
179     inner class AnimatedFloat(
180         name: String,
181         private val minimumVisibleChange: Float? = null,
182         private val minimumValue: Float? = null,
183         private val maximumValue: Float? = null,
184     ) {
185 
186         // The resting position when not stretched by a touch drag
187         private var restingPosition = 0f
188 
189         // The current position as updated by the SpringAnimation
190         var pos = 0f
191             private set(v) {
192                 if (field != v) {
193                     field = v
194                     invalidate()
195                 }
196             }
197 
198         private val animation: SpringAnimation
199         var spring: SpringForce
200             get() = animation.spring
201             set(value) {
202                 animation.cancel()
203                 animation.spring = value
204             }
205 
206         val isRunning: Boolean
207             get() = animation.isRunning
208 
addEndListenernull209         fun addEndListener(listener: DelayedOnAnimationEndListener) {
210             animation.addEndListener(listener)
211         }
212 
213         init {
214             val floatProp =
215                 object : FloatPropertyCompat<AnimatedFloat>(name) {
setValuenull216                     override fun setValue(animatedFloat: AnimatedFloat, value: Float) {
217                         animatedFloat.pos = value
218                     }
219 
getValuenull220                     override fun getValue(animatedFloat: AnimatedFloat): Float = animatedFloat.pos
221                 }
222             animation =
223                 SpringAnimation(this, floatProp).apply {
224                     spring = SpringForce()
225                     this@AnimatedFloat.minimumValue?.let { setMinValue(it) }
226                     this@AnimatedFloat.maximumValue?.let { setMaxValue(it) }
227                     this@AnimatedFloat.minimumVisibleChange?.let { minimumVisibleChange = it }
228                 }
229         }
230 
snapTonull231         fun snapTo(newPosition: Float) {
232             animation.cancel()
233             restingPosition = newPosition
234             animation.spring.finalPosition = newPosition
235             pos = newPosition
236         }
237 
snapToRestingPositionnull238         fun snapToRestingPosition() {
239             snapTo(restingPosition)
240         }
241 
stretchTonull242         fun stretchTo(
243             stretchAmount: Float,
244             startingVelocity: Float? = null,
245             springForce: SpringForce? = null
246         ) {
247             animation.apply {
248                 startingVelocity?.let {
249                     cancel()
250                     setStartVelocity(it)
251                 }
252                 springForce?.let { spring = springForce }
253                 animateToFinalPosition(restingPosition + stretchAmount)
254             }
255         }
256 
257         /**
258          * Animates to a new position ([finalPosition]) that is the given fraction ([amount])
259          * between the existing [restingPosition] and the new [finalPosition].
260          *
261          * The [restingPosition] will remain unchanged. Only the animation is updated.
262          */
stretchBynull263         fun stretchBy(finalPosition: Float?, amount: Float) {
264             val stretchedAmount = amount * ((finalPosition ?: 0f) - restingPosition)
265             animation.animateToFinalPosition(restingPosition + stretchedAmount)
266         }
267 
updateRestingPositionnull268         fun updateRestingPosition(pos: Float?, animated: Boolean = true) {
269             if (pos == null) return
270 
271             restingPosition = pos
272             if (animated) {
273                 animation.animateToFinalPosition(restingPosition)
274             } else {
275                 snapTo(restingPosition)
276             }
277         }
278 
cancelnull279         fun cancel() = animation.cancel()
280     }
281 
282     init {
283         visibility = GONE
284         arrowPaint.apply {
285             style = Paint.Style.STROKE
286             strokeCap = Paint.Cap.SQUARE
287         }
288         arrowBackgroundPaint.apply {
289             style = Paint.Style.FILL
290             strokeJoin = Paint.Join.ROUND
291             strokeCap = Paint.Cap.ROUND
292         }
293     }
294 
calculateArrowPathnull295     private fun calculateArrowPath(dx: Float, dy: Float): Path {
296         arrowPath.reset()
297         arrowPath.moveTo(dx, -dy)
298         arrowPath.lineTo(0f, 0f)
299         arrowPath.lineTo(dx, dy)
300         arrowPath.moveTo(dx, -dy)
301         return arrowPath
302     }
303 
addAnimationEndListenernull304     fun addAnimationEndListener(
305         animatedFloat: AnimatedFloat,
306         endListener: DelayedOnAnimationEndListener
307     ): Boolean {
308         return if (animatedFloat.isRunning) {
309             animatedFloat.addEndListener(endListener)
310             true
311         } else {
312             endListener.run()
313             false
314         }
315     }
316 
cancelAnimationsnull317     fun cancelAnimations() {
318         allAnimatedFloat.forEach { it.cancel() }
319     }
320 
setStretchnull321     fun setStretch(
322         horizontalTranslationStretchAmount: Float,
323         arrowStretchAmount: Float,
324         arrowAlphaStretchAmount: Float,
325         backgroundAlphaStretchAmount: Float,
326         backgroundWidthStretchAmount: Float,
327         backgroundHeightStretchAmount: Float,
328         edgeCornerStretchAmount: Float,
329         farCornerStretchAmount: Float,
330         fullyStretchedDimens: EdgePanelParams.BackIndicatorDimens
331     ) {
332         horizontalTranslation.stretchBy(
333             finalPosition = fullyStretchedDimens.horizontalTranslation,
334             amount = horizontalTranslationStretchAmount
335         )
336         arrowLength.stretchBy(
337             finalPosition = fullyStretchedDimens.arrowDimens.length,
338             amount = arrowStretchAmount
339         )
340         arrowHeight.stretchBy(
341             finalPosition = fullyStretchedDimens.arrowDimens.height,
342             amount = arrowStretchAmount
343         )
344         arrowAlpha.stretchBy(
345             finalPosition = fullyStretchedDimens.arrowDimens.alpha,
346             amount = arrowAlphaStretchAmount
347         )
348         backgroundAlpha.stretchBy(
349             finalPosition = fullyStretchedDimens.backgroundDimens.alpha,
350             amount = backgroundAlphaStretchAmount
351         )
352         backgroundWidth.stretchBy(
353             finalPosition = fullyStretchedDimens.backgroundDimens.width,
354             amount = backgroundWidthStretchAmount
355         )
356         backgroundHeight.stretchBy(
357             finalPosition = fullyStretchedDimens.backgroundDimens.height,
358             amount = backgroundHeightStretchAmount
359         )
360         backgroundEdgeCornerRadius.stretchBy(
361             finalPosition = fullyStretchedDimens.backgroundDimens.edgeCornerRadius,
362             amount = edgeCornerStretchAmount
363         )
364         backgroundFarCornerRadius.stretchBy(
365             finalPosition = fullyStretchedDimens.backgroundDimens.farCornerRadius,
366             amount = farCornerStretchAmount
367         )
368     }
369 
popOffEdgenull370     fun popOffEdge(startingVelocity: Float) {
371         scale.stretchTo(stretchAmount = 0f, startingVelocity = startingVelocity * -.8f)
372         horizontalTranslation.stretchTo(stretchAmount = 0f, startingVelocity * 200f)
373     }
374 
popScalenull375     fun popScale(startingVelocity: Float) {
376         scalePivotX.snapTo(backgroundWidth.pos / 2)
377         scale.stretchTo(stretchAmount = 0f, startingVelocity = startingVelocity)
378     }
379 
popArrowAlphanull380     fun popArrowAlpha(startingVelocity: Float, springForce: SpringForce? = null) {
381         arrowAlpha.stretchTo(
382             stretchAmount = 0f,
383             startingVelocity = startingVelocity,
384             springForce = springForce
385         )
386     }
387 
resetStretchnull388     fun resetStretch() {
389         backgroundAlpha.snapTo(1f)
390         verticalTranslation.snapTo(0f)
391         scale.snapTo(1f)
392 
393         horizontalTranslation.snapToRestingPosition()
394         arrowLength.snapToRestingPosition()
395         arrowHeight.snapToRestingPosition()
396         arrowAlpha.snapToRestingPosition()
397         backgroundWidth.snapToRestingPosition()
398         backgroundHeight.snapToRestingPosition()
399         backgroundEdgeCornerRadius.snapToRestingPosition()
400         backgroundFarCornerRadius.snapToRestingPosition()
401     }
402 
403     /** Updates resting arrow and background size not accounting for stretch */
setRestingDimensnull404     internal fun setRestingDimens(
405         restingParams: EdgePanelParams.BackIndicatorDimens,
406         animate: Boolean = true
407     ) {
408         horizontalTranslation.updateRestingPosition(restingParams.horizontalTranslation)
409         scale.updateRestingPosition(restingParams.scale)
410         backgroundAlpha.updateRestingPosition(restingParams.backgroundDimens.alpha)
411 
412         arrowAlpha.updateRestingPosition(restingParams.arrowDimens.alpha, animate)
413         arrowLength.updateRestingPosition(restingParams.arrowDimens.length, animate)
414         arrowHeight.updateRestingPosition(restingParams.arrowDimens.height, animate)
415         scalePivotX.updateRestingPosition(restingParams.scalePivotX, animate)
416         backgroundWidth.updateRestingPosition(restingParams.backgroundDimens.width, animate)
417         backgroundHeight.updateRestingPosition(restingParams.backgroundDimens.height, animate)
418         backgroundEdgeCornerRadius.updateRestingPosition(
419             restingParams.backgroundDimens.edgeCornerRadius,
420             animate
421         )
422         backgroundFarCornerRadius.updateRestingPosition(
423             restingParams.backgroundDimens.farCornerRadius,
424             animate
425         )
426     }
427 
animateVerticallynull428     fun animateVertically(yPos: Float) = verticalTranslation.stretchTo(yPos)
429 
430     fun setSpring(
431         horizontalTranslation: SpringForce? = null,
432         verticalTranslation: SpringForce? = null,
433         scale: SpringForce? = null,
434         arrowLength: SpringForce? = null,
435         arrowHeight: SpringForce? = null,
436         arrowAlpha: SpringForce? = null,
437         backgroundAlpha: SpringForce? = null,
438         backgroundFarCornerRadius: SpringForce? = null,
439         backgroundEdgeCornerRadius: SpringForce? = null,
440         backgroundWidth: SpringForce? = null,
441         backgroundHeight: SpringForce? = null,
442     ) {
443         arrowLength?.let { this.arrowLength.spring = it }
444         arrowHeight?.let { this.arrowHeight.spring = it }
445         arrowAlpha?.let { this.arrowAlpha.spring = it }
446         backgroundAlpha?.let { this.backgroundAlpha.spring = it }
447         backgroundFarCornerRadius?.let { this.backgroundFarCornerRadius.spring = it }
448         backgroundEdgeCornerRadius?.let { this.backgroundEdgeCornerRadius.spring = it }
449         scale?.let { this.scale.spring = it }
450         backgroundWidth?.let { this.backgroundWidth.spring = it }
451         backgroundHeight?.let { this.backgroundHeight.spring = it }
452         horizontalTranslation?.let { this.horizontalTranslation.spring = it }
453         verticalTranslation?.let { this.verticalTranslation.spring = it }
454     }
455 
hasOverlappingRenderingnull456     override fun hasOverlappingRendering() = false
457 
458     override fun onDraw(canvas: Canvas) {
459         val edgeCorner = backgroundEdgeCornerRadius.pos
460         val farCorner = backgroundFarCornerRadius.pos
461         val halfHeight = backgroundHeight.pos / 2
462         val canvasWidth = width
463         val backgroundWidth = backgroundWidth.pos
464         val scalePivotX = scalePivotX.pos
465 
466         canvas.save()
467 
468         if (!isLeftPanel) canvas.scale(-1f, 1f, canvasWidth / 2.0f, 0f)
469 
470         canvas.translate(horizontalTranslation.pos, height * 0.5f + verticalTranslation.pos)
471 
472         canvas.scale(scale.pos, scale.pos, scalePivotX, 0f)
473 
474         val arrowBackground =
475             arrowBackgroundRect
476                 .apply {
477                     left = 0f
478                     top = -halfHeight
479                     right = backgroundWidth
480                     bottom = halfHeight
481                 }
482                 .toPathWithRoundCorners(
483                     topLeft = edgeCorner,
484                     bottomLeft = edgeCorner,
485                     topRight = farCorner,
486                     bottomRight = farCorner
487                 )
488         canvas.drawPath(
489             arrowBackground,
490             arrowBackgroundPaint.apply { alpha = (255 * backgroundAlpha.pos).toInt() }
491         )
492 
493         val dx = arrowLength.pos
494         val dy = arrowHeight.pos
495 
496         // How far the arrow bounding box should be from the edge of the screen. Measured from
497         // either the tip or the back of the arrow, whichever is closer
498         val arrowOffset = (backgroundWidth - dx) / 2
499         canvas.translate(
500             /* dx= */ arrowOffset,
501             /* dy= */ 0f /* pass 0 for the y position since the canvas was already translated */
502         )
503 
504         val arrowPointsAwayFromEdge = !arrowsPointLeft.xor(isLeftPanel)
505         if (arrowPointsAwayFromEdge) {
506             canvas.apply {
507                 scale(-1f, 1f, 0f, 0f)
508                 translate(-dx, 0f)
509             }
510         }
511 
512         val arrowPath = calculateArrowPath(dx = dx, dy = dy)
513         val arrowPaint =
514             arrowPaint.apply { alpha = (255 * min(arrowAlpha.pos, backgroundAlpha.pos)).toInt() }
515         canvas.drawPath(arrowPath, arrowPaint)
516         canvas.restore()
517 
518         if (trackingBackArrowLatency) {
519             latencyTracker.onActionEnd(LatencyTracker.ACTION_SHOW_BACK_ARROW)
520             trackingBackArrowLatency = false
521         }
522 
523         if (DEBUG) drawDebugInfo?.invoke(canvas)
524     }
525 
startTrackingShowBackArrowLatencynull526     fun startTrackingShowBackArrowLatency() {
527         latencyTracker.onActionStart(LatencyTracker.ACTION_SHOW_BACK_ARROW)
528         trackingBackArrowLatency = true
529     }
530 
RectFnull531     private fun RectF.toPathWithRoundCorners(
532         topLeft: Float = 0f,
533         topRight: Float = 0f,
534         bottomRight: Float = 0f,
535         bottomLeft: Float = 0f
536     ): Path =
537         Path().apply {
538             val corners =
539                 floatArrayOf(
540                     topLeft,
541                     topLeft,
542                     topRight,
543                     topRight,
544                     bottomRight,
545                     bottomRight,
546                     bottomLeft,
547                     bottomLeft
548                 )
549             addRoundRect(this@toPathWithRoundCorners, corners, Path.Direction.CW)
550         }
551 }
552