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