1 /*
2  * Copyright (C) 2020 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.systemui.media
18 
19 import android.graphics.Outline
20 import android.util.MathUtils
21 import android.view.GestureDetector
22 import android.view.MotionEvent
23 import android.view.View
24 import android.view.ViewGroup
25 import android.view.ViewOutlineProvider
26 import androidx.core.view.GestureDetectorCompat
27 import androidx.dynamicanimation.animation.FloatPropertyCompat
28 import androidx.dynamicanimation.animation.SpringForce
29 import com.android.settingslib.Utils
30 import com.android.systemui.Gefingerpoken
31 import com.android.systemui.qs.PageIndicator
32 import com.android.systemui.R
33 import com.android.systemui.plugins.FalsingManager
34 import com.android.systemui.util.animation.PhysicsAnimator
35 import com.android.systemui.util.concurrency.DelayableExecutor
36 
37 private const val FLING_SLOP = 1000000
38 private const val DISMISS_DELAY = 100L
39 private const val RUBBERBAND_FACTOR = 0.2f
40 private const val SETTINGS_BUTTON_TRANSLATION_FRACTION = 0.3f
41 
42 /**
43  * Default spring configuration to use for animations where stiffness and/or damping ratio
44  * were not provided, and a default spring was not set via [PhysicsAnimator.setDefaultSpringConfig].
45  */
46 private val translationConfig = PhysicsAnimator.SpringConfig(
47         SpringForce.STIFFNESS_MEDIUM,
48         SpringForce.DAMPING_RATIO_LOW_BOUNCY)
49 
50 /**
51  * A controller class for the media scrollview, responsible for touch handling
52  */
53 class MediaCarouselScrollHandler(
54     private val scrollView: MediaScrollView,
55     private val pageIndicator: PageIndicator,
56     private val mainExecutor: DelayableExecutor,
57     private val dismissCallback: () -> Unit,
58     private var translationChangedListener: () -> Unit,
59     private val falsingManager: FalsingManager
60 ) {
61     /**
62      * Is the view in RTL
63      */
64     val isRtl: Boolean get() = scrollView.isLayoutRtl
65     /**
66      * Do we need falsing protection?
67      */
68     var falsingProtectionNeeded: Boolean = false
69     /**
70      * The width of the carousel
71      */
72     private var carouselWidth: Int = 0
73 
74     /**
75      * The height of the carousel
76      */
77     private var carouselHeight: Int = 0
78 
79     /**
80      * How much are we scrolled into the current media?
81      */
82     private var cornerRadius: Int = 0
83 
84     /**
85      * The content where the players are added
86      */
87     private var mediaContent: ViewGroup
88     /**
89      * The gesture detector to detect touch gestures
90      */
91     private val gestureDetector: GestureDetectorCompat
92 
93     /**
94      * The settings button view
95      */
96     private lateinit var settingsButton: View
97 
98     /**
99      * What's the currently active player index?
100      */
101     var activeMediaIndex: Int = 0
102         private set
103     /**
104      * How much are we scrolled into the current media?
105      */
106     private var scrollIntoCurrentMedia: Int = 0
107 
108     /**
109      * how much is the content translated in X
110      */
111     var contentTranslation = 0.0f
112         private set(value) {
113             field = value
114             mediaContent.translationX = value
115             updateSettingsPresentation()
116             translationChangedListener.invoke()
117             updateClipToOutline()
118         }
119 
120     /**
121      * The width of a player including padding
122      */
123     var playerWidthPlusPadding: Int = 0
124         set(value) {
125             field = value
126             // The player width has changed, let's update the scroll position to make sure
127             // it's still at the same place
128             var newRelativeScroll = activeMediaIndex * playerWidthPlusPadding
129             if (scrollIntoCurrentMedia > playerWidthPlusPadding) {
130                 newRelativeScroll += playerWidthPlusPadding -
131                         (scrollIntoCurrentMedia - playerWidthPlusPadding)
132             } else {
133                 newRelativeScroll += scrollIntoCurrentMedia
134             }
135             scrollView.relativeScrollX = newRelativeScroll
136         }
137 
138     /**
139      * Does the dismiss currently show the setting cog?
140      */
141     var showsSettingsButton: Boolean = false
142 
143     /**
144      * A utility to detect gestures, used in the touch listener
145      */
146     private val gestureListener = object : GestureDetector.SimpleOnGestureListener() {
onFlingnull147         override fun onFling(
148             eStart: MotionEvent?,
149             eCurrent: MotionEvent?,
150             vX: Float,
151             vY: Float
152         ) = onFling(vX, vY)
153 
154         override fun onScroll(
155             down: MotionEvent?,
156             lastMotion: MotionEvent?,
157             distanceX: Float,
158             distanceY: Float
159         ) = onScroll(down!!, lastMotion!!, distanceX)
160 
161         override fun onDown(e: MotionEvent?): Boolean {
162             if (falsingProtectionNeeded) {
163                 falsingManager.onNotificationStartDismissing()
164             }
165             return false
166         }
167     }
168 
169     /**
170      * The touch listener for the scroll view
171      */
172     private val touchListener = object : Gefingerpoken {
onTouchEventnull173         override fun onTouchEvent(motionEvent: MotionEvent?) = onTouch(motionEvent!!)
174         override fun onInterceptTouchEvent(ev: MotionEvent?) = onInterceptTouch(ev!!)
175     }
176 
177     /**
178      * A listener that is invoked when the scrolling changes to update player visibilities
179      */
180     private val scrollChangedListener = object : View.OnScrollChangeListener {
181         override fun onScrollChange(
182             v: View?,
183             scrollX: Int,
184             scrollY: Int,
185             oldScrollX: Int,
186             oldScrollY: Int
187         ) {
188             if (playerWidthPlusPadding == 0) {
189                 return
190             }
191             val relativeScrollX = scrollView.relativeScrollX
192             onMediaScrollingChanged(relativeScrollX / playerWidthPlusPadding,
193                     relativeScrollX % playerWidthPlusPadding)
194         }
195     }
196 
197     init {
198         gestureDetector = GestureDetectorCompat(scrollView.context, gestureListener)
199         scrollView.touchListener = touchListener
200         scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER)
201         mediaContent = scrollView.contentContainer
202         scrollView.setOnScrollChangeListener(scrollChangedListener)
203         scrollView.outlineProvider = object : ViewOutlineProvider() {
getOutlinenull204             override fun getOutline(view: View?, outline: Outline?) {
205                 outline?.setRoundRect(0, 0, carouselWidth, carouselHeight, cornerRadius.toFloat())
206             }
207         }
208     }
209 
onSettingsButtonUpdatednull210     fun onSettingsButtonUpdated(button: View) {
211         settingsButton = button
212         // We don't have a context to resolve, lets use the settingsbuttons one since that is
213         // reinflated appropriately
214         cornerRadius = settingsButton.resources.getDimensionPixelSize(
215                 Utils.getThemeAttr(settingsButton.context, android.R.attr.dialogCornerRadius))
216         updateSettingsPresentation()
217         scrollView.invalidateOutline()
218     }
219 
updateSettingsPresentationnull220     private fun updateSettingsPresentation() {
221         if (showsSettingsButton) {
222             val settingsOffset = MathUtils.map(
223                     0.0f,
224                     getMaxTranslation().toFloat(),
225                     0.0f,
226                     1.0f,
227                     Math.abs(contentTranslation))
228             val settingsTranslation = (1.0f - settingsOffset) * -settingsButton.width *
229                     SETTINGS_BUTTON_TRANSLATION_FRACTION
230             val newTranslationX = if (isRtl) {
231                 // In RTL, the 0-placement is on the right side of the view, not the left...
232                 if (contentTranslation > 0) {
233                     -(scrollView.width - settingsTranslation - settingsButton.width)
234                 } else {
235                     -settingsTranslation
236                 }
237             } else {
238                 if (contentTranslation > 0) {
239                     settingsTranslation
240                 } else {
241                     scrollView.width - settingsTranslation - settingsButton.width
242                 }
243             }
244             val rotation = (1.0f - settingsOffset) * 50
245             settingsButton.rotation = rotation * -Math.signum(contentTranslation)
246             val alpha = MathUtils.saturate(MathUtils.map(0.5f, 1.0f, 0.0f, 1.0f, settingsOffset))
247             settingsButton.alpha = alpha
248             settingsButton.visibility = if (alpha != 0.0f) View.VISIBLE else View.INVISIBLE
249             settingsButton.translationX = newTranslationX
250             settingsButton.translationY = (scrollView.height - settingsButton.height) / 2.0f
251         } else {
252             settingsButton.visibility = View.INVISIBLE
253         }
254     }
255 
onTouchnull256     private fun onTouch(motionEvent: MotionEvent): Boolean {
257         val isUp = motionEvent.action == MotionEvent.ACTION_UP
258         if (isUp && falsingProtectionNeeded) {
259             falsingManager.onNotificationStopDismissing()
260         }
261         if (gestureDetector.onTouchEvent(motionEvent)) {
262             if (isUp) {
263                 // If this is an up and we're flinging, we don't want to have this touch reach
264                 // the view, otherwise that would scroll, while we are trying to snap to the
265                 // new page. Let's dispatch a cancel instead.
266                 scrollView.cancelCurrentScroll()
267                 return true
268             } else {
269                 // Pass touches to the scrollView
270                 return false
271             }
272         }
273         if (isUp || motionEvent.action == MotionEvent.ACTION_CANCEL) {
274             // It's an up and the fling didn't take it above
275             val relativePos = scrollView.relativeScrollX % playerWidthPlusPadding
276             val scrollXAmount: Int
277             if (relativePos > playerWidthPlusPadding / 2) {
278                 scrollXAmount = playerWidthPlusPadding - relativePos
279             } else {
280                 scrollXAmount = -1 * relativePos
281             }
282             if (scrollXAmount != 0) {
283                 // Delay the scrolling since scrollView calls springback which cancels
284                 // the animation again..
285                 mainExecutor.execute {
286                     scrollView.smoothScrollBy(if (isRtl) -scrollXAmount else scrollXAmount, 0)
287                 }
288             }
289             val currentTranslation = scrollView.getContentTranslation()
290             if (currentTranslation != 0.0f) {
291                 // We started a Swipe but didn't end up with a fling. Let's either go to the
292                 // dismissed position or go back.
293                 val springBack = Math.abs(currentTranslation) < getMaxTranslation() / 2 ||
294                         isFalseTouch()
295                 val newTranslation: Float
296                 if (springBack) {
297                     newTranslation = 0.0f
298                 } else {
299                     newTranslation = getMaxTranslation() * Math.signum(currentTranslation)
300                     if (!showsSettingsButton) {
301                         // Delay the dismiss a bit to avoid too much overlap. Waiting until the
302                         // animation has finished also feels a bit too slow here.
303                         mainExecutor.executeDelayed({
304                             dismissCallback.invoke()
305                         }, DISMISS_DELAY)
306                     }
307                 }
308                 PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION,
309                         newTranslation, startVelocity = 0.0f, config = translationConfig).start()
310                 scrollView.animationTargetX = newTranslation
311             }
312         }
313         // Always pass touches to the scrollView
314         return false
315     }
316 
isFalseTouchnull317     private fun isFalseTouch() = falsingProtectionNeeded && falsingManager.isFalseTouch
318 
319     private fun getMaxTranslation() = if (showsSettingsButton) {
320             settingsButton.width
321         } else {
322             playerWidthPlusPadding
323         }
324 
onInterceptTouchnull325     private fun onInterceptTouch(motionEvent: MotionEvent): Boolean {
326         return gestureDetector.onTouchEvent(motionEvent)
327     }
328 
onScrollnull329     fun onScroll(
330         down: MotionEvent,
331         lastMotion: MotionEvent,
332         distanceX: Float
333     ): Boolean {
334         val totalX = lastMotion.x - down.x
335         val currentTranslation = scrollView.getContentTranslation()
336         if (currentTranslation != 0.0f ||
337                 !scrollView.canScrollHorizontally((-totalX).toInt())) {
338             var newTranslation = currentTranslation - distanceX
339             val absTranslation = Math.abs(newTranslation)
340             if (absTranslation > getMaxTranslation()) {
341                 // Rubberband all translation above the maximum
342                 if (Math.signum(distanceX) != Math.signum(currentTranslation)) {
343                     // The movement is in the same direction as our translation,
344                     // Let's rubberband it.
345                     if (Math.abs(currentTranslation) > getMaxTranslation()) {
346                         // we were already overshooting before. Let's add the distance
347                         // fully rubberbanded.
348                         newTranslation = currentTranslation - distanceX * RUBBERBAND_FACTOR
349                     } else {
350                         // We just crossed the boundary, let's rubberband it all
351                         newTranslation = Math.signum(newTranslation) * (getMaxTranslation() +
352                                 (absTranslation - getMaxTranslation()) * RUBBERBAND_FACTOR)
353                     }
354                 } // Otherwise we don't have do do anything, and will remove the unrubberbanded
355                 // translation
356             }
357             if (Math.signum(newTranslation) != Math.signum(currentTranslation) &&
358                     currentTranslation != 0.0f) {
359                 // We crossed the 0.0 threshold of the translation. Let's see if we're allowed
360                 // to scroll into the new direction
361                 if (scrollView.canScrollHorizontally(-newTranslation.toInt())) {
362                     // We can actually scroll in the direction where we want to translate,
363                     // Let's make sure to stop at 0
364                     newTranslation = 0.0f
365                 }
366             }
367             val physicsAnimator = PhysicsAnimator.getInstance(this)
368             if (physicsAnimator.isRunning()) {
369                 physicsAnimator.spring(CONTENT_TRANSLATION,
370                         newTranslation, startVelocity = 0.0f, config = translationConfig).start()
371             } else {
372                 contentTranslation = newTranslation
373             }
374             scrollView.animationTargetX = newTranslation
375             return true
376         }
377         return false
378     }
379 
onFlingnull380     private fun onFling(
381         vX: Float,
382         vY: Float
383     ): Boolean {
384         if (vX * vX < 0.5 * vY * vY) {
385             return false
386         }
387         if (vX * vX < FLING_SLOP) {
388             return false
389         }
390         val currentTranslation = scrollView.getContentTranslation()
391         if (currentTranslation != 0.0f) {
392             // We're translated and flung. Let's see if the fling is in the same direction
393             val newTranslation: Float
394             if (Math.signum(vX) != Math.signum(currentTranslation) || isFalseTouch()) {
395                 // The direction of the fling isn't the same as the translation, let's go to 0
396                 newTranslation = 0.0f
397             } else {
398                 newTranslation = getMaxTranslation() * Math.signum(currentTranslation)
399                 // Delay the dismiss a bit to avoid too much overlap. Waiting until the animation
400                 // has finished also feels a bit too slow here.
401                 if (!showsSettingsButton) {
402                     mainExecutor.executeDelayed({
403                         dismissCallback.invoke()
404                     }, DISMISS_DELAY)
405                 }
406             }
407             PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION,
408                     newTranslation, startVelocity = vX, config = translationConfig).start()
409             scrollView.animationTargetX = newTranslation
410         } else {
411             // We're flinging the player! Let's go either to the previous or to the next player
412             val pos = scrollView.relativeScrollX
413             val currentIndex = if (playerWidthPlusPadding > 0) pos / playerWidthPlusPadding else 0
414             val flungTowardEnd = if (isRtl) vX > 0 else vX < 0
415             var destIndex = if (flungTowardEnd) currentIndex + 1 else currentIndex
416             destIndex = Math.max(0, destIndex)
417             destIndex = Math.min(mediaContent.getChildCount() - 1, destIndex)
418             val view = mediaContent.getChildAt(destIndex)
419             // We need to post this since we're dispatching a touch to the underlying view to cancel
420             // but canceling will actually abort the animation.
421             mainExecutor.execute {
422                 scrollView.smoothScrollTo(view.left, scrollView.scrollY)
423             }
424         }
425         return true
426     }
427 
428     /**
429      * Reset the translation of the players when swiped
430      */
resetTranslationnull431     fun resetTranslation(animate: Boolean = false) {
432         if (scrollView.getContentTranslation() != 0.0f) {
433             if (animate) {
434                 PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION,
435                         0.0f, config = translationConfig).start()
436                 scrollView.animationTargetX = 0.0f
437             } else {
438                 PhysicsAnimator.getInstance(this).cancel()
439                 contentTranslation = 0.0f
440             }
441         }
442     }
443 
updateClipToOutlinenull444     private fun updateClipToOutline() {
445         val clip = contentTranslation != 0.0f || scrollIntoCurrentMedia != 0
446         scrollView.clipToOutline = clip
447     }
448 
onMediaScrollingChangednull449     private fun onMediaScrollingChanged(newIndex: Int, scrollInAmount: Int) {
450         val wasScrolledIn = scrollIntoCurrentMedia != 0
451         scrollIntoCurrentMedia = scrollInAmount
452         val nowScrolledIn = scrollIntoCurrentMedia != 0
453         if (newIndex != activeMediaIndex || wasScrolledIn != nowScrolledIn) {
454             activeMediaIndex = newIndex
455             updatePlayerVisibilities()
456         }
457         val relativeLocation = activeMediaIndex.toFloat() + if (playerWidthPlusPadding > 0)
458             scrollInAmount.toFloat() / playerWidthPlusPadding else 0f
459         // Fix the location, because PageIndicator does not handle RTL internally
460         val location = if (isRtl) {
461             mediaContent.childCount - relativeLocation - 1
462         } else {
463             relativeLocation
464         }
465         pageIndicator.setLocation(location)
466         updateClipToOutline()
467     }
468 
469     /**
470      * Notified whenever the players or their order has changed
471      */
onPlayersChangednull472     fun onPlayersChanged() {
473         updatePlayerVisibilities()
474         updateMediaPaddings()
475     }
476 
updateMediaPaddingsnull477     private fun updateMediaPaddings() {
478         val padding = scrollView.context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
479         val childCount = mediaContent.childCount
480         for (i in 0 until childCount) {
481             val mediaView = mediaContent.getChildAt(i)
482             val desiredPaddingEnd = if (i == childCount - 1) 0 else padding
483             val layoutParams = mediaView.layoutParams as ViewGroup.MarginLayoutParams
484             if (layoutParams.marginEnd != desiredPaddingEnd) {
485                 layoutParams.marginEnd = desiredPaddingEnd
486                 mediaView.layoutParams = layoutParams
487             }
488         }
489     }
490 
updatePlayerVisibilitiesnull491     private fun updatePlayerVisibilities() {
492         val scrolledIn = scrollIntoCurrentMedia != 0
493         for (i in 0 until mediaContent.childCount) {
494             val view = mediaContent.getChildAt(i)
495             val visible = (i == activeMediaIndex) || ((i == (activeMediaIndex + 1)) && scrolledIn)
496             view.visibility = if (visible) View.VISIBLE else View.INVISIBLE
497         }
498     }
499 
500     /**
501      * Notify that a player will be removed right away. This gives us the opporunity to look
502      * where it was and update our scroll position.
503      */
onPrePlayerRemovednull504     fun onPrePlayerRemoved(removed: MediaControlPanel) {
505         val removedIndex = mediaContent.indexOfChild(removed.view?.player)
506         // If the removed index is less than the activeMediaIndex, then we need to decrement it.
507         // RTL has no effect on this, because indices are always relative (start-to-end).
508         // Update the index 'manually' since we won't always get a call to onMediaScrollingChanged
509         val beforeActive = removedIndex <= activeMediaIndex
510         if (beforeActive) {
511             activeMediaIndex = Math.max(0, activeMediaIndex - 1)
512         }
513         // If the removed media item is "left of" the active one (in an absolute sense), we need to
514         // scroll the view to keep that player in view.  This is because scroll position is always
515         // calculated from left to right.
516         val leftOfActive = if (isRtl) !beforeActive else beforeActive
517         if (leftOfActive) {
518             scrollView.scrollX = Math.max(scrollView.scrollX - playerWidthPlusPadding, 0)
519         }
520     }
521 
522     /**
523      * Update the bounds of the carousel
524      */
setCarouselBoundsnull525     fun setCarouselBounds(currentCarouselWidth: Int, currentCarouselHeight: Int) {
526         if (currentCarouselHeight != carouselHeight || currentCarouselWidth != carouselHeight) {
527             carouselWidth = currentCarouselWidth
528             carouselHeight = currentCarouselHeight
529             scrollView.invalidateOutline()
530         }
531     }
532 
533     /**
534      * Reset the MediaScrollView to the start.
535      */
scrollToStartnull536     fun scrollToStart() {
537         scrollView.relativeScrollX = 0
538     }
539 
540     companion object {
541         private val CONTENT_TRANSLATION = object : FloatPropertyCompat<MediaCarouselScrollHandler>(
542                 "contentTranslation") {
getValuenull543             override fun getValue(handler: MediaCarouselScrollHandler): Float {
544                 return handler.contentTranslation
545             }
546 
setValuenull547             override fun setValue(handler: MediaCarouselScrollHandler, value: Float) {
548                 handler.contentTranslation = value
549             }
550         }
551     }
552 }