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