blob: 77cac5023db3700a22d46b12b21fee03672ea5c8 [file] [log] [blame]
/*
* 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 closeGuts: () -> 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.saturate(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
closeGuts()
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
}
}
}
}