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