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