| /* |
| * Copyright (C) 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.systemui.media |
| |
| import android.graphics.Outline |
| import android.util.MathUtils |
| import android.view.GestureDetector |
| import android.view.MotionEvent |
| import android.view.View |
| import android.view.ViewGroup |
| import android.view.ViewOutlineProvider |
| import androidx.core.view.GestureDetectorCompat |
| import androidx.dynamicanimation.animation.FloatPropertyCompat |
| import androidx.dynamicanimation.animation.SpringForce |
| import com.android.settingslib.Utils |
| import com.android.systemui.Gefingerpoken |
| import com.android.systemui.qs.PageIndicator |
| import com.android.systemui.R |
| import com.android.systemui.plugins.FalsingManager |
| import com.android.systemui.util.animation.PhysicsAnimator |
| import com.android.systemui.util.concurrency.DelayableExecutor |
| |
| private const val FLING_SLOP = 1000000 |
| private const val DISMISS_DELAY = 100L |
| private const val RUBBERBAND_FACTOR = 0.2f |
| private const val SETTINGS_BUTTON_TRANSLATION_FRACTION = 0.3f |
| |
| /** |
| * Default spring configuration to use for animations where stiffness and/or damping ratio |
| * were not provided, and a default spring was not set via [PhysicsAnimator.setDefaultSpringConfig]. |
| */ |
| private val translationConfig = PhysicsAnimator.SpringConfig( |
| SpringForce.STIFFNESS_MEDIUM, |
| SpringForce.DAMPING_RATIO_LOW_BOUNCY) |
| |
| /** |
| * A controller class for the media scrollview, responsible for touch handling |
| */ |
| class MediaCarouselScrollHandler( |
| private val scrollView: MediaScrollView, |
| private val pageIndicator: PageIndicator, |
| private val mainExecutor: DelayableExecutor, |
| private val dismissCallback: () -> Unit, |
| private var translationChangedListener: () -> Unit, |
| private val falsingManager: FalsingManager |
| ) { |
| /** |
| * Is the view in RTL |
| */ |
| val isRtl: Boolean get() = scrollView.isLayoutRtl |
| /** |
| * Do we need falsing protection? |
| */ |
| var falsingProtectionNeeded: Boolean = false |
| /** |
| * The width of the carousel |
| */ |
| private var carouselWidth: Int = 0 |
| |
| /** |
| * The height of the carousel |
| */ |
| private var carouselHeight: Int = 0 |
| |
| /** |
| * How much are we scrolled into the current media? |
| */ |
| private var cornerRadius: Int = 0 |
| |
| /** |
| * The content where the players are added |
| */ |
| private var mediaContent: ViewGroup |
| /** |
| * The gesture detector to detect touch gestures |
| */ |
| private val gestureDetector: GestureDetectorCompat |
| |
| /** |
| * The settings button view |
| */ |
| private lateinit var settingsButton: View |
| |
| /** |
| * What's the currently active player index? |
| */ |
| var activeMediaIndex: Int = 0 |
| private set |
| /** |
| * How much are we scrolled into the current media? |
| */ |
| private var scrollIntoCurrentMedia: Int = 0 |
| |
| /** |
| * how much is the content translated in X |
| */ |
| var contentTranslation = 0.0f |
| private set(value) { |
| field = value |
| mediaContent.translationX = value |
| updateSettingsPresentation() |
| translationChangedListener.invoke() |
| updateClipToOutline() |
| } |
| |
| /** |
| * The width of a player including padding |
| */ |
| var playerWidthPlusPadding: Int = 0 |
| set(value) { |
| field = value |
| // The player width has changed, let's update the scroll position to make sure |
| // it's still at the same place |
| var newRelativeScroll = activeMediaIndex * playerWidthPlusPadding |
| if (scrollIntoCurrentMedia > playerWidthPlusPadding) { |
| newRelativeScroll += playerWidthPlusPadding - |
| (scrollIntoCurrentMedia - playerWidthPlusPadding) |
| } else { |
| newRelativeScroll += scrollIntoCurrentMedia |
| } |
| scrollView.relativeScrollX = newRelativeScroll |
| } |
| |
| /** |
| * Does the dismiss currently show the setting cog? |
| */ |
| var showsSettingsButton: Boolean = false |
| |
| /** |
| * A utility to detect gestures, used in the touch listener |
| */ |
| private val gestureListener = object : GestureDetector.SimpleOnGestureListener() { |
| override fun onFling( |
| eStart: MotionEvent?, |
| eCurrent: MotionEvent?, |
| vX: Float, |
| vY: Float |
| ) = onFling(vX, vY) |
| |
| override fun onScroll( |
| down: MotionEvent?, |
| lastMotion: MotionEvent?, |
| distanceX: Float, |
| distanceY: Float |
| ) = onScroll(down!!, lastMotion!!, distanceX) |
| |
| override fun onDown(e: MotionEvent?): Boolean { |
| if (falsingProtectionNeeded) { |
| falsingManager.onNotificationStartDismissing() |
| } |
| return false |
| } |
| } |
| |
| /** |
| * The touch listener for the scroll view |
| */ |
| private val touchListener = object : Gefingerpoken { |
| override fun onTouchEvent(motionEvent: MotionEvent?) = onTouch(motionEvent!!) |
| override fun onInterceptTouchEvent(ev: MotionEvent?) = onInterceptTouch(ev!!) |
| } |
| |
| /** |
| * A listener that is invoked when the scrolling changes to update player visibilities |
| */ |
| private val scrollChangedListener = object : View.OnScrollChangeListener { |
| override fun onScrollChange( |
| v: View?, |
| scrollX: Int, |
| scrollY: Int, |
| oldScrollX: Int, |
| oldScrollY: Int |
| ) { |
| if (playerWidthPlusPadding == 0) { |
| return |
| } |
| val relativeScrollX = scrollView.relativeScrollX |
| onMediaScrollingChanged(relativeScrollX / playerWidthPlusPadding, |
| relativeScrollX % playerWidthPlusPadding) |
| } |
| } |
| |
| init { |
| gestureDetector = GestureDetectorCompat(scrollView.context, gestureListener) |
| scrollView.touchListener = touchListener |
| scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER) |
| mediaContent = scrollView.contentContainer |
| scrollView.setOnScrollChangeListener(scrollChangedListener) |
| scrollView.outlineProvider = object : ViewOutlineProvider() { |
| override fun getOutline(view: View?, outline: Outline?) { |
| outline?.setRoundRect(0, 0, carouselWidth, carouselHeight, cornerRadius.toFloat()) |
| } |
| } |
| } |
| |
| fun onSettingsButtonUpdated(button: View) { |
| settingsButton = button |
| // We don't have a context to resolve, lets use the settingsbuttons one since that is |
| // reinflated appropriately |
| cornerRadius = settingsButton.resources.getDimensionPixelSize( |
| Utils.getThemeAttr(settingsButton.context, android.R.attr.dialogCornerRadius)) |
| updateSettingsPresentation() |
| scrollView.invalidateOutline() |
| } |
| |
| private fun updateSettingsPresentation() { |
| if (showsSettingsButton) { |
| val settingsOffset = MathUtils.map( |
| 0.0f, |
| getMaxTranslation().toFloat(), |
| 0.0f, |
| 1.0f, |
| Math.abs(contentTranslation)) |
| val settingsTranslation = (1.0f - settingsOffset) * -settingsButton.width * |
| SETTINGS_BUTTON_TRANSLATION_FRACTION |
| val newTranslationX = if (isRtl) { |
| // In RTL, the 0-placement is on the right side of the view, not the left... |
| if (contentTranslation > 0) { |
| -(scrollView.width - settingsTranslation - settingsButton.width) |
| } else { |
| -settingsTranslation |
| } |
| } else { |
| if (contentTranslation > 0) { |
| settingsTranslation |
| } else { |
| scrollView.width - settingsTranslation - settingsButton.width |
| } |
| } |
| val rotation = (1.0f - settingsOffset) * 50 |
| settingsButton.rotation = rotation * -Math.signum(contentTranslation) |
| val alpha = MathUtils.map(0.5f, 1.0f, 0.0f, 1.0f, settingsOffset) |
| settingsButton.alpha = alpha |
| settingsButton.visibility = if (alpha != 0.0f) View.VISIBLE else View.INVISIBLE |
| settingsButton.translationX = newTranslationX |
| settingsButton.translationY = (scrollView.height - settingsButton.height) / 2.0f |
| } else { |
| settingsButton.visibility = View.INVISIBLE |
| } |
| } |
| |
| private fun onTouch(motionEvent: MotionEvent): Boolean { |
| val isUp = motionEvent.action == MotionEvent.ACTION_UP |
| if (isUp && falsingProtectionNeeded) { |
| falsingManager.onNotificationStopDismissing() |
| } |
| if (gestureDetector.onTouchEvent(motionEvent)) { |
| if (isUp) { |
| // If this is an up and we're flinging, we don't want to have this touch reach |
| // the view, otherwise that would scroll, while we are trying to snap to the |
| // new page. Let's dispatch a cancel instead. |
| scrollView.cancelCurrentScroll() |
| return true |
| } else { |
| // Pass touches to the scrollView |
| return false |
| } |
| } |
| if (isUp || motionEvent.action == MotionEvent.ACTION_CANCEL) { |
| // It's an up and the fling didn't take it above |
| val relativePos = scrollView.relativeScrollX % playerWidthPlusPadding |
| val scrollXAmount: Int |
| if (relativePos > playerWidthPlusPadding / 2) { |
| scrollXAmount = playerWidthPlusPadding - relativePos |
| } else { |
| scrollXAmount = -1 * relativePos |
| } |
| if (scrollXAmount != 0) { |
| // Delay the scrolling since scrollView calls springback which cancels |
| // the animation again.. |
| mainExecutor.execute { |
| scrollView.smoothScrollBy(if (isRtl) -scrollXAmount else scrollXAmount, 0) |
| } |
| } |
| val currentTranslation = scrollView.getContentTranslation() |
| if (currentTranslation != 0.0f) { |
| // We started a Swipe but didn't end up with a fling. Let's either go to the |
| // dismissed position or go back. |
| val springBack = Math.abs(currentTranslation) < getMaxTranslation() / 2 || |
| isFalseTouch() |
| val newTranslation: Float |
| if (springBack) { |
| newTranslation = 0.0f |
| } else { |
| newTranslation = getMaxTranslation() * Math.signum(currentTranslation) |
| if (!showsSettingsButton) { |
| // Delay the dismiss a bit to avoid too much overlap. Waiting until the |
| // animation has finished also feels a bit too slow here. |
| mainExecutor.executeDelayed({ |
| dismissCallback.invoke() |
| }, DISMISS_DELAY) |
| } |
| } |
| PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION, |
| newTranslation, startVelocity = 0.0f, config = translationConfig).start() |
| scrollView.animationTargetX = newTranslation |
| } |
| } |
| // Always pass touches to the scrollView |
| return false |
| } |
| |
| private fun isFalseTouch() = falsingProtectionNeeded && falsingManager.isFalseTouch |
| |
| private fun getMaxTranslation() = if (showsSettingsButton) { |
| settingsButton.width |
| } else { |
| playerWidthPlusPadding |
| } |
| |
| private fun onInterceptTouch(motionEvent: MotionEvent): Boolean { |
| return gestureDetector.onTouchEvent(motionEvent) |
| } |
| |
| fun onScroll( |
| down: MotionEvent, |
| lastMotion: MotionEvent, |
| distanceX: Float |
| ): Boolean { |
| val totalX = lastMotion.x - down.x |
| val currentTranslation = scrollView.getContentTranslation() |
| if (currentTranslation != 0.0f || |
| !scrollView.canScrollHorizontally((-totalX).toInt())) { |
| var newTranslation = currentTranslation - distanceX |
| val absTranslation = Math.abs(newTranslation) |
| if (absTranslation > getMaxTranslation()) { |
| // Rubberband all translation above the maximum |
| if (Math.signum(distanceX) != Math.signum(currentTranslation)) { |
| // The movement is in the same direction as our translation, |
| // Let's rubberband it. |
| if (Math.abs(currentTranslation) > getMaxTranslation()) { |
| // we were already overshooting before. Let's add the distance |
| // fully rubberbanded. |
| newTranslation = currentTranslation - distanceX * RUBBERBAND_FACTOR |
| } else { |
| // We just crossed the boundary, let's rubberband it all |
| newTranslation = Math.signum(newTranslation) * (getMaxTranslation() + |
| (absTranslation - getMaxTranslation()) * RUBBERBAND_FACTOR) |
| } |
| } // Otherwise we don't have do do anything, and will remove the unrubberbanded |
| // translation |
| } |
| if (Math.signum(newTranslation) != Math.signum(currentTranslation) && |
| currentTranslation != 0.0f) { |
| // We crossed the 0.0 threshold of the translation. Let's see if we're allowed |
| // to scroll into the new direction |
| if (scrollView.canScrollHorizontally(-newTranslation.toInt())) { |
| // We can actually scroll in the direction where we want to translate, |
| // Let's make sure to stop at 0 |
| newTranslation = 0.0f |
| } |
| } |
| val physicsAnimator = PhysicsAnimator.getInstance(this) |
| if (physicsAnimator.isRunning()) { |
| physicsAnimator.spring(CONTENT_TRANSLATION, |
| newTranslation, startVelocity = 0.0f, config = translationConfig).start() |
| } else { |
| contentTranslation = newTranslation |
| } |
| scrollView.animationTargetX = newTranslation |
| return true |
| } |
| return false |
| } |
| |
| private fun onFling( |
| vX: Float, |
| vY: Float |
| ): Boolean { |
| if (vX * vX < 0.5 * vY * vY) { |
| return false |
| } |
| if (vX * vX < FLING_SLOP) { |
| return false |
| } |
| val currentTranslation = scrollView.getContentTranslation() |
| if (currentTranslation != 0.0f) { |
| // We're translated and flung. Let's see if the fling is in the same direction |
| val newTranslation: Float |
| if (Math.signum(vX) != Math.signum(currentTranslation) || isFalseTouch()) { |
| // The direction of the fling isn't the same as the translation, let's go to 0 |
| newTranslation = 0.0f |
| } else { |
| newTranslation = getMaxTranslation() * Math.signum(currentTranslation) |
| // Delay the dismiss a bit to avoid too much overlap. Waiting until the animation |
| // has finished also feels a bit too slow here. |
| if (!showsSettingsButton) { |
| mainExecutor.executeDelayed({ |
| dismissCallback.invoke() |
| }, DISMISS_DELAY) |
| } |
| } |
| PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION, |
| newTranslation, startVelocity = vX, config = translationConfig).start() |
| scrollView.animationTargetX = newTranslation |
| } else { |
| // We're flinging the player! Let's go either to the previous or to the next player |
| val pos = scrollView.relativeScrollX |
| val currentIndex = if (playerWidthPlusPadding > 0) pos / playerWidthPlusPadding else 0 |
| val flungTowardEnd = if (isRtl) vX > 0 else vX < 0 |
| var destIndex = if (flungTowardEnd) currentIndex + 1 else currentIndex |
| destIndex = Math.max(0, destIndex) |
| destIndex = Math.min(mediaContent.getChildCount() - 1, destIndex) |
| val view = mediaContent.getChildAt(destIndex) |
| // We need to post this since we're dispatching a touch to the underlying view to cancel |
| // but canceling will actually abort the animation. |
| mainExecutor.execute { |
| scrollView.smoothScrollTo(view.left, scrollView.scrollY) |
| } |
| } |
| return true |
| } |
| |
| /** |
| * Reset the translation of the players when swiped |
| */ |
| fun resetTranslation(animate: Boolean = false) { |
| if (scrollView.getContentTranslation() != 0.0f) { |
| if (animate) { |
| PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION, |
| 0.0f, config = translationConfig).start() |
| scrollView.animationTargetX = 0.0f |
| } else { |
| PhysicsAnimator.getInstance(this).cancel() |
| contentTranslation = 0.0f |
| } |
| } |
| } |
| |
| private fun updateClipToOutline() { |
| val clip = contentTranslation != 0.0f || scrollIntoCurrentMedia != 0 |
| scrollView.clipToOutline = clip |
| } |
| |
| private fun onMediaScrollingChanged(newIndex: Int, scrollInAmount: Int) { |
| val wasScrolledIn = scrollIntoCurrentMedia != 0 |
| scrollIntoCurrentMedia = scrollInAmount |
| val nowScrolledIn = scrollIntoCurrentMedia != 0 |
| if (newIndex != activeMediaIndex || wasScrolledIn != nowScrolledIn) { |
| activeMediaIndex = newIndex |
| updatePlayerVisibilities() |
| } |
| val relativeLocation = activeMediaIndex.toFloat() + if (playerWidthPlusPadding > 0) |
| scrollInAmount.toFloat() / playerWidthPlusPadding else 0f |
| // Fix the location, because PageIndicator does not handle RTL internally |
| val location = if (isRtl) { |
| mediaContent.childCount - relativeLocation - 1 |
| } else { |
| relativeLocation |
| } |
| pageIndicator.setLocation(location) |
| updateClipToOutline() |
| } |
| |
| /** |
| * Notified whenever the players or their order has changed |
| */ |
| fun onPlayersChanged() { |
| updatePlayerVisibilities() |
| updateMediaPaddings() |
| } |
| |
| private fun updateMediaPaddings() { |
| val padding = scrollView.context.resources.getDimensionPixelSize(R.dimen.qs_media_padding) |
| val childCount = mediaContent.childCount |
| for (i in 0 until childCount) { |
| val mediaView = mediaContent.getChildAt(i) |
| val desiredPaddingEnd = if (i == childCount - 1) 0 else padding |
| val layoutParams = mediaView.layoutParams as ViewGroup.MarginLayoutParams |
| if (layoutParams.marginEnd != desiredPaddingEnd) { |
| layoutParams.marginEnd = desiredPaddingEnd |
| mediaView.layoutParams = layoutParams |
| } |
| } |
| } |
| |
| private fun updatePlayerVisibilities() { |
| val scrolledIn = scrollIntoCurrentMedia != 0 |
| for (i in 0 until mediaContent.childCount) { |
| val view = mediaContent.getChildAt(i) |
| val visible = (i == activeMediaIndex) || ((i == (activeMediaIndex + 1)) && scrolledIn) |
| view.visibility = if (visible) View.VISIBLE else View.INVISIBLE |
| } |
| } |
| |
| /** |
| * Notify that a player will be removed right away. This gives us the opporunity to look |
| * where it was and update our scroll position. |
| */ |
| fun onPrePlayerRemoved(removed: MediaControlPanel) { |
| val removedIndex = mediaContent.indexOfChild(removed.view?.player) |
| // If the removed index is less than the activeMediaIndex, then we need to decrement it. |
| // RTL has no effect on this, because indices are always relative (start-to-end). |
| // Update the index 'manually' since we won't always get a call to onMediaScrollingChanged |
| val beforeActive = removedIndex <= activeMediaIndex |
| if (beforeActive) { |
| activeMediaIndex = Math.max(0, activeMediaIndex - 1) |
| } |
| // If the removed media item is "left of" the active one (in an absolute sense), we need to |
| // scroll the view to keep that player in view. This is because scroll position is always |
| // calculated from left to right. |
| val leftOfActive = if (isRtl) !beforeActive else beforeActive |
| if (leftOfActive) { |
| scrollView.scrollX = Math.max(scrollView.scrollX - playerWidthPlusPadding, 0) |
| } |
| } |
| |
| /** |
| * Update the bounds of the carousel |
| */ |
| fun setCarouselBounds(currentCarouselWidth: Int, currentCarouselHeight: Int) { |
| if (currentCarouselHeight != carouselHeight || currentCarouselWidth != carouselHeight) { |
| carouselWidth = currentCarouselWidth |
| carouselHeight = currentCarouselHeight |
| scrollView.invalidateOutline() |
| } |
| } |
| |
| /** |
| * Reset the MediaScrollView to the start. |
| */ |
| fun scrollToStart() { |
| scrollView.relativeScrollX = 0 |
| } |
| |
| companion object { |
| private val CONTENT_TRANSLATION = object : FloatPropertyCompat<MediaCarouselScrollHandler>( |
| "contentTranslation") { |
| override fun getValue(handler: MediaCarouselScrollHandler): Float { |
| return handler.contentTranslation |
| } |
| |
| override fun setValue(handler: MediaCarouselScrollHandler, value: Float) { |
| handler.contentTranslation = value |
| } |
| } |
| } |
| } |