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