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 }