You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

328 lines
13 KiB

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.browser
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.app.Activity
import android.graphics.PointF
import android.graphics.Rect
import android.view.View
import android.view.ViewConfiguration
import androidx.annotation.Dimension
import androidx.annotation.Dimension.DP
import androidx.core.animation.doOnEnd
import androidx.core.graphics.contains
import androidx.core.graphics.toPoint
import androidx.core.view.isVisible
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.support.ktx.android.util.dpToPx
import mozilla.components.support.ktx.android.view.getRectWithViewLocation
import org.mozilla.fenix.ext.getRectWithScreenLocation
import org.mozilla.fenix.ext.getWindowInsets
import org.mozilla.fenix.ext.isKeyboardVisible
import org.mozilla.fenix.ext.sessionsOfType
import org.mozilla.fenix.ext.settings
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
/**
* Handles intercepting touch events on the toolbar for swipe gestures and executes the
* necessary animations.
*/
@Suppress("LargeClass", "TooManyFunctions")
class ToolbarGestureHandler(
private val activity: Activity,
private val contentLayout: View,
private val tabPreview: TabPreview,
private val toolbarLayout: View,
private val sessionManager: SessionManager
) : SwipeGestureListener {
private enum class GestureDirection {
LEFT_TO_RIGHT, RIGHT_TO_LEFT
}
private sealed class Destination {
data class Tab(val session: Session) : Destination()
object None : Destination()
}
private val windowWidth: Int
get() = activity.resources.displayMetrics.widthPixels
private val previewOffset = PREVIEW_OFFSET.dpToPx(activity.resources.displayMetrics)
private val touchSlop = ViewConfiguration.get(activity).scaledTouchSlop
private val minimumFlingVelocity = ViewConfiguration.get(activity).scaledMinimumFlingVelocity
private var gestureDirection = GestureDirection.LEFT_TO_RIGHT
override fun onSwipeStarted(start: PointF, next: PointF): Boolean {
val dx = next.x - start.x
val dy = next.y - start.y
gestureDirection = if (dx < 0) {
GestureDirection.RIGHT_TO_LEFT
} else {
GestureDirection.LEFT_TO_RIGHT
}
@Suppress("ComplexCondition")
return if (
!activity.window.decorView.isKeyboardVisible() &&
start.isInToolbar() &&
abs(dx) > touchSlop &&
abs(dy) < abs(dx)
) {
preparePreview(getDestination())
true
} else {
false
}
}
override fun onSwipeUpdate(distanceX: Float, distanceY: Float) {
when (getDestination()) {
is Destination.Tab -> {
// Restrict the range of motion for the views so you can't start a swipe in one direction
// then move your finger far enough in the other direction and make the content visually
// start sliding off screen the other way.
tabPreview.translationX = when (gestureDirection) {
GestureDirection.RIGHT_TO_LEFT -> min(
windowWidth.toFloat() + previewOffset,
tabPreview.translationX - distanceX
)
GestureDirection.LEFT_TO_RIGHT -> max(
-windowWidth.toFloat() - previewOffset,
tabPreview.translationX - distanceX
)
}
contentLayout.translationX = when (gestureDirection) {
GestureDirection.RIGHT_TO_LEFT -> min(
0f,
contentLayout.translationX - distanceX
)
GestureDirection.LEFT_TO_RIGHT -> max(
0f,
contentLayout.translationX - distanceX
)
}
}
is Destination.None -> {
// If there is no "next" tab to swipe to in the gesture direction, only do a
// partial animation to show that we are at the end of the tab list
val maxContentHidden = contentLayout.width * OVERSCROLL_HIDE_PERCENT
contentLayout.translationX = when (gestureDirection) {
GestureDirection.RIGHT_TO_LEFT -> max(
-maxContentHidden.toFloat(),
contentLayout.translationX - distanceX
).coerceAtMost(0f)
GestureDirection.LEFT_TO_RIGHT -> min(
maxContentHidden.toFloat(),
contentLayout.translationX - distanceX
).coerceAtLeast(0f)
}
}
}
}
override fun onSwipeFinished(
velocityX: Float,
velocityY: Float
) {
val destination = getDestination()
if (destination is Destination.Tab && isGestureComplete(velocityX)) {
animateToNextTab(destination.session)
} else {
animateCanceledGesture(velocityX)
}
}
private fun getDestination(): Destination {
val isLtr = activity.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_LTR
val currentSession = sessionManager.selectedSession ?: return Destination.None
val currentIndex = sessionManager.sessionsOfType(currentSession.private).indexOfFirst {
it.id == currentSession.id
}
return if (currentIndex == -1) {
Destination.None
} else {
val sessions = sessionManager.sessionsOfType(currentSession.private)
val index = when (gestureDirection) {
GestureDirection.RIGHT_TO_LEFT -> if (isLtr) {
currentIndex - 1
} else {
currentIndex + 1
}
GestureDirection.LEFT_TO_RIGHT -> if (isLtr) {
currentIndex + 1
} else {
currentIndex - 1
}
}
if (index < sessions.count() && index >= 0) {
Destination.Tab(sessions.elementAt(index))
} else {
Destination.None
}
}
}
private fun preparePreview(destination: Destination) {
val thumbnailId = when (destination) {
is Destination.Tab -> destination.session.id
is Destination.None -> return
}
tabPreview.loadPreviewThumbnail(thumbnailId)
tabPreview.alpha = 1f
tabPreview.translationX = when (gestureDirection) {
GestureDirection.RIGHT_TO_LEFT -> windowWidth.toFloat() + previewOffset
GestureDirection.LEFT_TO_RIGHT -> -windowWidth.toFloat() - previewOffset
}
tabPreview.isVisible = true
}
/**
* Checks if the gesture is complete based on the position of tab preview and the velocity of
* the gesture. A completed gesture means the user has indicated they want to swipe to the next
* tab. The gesture is considered complete if one of the following is true:
*
* 1. The user initiated a fling in the same direction as the initial movement
* 2. There is no fling initiated, but the percentage of the tab preview shown is at least
* [GESTURE_FINISH_PERCENT]
*
* If the user initiated a fling in the opposite direction of the initial movement, the
* gesture is always considered incomplete.
*/
private fun isGestureComplete(velocityX: Float): Boolean {
val previewWidth = tabPreview.getRectWithViewLocation().visibleWidth.toDouble()
val velocityMatchesDirection = when (gestureDirection) {
GestureDirection.RIGHT_TO_LEFT -> velocityX <= 0
GestureDirection.LEFT_TO_RIGHT -> velocityX >= 0
}
val reverseFling =
abs(velocityX) >= minimumFlingVelocity && !velocityMatchesDirection
return !reverseFling && (previewWidth / windowWidth >= GESTURE_FINISH_PERCENT ||
abs(velocityX) >= minimumFlingVelocity)
}
private fun getAnimator(finalContextX: Float, duration: Long): ValueAnimator {
return ValueAnimator.ofFloat(contentLayout.translationX, finalContextX).apply {
this.duration = duration
this.interpolator = LinearOutSlowInInterpolator()
addUpdateListener { animator ->
val value = animator.animatedValue as Float
contentLayout.translationX = value
tabPreview.translationX = when (gestureDirection) {
GestureDirection.RIGHT_TO_LEFT -> value + windowWidth + previewOffset
GestureDirection.LEFT_TO_RIGHT -> value - windowWidth - previewOffset
}
}
}
}
private fun animateToNextTab(session: Session) {
val browserFinalXCoordinate: Float = when (gestureDirection) {
GestureDirection.RIGHT_TO_LEFT -> -windowWidth.toFloat() - previewOffset
GestureDirection.LEFT_TO_RIGHT -> windowWidth.toFloat() + previewOffset
}
// Finish animating the contentLayout off screen and tabPreview on screen
getAnimator(browserFinalXCoordinate, FINISHED_GESTURE_ANIMATION_DURATION).apply {
doOnEnd {
contentLayout.translationX = 0f
sessionManager.select(session)
// Fade out the tab preview to prevent flickering
val shortAnimationDuration =
activity.resources.getInteger(android.R.integer.config_shortAnimTime)
tabPreview.animate()
.alpha(0f)
.setDuration(shortAnimationDuration.toLong())
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
tabPreview.isVisible = false
}
})
}
}.start()
}
private fun animateCanceledGesture(velocityX: Float) {
val duration = if (abs(velocityX) >= minimumFlingVelocity) {
CANCELED_FLING_ANIMATION_DURATION
} else {
CANCELED_GESTURE_ANIMATION_DURATION
}
getAnimator(0f, duration).apply {
doOnEnd {
tabPreview.isVisible = false
}
}.start()
}
private fun PointF.isInToolbar(): Boolean {
val toolbarLocation = toolbarLayout.getRectWithScreenLocation()
// In Android 10, the system gesture touch area overlaps the bottom of the toolbar, so
// lets make our swipe area taller by that amount
activity.window.decorView.getWindowInsets()?.let { insets ->
if (activity.settings().shouldUseBottomToolbar) {
toolbarLocation.top -= (insets.mandatorySystemGestureInsets.bottom - insets.stableInsetBottom)
}
}
return toolbarLocation.contains(toPoint())
}
private val Rect.visibleWidth: Int
get() = if (left < 0) {
right
} else {
windowWidth - left
}
companion object {
/**
* The percentage of the tab preview that needs to be visible to consider the
* tab switching gesture complete.
*/
private const val GESTURE_FINISH_PERCENT = 0.25
/**
* The percentage of the content view that can be hidden by the tab switching gesture if
* there is not tab available to switch to
*/
private const val OVERSCROLL_HIDE_PERCENT = 0.20
/**
* The size of the gap between the tab preview and content layout.
*/
@Dimension(unit = DP)
private const val PREVIEW_OFFSET = 48
/**
* Animation duration when switching to another tab
*/
private const val FINISHED_GESTURE_ANIMATION_DURATION = 250L
/**
* Animation duration gesture is canceled due to the swipe not being far enough
*/
private const val CANCELED_GESTURE_ANIMATION_DURATION = 200L
/**
* Animation duration gesture is canceled due to a swipe in the opposite direction
*/
private const val CANCELED_FLING_ANIMATION_DURATION = 150L
}
}