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