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