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