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