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