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.annotation.VisibleForTesting 27 import androidx.core.view.GestureDetectorCompat 28 import androidx.dynamicanimation.animation.FloatPropertyCompat 29 import androidx.dynamicanimation.animation.SpringForce 30 import com.android.app.tracing.TraceStateLogger 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 import kotlin.math.sign 42 43 private const val FLING_SLOP = 1000000 44 @VisibleForTesting const val DISMISS_DELAY = 100L 45 private const val SCROLL_DELAY = 100L 46 private const val RUBBERBAND_FACTOR = 0.2f 47 private const val SETTINGS_BUTTON_TRANSLATION_FRACTION = 0.3f 48 private const val TAG = "MediaCarouselScrollHandler" 49 50 /** 51 * Default spring configuration to use for animations where stiffness and/or damping ratio were not 52 * provided, and a default spring was not set via [PhysicsAnimator.setDefaultSpringConfig]. 53 */ 54 private val translationConfig = 55 PhysicsAnimator.SpringConfig(SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_LOW_BOUNCY) 56 57 /** A controller class for the media scrollview, responsible for touch handling */ 58 class MediaCarouselScrollHandler( 59 private val scrollView: MediaScrollView, 60 private val pageIndicator: PageIndicator, 61 private val mainExecutor: DelayableExecutor, 62 val dismissCallback: () -> Unit, 63 private var translationChangedListener: () -> Unit, 64 private var seekBarUpdateListener: (visibleToUser: Boolean) -> Unit, 65 private val closeGuts: (immediate: Boolean) -> Unit, 66 private val falsingManager: FalsingManager, 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 @VisibleForTesting 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 /** Is scrolling disabled for the carousel */ 131 var scrollingDisabled: Boolean = false 132 133 /** Does the dismiss currently show the setting cog? */ 134 var showsSettingsButton: Boolean = false 135 136 /** A utility to detect gestures, used in the touch listener */ 137 private val gestureListener = 138 object : GestureDetector.SimpleOnGestureListener() { onFlingnull139 override fun onFling( 140 eStart: MotionEvent?, 141 eCurrent: MotionEvent, 142 vX: Float, 143 vY: Float, 144 ) = onFling(vX, vY) 145 146 override fun onScroll( 147 down: MotionEvent?, 148 lastMotion: MotionEvent, 149 distanceX: Float, 150 distanceY: Float, 151 ) = onScroll(down!!, lastMotion, distanceX) 152 153 override fun onDown(e: MotionEvent): Boolean { 154 return false 155 } 156 } 157 158 /** The touch listener for the scroll view */ 159 @VisibleForTesting 160 val touchListener = 161 object : Gefingerpoken { onTouchEventnull162 override fun onTouchEvent(motionEvent: MotionEvent?) = onTouch(motionEvent!!) 163 164 override fun onInterceptTouchEvent(ev: MotionEvent?) = onInterceptTouch(ev!!) 165 } 166 167 /** A listener that is invoked when the scrolling changes to update player visibilities */ 168 private val scrollChangedListener = 169 object : View.OnScrollChangeListener { 170 override fun onScrollChange( 171 v: View?, 172 scrollX: Int, 173 scrollY: Int, 174 oldScrollX: Int, 175 oldScrollY: Int, 176 ) { 177 if (playerWidthPlusPadding == 0) { 178 return 179 } 180 181 val relativeScrollX = scrollView.relativeScrollX 182 onMediaScrollingChanged( 183 relativeScrollX / playerWidthPlusPadding, 184 relativeScrollX % playerWidthPlusPadding, 185 ) 186 } 187 } 188 189 /** Whether the media card is visible to user if any */ 190 var visibleToUser: Boolean = false 191 set(value) { 192 if (field != value) { 193 field = value 194 seekBarUpdateListener.invoke(field) 195 visibleStateLogger.log("$visibleToUser") 196 } 197 } 198 199 /** Whether the quick setting is expanded or not */ 200 var qsExpanded: Boolean = false 201 202 init { 203 gestureDetector = GestureDetectorCompat(scrollView.context, gestureListener) 204 scrollView.touchListener = touchListener 205 scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER) 206 mediaContent = scrollView.contentContainer 207 scrollView.setOnScrollChangeListener(scrollChangedListener) 208 scrollView.outlineProvider = 209 object : ViewOutlineProvider() { getOutlinenull210 override fun getOutline(view: View?, outline: Outline?) { 211 outline?.setRoundRect( 212 0, 213 0, 214 carouselWidth, 215 carouselHeight, 216 cornerRadius.toFloat(), 217 ) 218 } 219 } 220 } 221 onSettingsButtonUpdatednull222 fun onSettingsButtonUpdated(button: View) { 223 settingsButton = button 224 // We don't have a context to resolve, lets use the settingsbuttons one since that is 225 // reinflated appropriately 226 cornerRadius = 227 settingsButton.resources.getDimensionPixelSize( 228 Utils.getThemeAttr(settingsButton.context, android.R.attr.dialogCornerRadius) 229 ) 230 updateSettingsPresentation() 231 scrollView.invalidateOutline() 232 } 233 updateSettingsPresentationnull234 private fun updateSettingsPresentation() { 235 if (showsSettingsButton && settingsButton.width > 0) { 236 val settingsOffset = 237 MathUtils.map( 238 0.0f, 239 getMaxTranslation().toFloat(), 240 0.0f, 241 1.0f, 242 Math.abs(contentTranslation), 243 ) 244 val settingsTranslation = 245 (1.0f - settingsOffset) * 246 -settingsButton.width * 247 SETTINGS_BUTTON_TRANSLATION_FRACTION 248 val newTranslationX = 249 if (isRtl) { 250 // In RTL, the 0-placement is on the right side of the view, not the left... 251 if (contentTranslation > 0) { 252 -(scrollView.width - settingsTranslation - settingsButton.width) 253 } else { 254 -settingsTranslation 255 } 256 } else { 257 if (contentTranslation > 0) { 258 settingsTranslation 259 } else { 260 scrollView.width - settingsTranslation - settingsButton.width 261 } 262 } 263 val rotation = (1.0f - settingsOffset) * 50 264 settingsButton.rotation = rotation * -Math.signum(contentTranslation) 265 val alpha = MathUtils.saturate(MathUtils.map(0.5f, 1.0f, 0.0f, 1.0f, settingsOffset)) 266 settingsButton.alpha = alpha 267 settingsButton.visibility = if (alpha != 0.0f) View.VISIBLE else View.INVISIBLE 268 settingsButton.translationX = newTranslationX 269 settingsButton.translationY = (scrollView.height - settingsButton.height) / 2.0f 270 } else { 271 settingsButton.visibility = View.INVISIBLE 272 } 273 } 274 onTouchnull275 private fun onTouch(motionEvent: MotionEvent): Boolean { 276 if (scrollingDisabled) { 277 return false 278 } 279 280 val isUp = motionEvent.action == MotionEvent.ACTION_UP 281 if (gestureDetector.onTouchEvent(motionEvent)) { 282 if (isUp) { 283 // If this is an up and we're flinging, we don't want to have this touch reach 284 // the view, otherwise that would scroll, while we are trying to snap to the 285 // new page. Let's dispatch a cancel instead. 286 scrollView.cancelCurrentScroll() 287 return true 288 } else { 289 // Pass touches to the scrollView 290 return false 291 } 292 } 293 if (motionEvent.action == MotionEvent.ACTION_MOVE) { 294 // cancel on going animation if there is any. 295 PhysicsAnimator.getInstance(this).cancel() 296 } else if (isUp || motionEvent.action == MotionEvent.ACTION_CANCEL) { 297 // It's an up and the fling didn't take it above 298 val relativePos = scrollView.relativeScrollX % playerWidthPlusPadding 299 val scrollXAmount: Int = 300 if (relativePos > playerWidthPlusPadding / 2) { 301 playerWidthPlusPadding - relativePos 302 } else { 303 -1 * relativePos 304 } 305 if (scrollXAmount != 0) { 306 val dx = if (isRtl) -scrollXAmount else scrollXAmount 307 val newScrollX = scrollView.scrollX + dx 308 // Delay the scrolling since scrollView calls springback which cancels 309 // the animation again.. 310 mainExecutor.execute { scrollView.smoothScrollTo(newScrollX, scrollView.scrollY) } 311 } 312 val currentTranslation = scrollView.getContentTranslation() 313 if (currentTranslation != 0.0f) { 314 // We started a Swipe but didn't end up with a fling. Let's either go to the 315 // dismissed position or go back. 316 val springBack = 317 Math.abs(currentTranslation) < getMaxTranslation() / 2 || isFalseTouch() 318 val newTranslation: Float 319 if (springBack) { 320 newTranslation = 0.0f 321 } else { 322 newTranslation = getMaxTranslation() * Math.signum(currentTranslation) 323 if (!showsSettingsButton) { 324 // Delay the dismiss a bit to avoid too much overlap. Waiting until the 325 // animation has finished also feels a bit too slow here. 326 mainExecutor.executeDelayed({ dismissCallback.invoke() }, DISMISS_DELAY) 327 } 328 } 329 PhysicsAnimator.getInstance(this) 330 .spring( 331 CONTENT_TRANSLATION, 332 newTranslation, 333 startVelocity = 0.0f, 334 config = translationConfig, 335 ) 336 .start() 337 scrollView.animationTargetX = newTranslation 338 } 339 } 340 // Always pass touches to the scrollView 341 return false 342 } 343 isFalseTouchnull344 private fun isFalseTouch() = 345 falsingProtectionNeeded && falsingManager.isFalseTouch(NOTIFICATION_DISMISS) 346 347 private fun getMaxTranslation() = 348 if (showsSettingsButton) { 349 settingsButton.width 350 } else { 351 playerWidthPlusPadding 352 } 353 onInterceptTouchnull354 private fun onInterceptTouch(motionEvent: MotionEvent): Boolean { 355 return gestureDetector.onTouchEvent(motionEvent) 356 } 357 onScrollnull358 fun onScroll(down: MotionEvent, lastMotion: MotionEvent, distanceX: Float): Boolean { 359 if (scrollingDisabled) { 360 return false 361 } 362 363 val totalX = lastMotion.x - down.x 364 val currentTranslation = scrollView.getContentTranslation() 365 if (currentTranslation != 0.0f || !scrollView.canScrollHorizontally((-totalX).toInt())) { 366 var newTranslation = currentTranslation - distanceX 367 val absTranslation = Math.abs(newTranslation) 368 if (absTranslation > getMaxTranslation()) { 369 // Rubberband all translation above the maximum 370 if (Math.signum(distanceX) != Math.signum(currentTranslation)) { 371 // The movement is in the same direction as our translation, 372 // Let's rubberband it. 373 if (Math.abs(currentTranslation) > getMaxTranslation()) { 374 // we were already overshooting before. Let's add the distance 375 // fully rubberbanded. 376 newTranslation = currentTranslation - distanceX * RUBBERBAND_FACTOR 377 } else { 378 // We just crossed the boundary, let's rubberband it all 379 newTranslation = 380 Math.signum(newTranslation) * 381 (getMaxTranslation() + 382 (absTranslation - getMaxTranslation()) * RUBBERBAND_FACTOR) 383 } 384 } // Otherwise we don't have do do anything, and will remove the unrubberbanded 385 // translation 386 } 387 if ( 388 Math.signum(newTranslation) != Math.signum(currentTranslation) && 389 currentTranslation != 0.0f 390 ) { 391 // We crossed the 0.0 threshold of the translation. Let's see if we're allowed 392 // to scroll into the new direction 393 if (scrollView.canScrollHorizontally(-newTranslation.toInt())) { 394 // We can actually scroll in the direction where we want to translate, 395 // Let's make sure to stop at 0 396 newTranslation = 0.0f 397 } 398 } 399 val physicsAnimator = PhysicsAnimator.getInstance(this) 400 if (physicsAnimator.isRunning()) { 401 physicsAnimator 402 .spring( 403 CONTENT_TRANSLATION, 404 newTranslation, 405 startVelocity = 0.0f, 406 config = translationConfig, 407 ) 408 .start() 409 } else { 410 contentTranslation = newTranslation 411 } 412 scrollView.animationTargetX = newTranslation 413 return true 414 } 415 return false 416 } 417 onFlingnull418 private fun onFling(vX: Float, vY: Float): Boolean { 419 if (scrollingDisabled) { 420 return false 421 } 422 423 if (vX * vX < 0.5 * vY * vY) { 424 return false 425 } 426 if (vX * vX < FLING_SLOP) { 427 return false 428 } 429 val currentTranslation = scrollView.getContentTranslation() 430 if (currentTranslation != 0.0f) { 431 // We're translated and flung. Let's see if the fling is in the same direction 432 val newTranslation: Float 433 if (Math.signum(vX) != Math.signum(currentTranslation) || isFalseTouch()) { 434 // The direction of the fling isn't the same as the translation, let's go to 0 435 newTranslation = 0.0f 436 } else { 437 newTranslation = getMaxTranslation() * Math.signum(currentTranslation) 438 // Delay the dismiss a bit to avoid too much overlap. Waiting until the animation 439 // has finished also feels a bit too slow here. 440 if (!showsSettingsButton) { 441 mainExecutor.executeDelayed({ dismissCallback.invoke() }, DISMISS_DELAY) 442 } 443 } 444 PhysicsAnimator.getInstance(this) 445 .spring( 446 CONTENT_TRANSLATION, 447 newTranslation, 448 startVelocity = vX, 449 config = translationConfig, 450 ) 451 .start() 452 scrollView.animationTargetX = newTranslation 453 } else { 454 // We're flinging the player! Let's go either to the previous or to the next player 455 val pos = scrollView.relativeScrollX 456 val currentIndex = if (playerWidthPlusPadding > 0) pos / playerWidthPlusPadding else 0 457 val flungTowardEnd = if (isRtl) vX > 0 else vX < 0 458 var destIndex = if (flungTowardEnd) currentIndex + 1 else currentIndex 459 destIndex = Math.max(0, destIndex) 460 destIndex = Math.min(mediaContent.getChildCount() - 1, destIndex) 461 val view = mediaContent.getChildAt(destIndex) 462 // We need to post this since we're dispatching a touch to the underlying view to cancel 463 // but canceling will actually abort the animation. 464 mainExecutor.execute { scrollView.smoothScrollTo(view.left, scrollView.scrollY) } 465 } 466 return true 467 } 468 469 /** Reset the translation of the players when swiped */ resetTranslationnull470 fun resetTranslation(animate: Boolean = false) { 471 if (scrollView.getContentTranslation() != 0.0f) { 472 if (animate) { 473 PhysicsAnimator.getInstance(this) 474 .spring(CONTENT_TRANSLATION, 0.0f, config = translationConfig) 475 .start() 476 scrollView.animationTargetX = 0.0f 477 } else { 478 PhysicsAnimator.getInstance(this).cancel() 479 contentTranslation = 0.0f 480 } 481 } 482 } 483 updateClipToOutlinenull484 private fun updateClipToOutline() { 485 val clip = contentTranslation != 0.0f || scrollIntoCurrentMedia != 0 486 scrollView.clipToOutline = clip 487 } 488 onMediaScrollingChangednull489 private fun onMediaScrollingChanged(newIndex: Int, scrollInAmount: Int) { 490 val wasScrolledIn = scrollIntoCurrentMedia != 0 491 scrollIntoCurrentMedia = scrollInAmount 492 val nowScrolledIn = scrollIntoCurrentMedia != 0 493 if (newIndex != visibleMediaIndex || wasScrolledIn != nowScrolledIn) { 494 val oldIndex = visibleMediaIndex 495 visibleMediaIndex = newIndex 496 if (oldIndex != visibleMediaIndex && visibleToUser) { 497 logger.logMediaCarouselPage(newIndex) 498 } 499 closeGuts(false) 500 updatePlayerVisibilities() 501 } 502 val relativeLocation = 503 visibleMediaIndex.toFloat() + 504 if (playerWidthPlusPadding > 0) { 505 scrollInAmount.toFloat() / playerWidthPlusPadding 506 } else { 507 0f 508 } 509 // Fix the location, because PageIndicator does not handle RTL internally 510 val location = 511 if (isRtl) { 512 mediaContent.childCount - relativeLocation - 1 513 } else { 514 relativeLocation 515 } 516 pageIndicator.setLocation(location) 517 updateClipToOutline() 518 } 519 520 /** Notified whenever the players or their order has changed */ onPlayersChangednull521 fun onPlayersChanged() { 522 updatePlayerVisibilities() 523 updateMediaPaddings() 524 } 525 updateMediaPaddingsnull526 private fun updateMediaPaddings() { 527 val padding = scrollView.context.resources.getDimensionPixelSize(R.dimen.qs_media_padding) 528 val childCount = mediaContent.childCount 529 for (i in 0 until childCount) { 530 val mediaView = mediaContent.getChildAt(i) 531 val desiredPaddingEnd = if (i == childCount - 1) 0 else padding 532 val layoutParams = mediaView.layoutParams as ViewGroup.MarginLayoutParams 533 if (layoutParams.marginEnd != desiredPaddingEnd) { 534 layoutParams.marginEnd = desiredPaddingEnd 535 mediaView.layoutParams = layoutParams 536 } 537 } 538 } 539 updatePlayerVisibilitiesnull540 private fun updatePlayerVisibilities() { 541 val scrolledIn = scrollIntoCurrentMedia != 0 542 for (i in 0 until mediaContent.childCount) { 543 val view = mediaContent.getChildAt(i) 544 val visible = (i == visibleMediaIndex) || ((i == (visibleMediaIndex + 1)) && scrolledIn) 545 view.visibility = if (visible) View.VISIBLE else View.INVISIBLE 546 } 547 } 548 549 /** 550 * Notify that a player will be removed right away. This gives us the opporunity to look where 551 * it was and update our scroll position. 552 */ onPrePlayerRemovednull553 fun onPrePlayerRemoved(player: TransitionLayout?) { 554 val removedIndex = mediaContent.indexOfChild(player) 555 // If the removed index is less than the visibleMediaIndex, then we need to decrement it. 556 // RTL has no effect on this, because indices are always relative (start-to-end). 557 // Update the index 'manually' since we won't always get a call to onMediaScrollingChanged 558 val beforeActive = removedIndex <= visibleMediaIndex 559 if (beforeActive) { 560 visibleMediaIndex = Math.max(0, visibleMediaIndex - 1) 561 } 562 // If the removed media item is "left of" the active one (in an absolute sense), we need to 563 // scroll the view to keep that player in view. This is because scroll position is always 564 // calculated from left to right. 565 // For RTL, we need to scroll if the visible media player is the last item. 566 val leftOfActive = if (isRtl && visibleMediaIndex != 0) !beforeActive else beforeActive 567 if (leftOfActive) { 568 scrollView.scrollX = Math.max(scrollView.scrollX - playerWidthPlusPadding, 0) 569 } 570 } 571 572 /** Update the bounds of the carousel */ setCarouselBoundsnull573 fun setCarouselBounds(currentCarouselWidth: Int, currentCarouselHeight: Int) { 574 if (currentCarouselHeight != carouselHeight || currentCarouselWidth != carouselHeight) { 575 carouselWidth = currentCarouselWidth 576 carouselHeight = currentCarouselHeight 577 scrollView.invalidateOutline() 578 } 579 } 580 581 /** Reset the MediaScrollView to the start. */ scrollToStartnull582 fun scrollToStart() { 583 scrollView.relativeScrollX = 0 584 } 585 586 /** 587 * Smooth scroll to the destination player. 588 * 589 * @param sourceIndex optional source index to indicate where the scroll should begin. 590 * @param destIndex destination index to indicate where the scroll should end. 591 */ scrollToPlayernull592 fun scrollToPlayer(sourceIndex: Int = -1, destIndex: Int) { 593 if (scrollingDisabled) { 594 return 595 } 596 if (sourceIndex >= 0 && sourceIndex < mediaContent.childCount) { 597 scrollView.relativeScrollX = sourceIndex * playerWidthPlusPadding 598 } 599 val destIndex = Math.min(mediaContent.getChildCount() - 1, destIndex) 600 val view = mediaContent.getChildAt(destIndex) 601 // We need to post this to wait for the active player becomes visible. 602 mainExecutor.executeDelayed( 603 { scrollView.smoothScrollTo(view.left, scrollView.scrollY) }, 604 SCROLL_DELAY, 605 ) 606 } 607 608 /** 609 * Scrolls the media carousel by the number of players specified by [step]. If scrolling beyond 610 * the carousel's bounds: 611 * - If the carousel is not dismissible, the settings button is displayed. 612 * - If the carousel is dismissible, no action taken. 613 * 614 * @param step A positive number means next, and negative means previous. 615 */ scrollByStepnull616 fun scrollByStep(step: Int) { 617 if (scrollingDisabled) { 618 return 619 } 620 val destIndex = visibleMediaIndex + step 621 if (destIndex >= mediaContent.childCount || destIndex < 0) { 622 if (!showsSettingsButton) return 623 var translation = getMaxTranslation() * sign(-step.toFloat()) 624 translation = if (isRtl) -translation else translation 625 PhysicsAnimator.getInstance(this) 626 .spring(CONTENT_TRANSLATION, translation, config = translationConfig) 627 .start() 628 scrollView.animationTargetX = translation 629 } else if (scrollView.getContentTranslation() != 0.0f) { 630 resetTranslation(true) 631 } else { 632 scrollToPlayer(destIndex = destIndex) 633 } 634 } 635 636 companion object { 637 private val CONTENT_TRANSLATION = 638 object : FloatPropertyCompat<MediaCarouselScrollHandler>("contentTranslation") { getValuenull639 override fun getValue(handler: MediaCarouselScrollHandler): Float { 640 return handler.contentTranslation 641 } 642 setValuenull643 override fun setValue(handler: MediaCarouselScrollHandler, value: Float) { 644 handler.contentTranslation = value 645 } 646 } 647 } 648 } 649