mirror of
https://github.com/fork-maintainers/iceraven-browser
synced 2024-11-17 15:26:23 +00:00
[fenix] For https://github.com/mozilla-mobile/fenix/issues/3481 - Implement swipe on toolbar to switch tabs.
This commit is contained in:
parent
d85e95f6b8
commit
a6a8c4dc08
@ -503,6 +503,7 @@ dependencies {
|
|||||||
implementation Deps.androidx_lifecycle_viewmodel
|
implementation Deps.androidx_lifecycle_viewmodel
|
||||||
implementation Deps.androidx_core
|
implementation Deps.androidx_core
|
||||||
implementation Deps.androidx_core_ktx
|
implementation Deps.androidx_core_ktx
|
||||||
|
implementation Deps.androidx_dynamic_animation
|
||||||
implementation Deps.androidx_transition
|
implementation Deps.androidx_transition
|
||||||
implementation Deps.androidx_work_ktx
|
implementation Deps.androidx_work_ktx
|
||||||
implementation Deps.google_material
|
implementation Deps.google_material
|
||||||
|
@ -24,4 +24,9 @@ object FeatureFlags {
|
|||||||
* Enables new tab tray pref
|
* Enables new tab tray pref
|
||||||
*/
|
*/
|
||||||
val tabTray = Config.channel.isNightlyOrDebug
|
val tabTray = Config.channel.isNightlyOrDebug
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables swipe on toolbar to switch tabs
|
||||||
|
*/
|
||||||
|
val swipeToSwitchTabs = Config.channel.isNightlyOrDebug
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import kotlinx.android.synthetic.main.fragment_browser.*
|
||||||
import kotlinx.android.synthetic.main.fragment_browser.view.*
|
import kotlinx.android.synthetic.main.fragment_browser.view.*
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import mozilla.components.browser.session.Session
|
import mozilla.components.browser.session.Session
|
||||||
@ -29,6 +30,7 @@ import mozilla.components.feature.tab.collections.TabCollection
|
|||||||
import mozilla.components.feature.tabs.WindowFeature
|
import mozilla.components.feature.tabs.WindowFeature
|
||||||
import mozilla.components.support.base.feature.UserInteractionHandler
|
import mozilla.components.support.base.feature.UserInteractionHandler
|
||||||
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
|
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
|
||||||
|
import org.mozilla.fenix.FeatureFlags
|
||||||
import org.mozilla.fenix.R
|
import org.mozilla.fenix.R
|
||||||
import org.mozilla.fenix.addons.runIfFragmentIsAttached
|
import org.mozilla.fenix.addons.runIfFragmentIsAttached
|
||||||
import org.mozilla.fenix.components.FenixSnackbar
|
import org.mozilla.fenix.components.FenixSnackbar
|
||||||
@ -66,11 +68,24 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
|
|||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
override fun initializeUI(view: View): Session? {
|
override fun initializeUI(view: View): Session? {
|
||||||
val context = requireContext()
|
val context = requireContext()
|
||||||
val components = context.components
|
val components = context.components
|
||||||
|
|
||||||
return super.initializeUI(view)?.also {
|
return super.initializeUI(view)?.also {
|
||||||
|
if (FeatureFlags.swipeToSwitchTabs) {
|
||||||
|
gestureLayout.addGestureListener(
|
||||||
|
ToolbarGestureHandler(
|
||||||
|
activity = requireActivity(),
|
||||||
|
contentLayout = browserLayout,
|
||||||
|
tabPreview = tabPreview,
|
||||||
|
toolbarLayout = browserToolbarView.view,
|
||||||
|
sessionManager = components.core.sessionManager
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val readerModeAction =
|
val readerModeAction =
|
||||||
BrowserToolbar.ToggleButton(
|
BrowserToolbar.ToggleButton(
|
||||||
image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_readermode)!!,
|
image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_readermode)!!,
|
||||||
|
@ -0,0 +1,136 @@
|
|||||||
|
/* 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.content.Context
|
||||||
|
import android.graphics.PointF
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.GestureDetector
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.core.view.GestureDetectorCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that allows intercepting and handling swipe gestures received in a [SwipeGestureLayout].
|
||||||
|
*/
|
||||||
|
interface SwipeGestureListener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the [SwipeGestureLayout] detects the start of a swipe gesture. The listener
|
||||||
|
* should return true if it wants to handle the swipe gesture. If the listener returns false
|
||||||
|
* it will not receive any callbacks for future events that the swipe produces.
|
||||||
|
*
|
||||||
|
* @param start the initial point where the gesture started
|
||||||
|
* @param next the next point in the gesture
|
||||||
|
*/
|
||||||
|
fun onSwipeStarted(start: PointF, next: PointF): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the swipe gesture receives a new event.
|
||||||
|
*
|
||||||
|
* @param distanceX the change along the x-axis since the last swipe update
|
||||||
|
* @param distanceY the change along the y-axis since the last swipe update
|
||||||
|
*/
|
||||||
|
fun onSwipeUpdate(distanceX: Float, distanceY: Float)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the user finishes the swipe gesture (ie lifts their finger off the screen)
|
||||||
|
*
|
||||||
|
* @param velocityX the velocity of the swipe along the x-axis
|
||||||
|
* @param velocityY the velocity of the swipe along the y-axis
|
||||||
|
*/
|
||||||
|
fun onSwipeFinished(velocityX: Float, velocityY: Float)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [FrameLayout] that allows listeners to intercept and handle swipe events.
|
||||||
|
*
|
||||||
|
* Listeners are called in the order they are added and the first listener to intercept a swipe event
|
||||||
|
* is the only listener that will receive events for the duration of that swipe.
|
||||||
|
*/
|
||||||
|
class SwipeGestureLayout @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private val gestureListener = object : GestureDetector.SimpleOnGestureListener() {
|
||||||
|
override fun onDown(e: MotionEvent?): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScroll(
|
||||||
|
e1: MotionEvent?,
|
||||||
|
e2: MotionEvent?,
|
||||||
|
distanceX: Float,
|
||||||
|
distanceY: Float
|
||||||
|
): Boolean {
|
||||||
|
val start = e1?.let { event -> PointF(event.rawX, event.rawY) } ?: return false
|
||||||
|
val next = e2?.let { event -> PointF(event.rawX, event.rawY) } ?: return false
|
||||||
|
|
||||||
|
if (activeListener == null && !handledInitialScroll) {
|
||||||
|
activeListener = listeners.firstOrNull { listener ->
|
||||||
|
listener.onSwipeStarted(start, next)
|
||||||
|
}
|
||||||
|
handledInitialScroll = true
|
||||||
|
}
|
||||||
|
activeListener?.onSwipeUpdate(distanceX, distanceY)
|
||||||
|
return activeListener != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFling(
|
||||||
|
e1: MotionEvent?,
|
||||||
|
e2: MotionEvent?,
|
||||||
|
velocityX: Float,
|
||||||
|
velocityY: Float
|
||||||
|
): Boolean {
|
||||||
|
activeListener?.onSwipeFinished(velocityX, velocityY)
|
||||||
|
return if (activeListener != null) {
|
||||||
|
activeListener = null
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gestureDetector = GestureDetectorCompat(context, gestureListener)
|
||||||
|
|
||||||
|
private val listeners = mutableListOf<SwipeGestureListener>()
|
||||||
|
private var activeListener: SwipeGestureListener? = null
|
||||||
|
private var handledInitialScroll = false
|
||||||
|
|
||||||
|
fun addGestureListener(listener: SwipeGestureListener) {
|
||||||
|
listeners.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
|
||||||
|
return when (event?.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
handledInitialScroll = false
|
||||||
|
gestureDetector.onTouchEvent(event)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
else -> gestureDetector.onTouchEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTouchEvent(event: MotionEvent?): Boolean {
|
||||||
|
return when (event?.actionMasked) {
|
||||||
|
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
|
||||||
|
gestureDetector.onTouchEvent(event)
|
||||||
|
// If the active listener is not null here, then we haven't detected a fling
|
||||||
|
// so notify the listener that the swipe was finished with 0 velocity
|
||||||
|
activeListener?.onSwipeFinished(
|
||||||
|
velocityX = 0f,
|
||||||
|
velocityY = 0f
|
||||||
|
)
|
||||||
|
activeListener = null
|
||||||
|
false
|
||||||
|
}
|
||||||
|
else -> gestureDetector.onTouchEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
67
app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt
Normal file
67
app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/* 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.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import kotlinx.android.synthetic.main.tab_preview.view.*
|
||||||
|
import mozilla.components.browser.thumbnails.loader.ThumbnailLoader
|
||||||
|
import mozilla.components.support.images.ext.loadIntoView
|
||||||
|
import org.mozilla.fenix.R
|
||||||
|
import org.mozilla.fenix.ext.components
|
||||||
|
import org.mozilla.fenix.ext.settings
|
||||||
|
import org.mozilla.fenix.theme.ThemeManager
|
||||||
|
|
||||||
|
class TabPreview @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyle: Int = 0
|
||||||
|
) : FrameLayout(context, attrs, defStyle) {
|
||||||
|
|
||||||
|
private val thumbnailLoader = ThumbnailLoader(context.components.core.thumbnailStorage)
|
||||||
|
|
||||||
|
init {
|
||||||
|
val inflater = LayoutInflater.from(context)
|
||||||
|
inflater.inflate(R.layout.tab_preview, this, true)
|
||||||
|
|
||||||
|
if (!context.settings().shouldUseBottomToolbar) {
|
||||||
|
fakeToolbar.updateLayoutParams<LayoutParams> {
|
||||||
|
gravity = Gravity.TOP
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeToolbar.background = ResourcesCompat.getDrawable(
|
||||||
|
resources,
|
||||||
|
ThemeManager.resolveAttribute(R.attr.bottomBarBackgroundTop, context),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
menuButton.setColorFilter(
|
||||||
|
ContextCompat.getColor(
|
||||||
|
context,
|
||||||
|
ThemeManager.resolveAttribute(R.attr.primaryText, context)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||||
|
super.onLayout(changed, left, top, right, bottom)
|
||||||
|
previewThumbnail.translationY = if (!context.settings().shouldUseBottomToolbar) {
|
||||||
|
fakeToolbar.height.toFloat()
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPreviewThumbnail(thumbnailId: String) {
|
||||||
|
thumbnailLoader.loadIntoView(previewThumbnail, thumbnailId)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,356 @@
|
|||||||
|
/* 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.app.Activity
|
||||||
|
import android.graphics.PointF
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewConfiguration
|
||||||
|
import androidx.annotation.Dimension
|
||||||
|
import androidx.annotation.Dimension.DP
|
||||||
|
import androidx.core.graphics.contains
|
||||||
|
import androidx.core.graphics.toPoint
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.dynamicanimation.animation.DynamicAnimation
|
||||||
|
import androidx.dynamicanimation.animation.FlingAnimation
|
||||||
|
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.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 windowInsets: WindowInsetsCompat?
|
||||||
|
get() =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
// In theory, the rootWindowInsets should exist at this point but if the decorView is
|
||||||
|
// not attached for some reason we'll get a NullPointerException without the check.
|
||||||
|
activity.window.decorView.rootWindowInsets?.let {
|
||||||
|
WindowInsetsCompat.toWindowInsetsCompat(it)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val previewOffset = PREVIEW_OFFSET.dpToPx(activity.resources.displayMetrics)
|
||||||
|
|
||||||
|
private val touchSlop = ViewConfiguration.get(activity).scaledTouchSlop
|
||||||
|
private val minimumFlingVelocity = ViewConfiguration.get(activity).scaledMinimumFlingVelocity
|
||||||
|
private val defaultVelocity = TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
|
MINIMUM_ANIMATION_VELOCITY,
|
||||||
|
activity.resources.displayMetrics
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (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(velocityX, destination.session)
|
||||||
|
} else {
|
||||||
|
animateCanceledGesture(velocityX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFlingAnimation(
|
||||||
|
view: View,
|
||||||
|
minValue: Float,
|
||||||
|
maxValue: Float,
|
||||||
|
startVelocity: Float
|
||||||
|
): FlingAnimation =
|
||||||
|
FlingAnimation(view, DynamicAnimation.TRANSLATION_X).apply {
|
||||||
|
setMinValue(minValue)
|
||||||
|
setMaxValue(maxValue)
|
||||||
|
setStartVelocity(startVelocity)
|
||||||
|
friction = ViewConfiguration.getScrollFriction()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getVelocityFromFling(velocityX: Float): Float {
|
||||||
|
return max(abs(velocityX), defaultVelocity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateToNextTab(velocityX: Float, session: Session) {
|
||||||
|
val browserFinalXCoordinate: Float = when (gestureDirection) {
|
||||||
|
GestureDirection.RIGHT_TO_LEFT -> -windowWidth.toFloat() - previewOffset
|
||||||
|
GestureDirection.LEFT_TO_RIGHT -> windowWidth.toFloat() + previewOffset
|
||||||
|
}
|
||||||
|
val animationVelocity = when (gestureDirection) {
|
||||||
|
GestureDirection.RIGHT_TO_LEFT -> -getVelocityFromFling(velocityX)
|
||||||
|
GestureDirection.LEFT_TO_RIGHT -> getVelocityFromFling(velocityX)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish animating the contentLayout off screen and tabPreview on screen
|
||||||
|
createFlingAnimation(
|
||||||
|
view = contentLayout,
|
||||||
|
minValue = min(0f, browserFinalXCoordinate),
|
||||||
|
maxValue = max(0f, browserFinalXCoordinate),
|
||||||
|
startVelocity = animationVelocity
|
||||||
|
).addUpdateListener { _, value, _ ->
|
||||||
|
tabPreview.translationX = when (gestureDirection) {
|
||||||
|
GestureDirection.RIGHT_TO_LEFT -> value + windowWidth + previewOffset
|
||||||
|
GestureDirection.LEFT_TO_RIGHT -> value - windowWidth - previewOffset
|
||||||
|
}
|
||||||
|
}.addEndListener { _, _, _, _ ->
|
||||||
|
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(gestureVelocity: Float) {
|
||||||
|
val velocity = if (getDestination() is Destination.None) {
|
||||||
|
defaultVelocity
|
||||||
|
} else {
|
||||||
|
getVelocityFromFling(gestureVelocity)
|
||||||
|
}.let { v ->
|
||||||
|
when (gestureDirection) {
|
||||||
|
GestureDirection.RIGHT_TO_LEFT -> v
|
||||||
|
GestureDirection.LEFT_TO_RIGHT -> -v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createFlingAnimation(
|
||||||
|
view = contentLayout,
|
||||||
|
minValue = min(0f, contentLayout.translationX),
|
||||||
|
maxValue = max(0f, contentLayout.translationX),
|
||||||
|
startVelocity = velocity
|
||||||
|
).addUpdateListener { _, value, _ ->
|
||||||
|
tabPreview.translationX = when (gestureDirection) {
|
||||||
|
GestureDirection.RIGHT_TO_LEFT -> value + windowWidth + previewOffset
|
||||||
|
GestureDirection.LEFT_TO_RIGHT -> value - windowWidth - previewOffset
|
||||||
|
}
|
||||||
|
}.addEndListener { _, _, _, _ ->
|
||||||
|
tabPreview.isVisible = false
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PointF.isInToolbar(): Boolean {
|
||||||
|
val toolbarLocation = toolbarLayout.getRectWithViewLocation()
|
||||||
|
// In Android 10, the system gesture touch area overlaps the bottom of the toolbar, so
|
||||||
|
// lets make our swipe area taller by that amount
|
||||||
|
windowInsets?.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 speed of the fling animation (in dp per second).
|
||||||
|
*/
|
||||||
|
@Dimension(unit = DP)
|
||||||
|
private const val MINIMUM_ANIMATION_VELOCITY = 1500f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The size of the gap between the tab preview and content layout.
|
||||||
|
*/
|
||||||
|
@Dimension(unit = DP)
|
||||||
|
private const val PREVIEW_OFFSET = 48
|
||||||
|
}
|
||||||
|
}
|
@ -2,51 +2,66 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<!-- 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
|
- 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/. -->
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
<org.mozilla.fenix.browser.SwipeGestureLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/browserLayout"
|
android:id="@+id/gestureLayout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context="browser.BrowserFragment">
|
tools:context="browser.BrowserFragment">
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
android:id="@+id/swipeRefresh"
|
android:id="@+id/browserLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipeRefresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:alpha="0"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||||
|
|
||||||
|
<mozilla.components.concept.engine.EngineView
|
||||||
|
android:id="@+id/engineView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
|
|
||||||
|
<ViewStub
|
||||||
|
android:id="@+id/stubFindInPage"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:inflatedId="@+id/findInPageView"
|
||||||
|
android:layout="@layout/stub_find_in_page" />
|
||||||
|
|
||||||
|
<include
|
||||||
|
android:id="@+id/viewDynamicDownloadDialog"
|
||||||
|
layout="@layout/download_dialog_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<mozilla.components.feature.readerview.view.ReaderViewControlsBar
|
||||||
|
android:id="@+id/readerViewControlsBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:background="?foundation"
|
||||||
|
android:elevation="24dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|
||||||
|
<org.mozilla.fenix.browser.TabPreview
|
||||||
|
android:id="@+id/tabPreview"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:alpha="0"
|
android:clickable="false"
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
android:focusable="false"
|
||||||
<mozilla.components.concept.engine.EngineView
|
|
||||||
android:id="@+id/engineView"
|
|
||||||
android:visibility="gone"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent" />
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
|
||||||
|
|
||||||
<ViewStub
|
|
||||||
android:id="@+id/stubFindInPage"
|
|
||||||
android:inflatedId="@+id/findInPageView"
|
|
||||||
android:layout="@layout/stub_find_in_page"
|
|
||||||
android:layout_gravity="bottom"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="56dp" />
|
|
||||||
|
|
||||||
<include
|
|
||||||
android:id="@+id/viewDynamicDownloadDialog"
|
|
||||||
layout="@layout/download_dialog_layout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="bottom"
|
|
||||||
android:visibility="gone"/>
|
|
||||||
|
|
||||||
<mozilla.components.feature.readerview.view.ReaderViewControlsBar
|
|
||||||
android:id="@+id/readerViewControlsBar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="bottom"
|
|
||||||
android:background="?foundation"
|
|
||||||
android:elevation="24dp"
|
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
</org.mozilla.fenix.browser.SwipeGestureLayout>
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
|
44
app/src/main/res/layout/tab_preview.xml
Normal file
44
app/src/main/res/layout/tab_preview.xml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 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/. -->
|
||||||
|
<merge xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||||
|
<mozilla.components.browser.tabstray.thumbnail.TabThumbnailView
|
||||||
|
android:id="@+id/previewThumbnail"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="?tabTrayThumbnailItemBackground" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/fakeToolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="@dimen/browser_toolbar_height"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:background="?bottomBarBackground"
|
||||||
|
android:elevation="5dp"
|
||||||
|
android:foregroundGravity="bottom"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/toolbar_wrapper"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginEnd="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:background="@drawable/home_search_background" />
|
||||||
|
|
||||||
|
<org.mozilla.fenix.components.toolbar.TabCounter
|
||||||
|
android:id="@+id/tab_button"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_gravity="center" />
|
||||||
|
|
||||||
|
<mozilla.components.browser.menu.view.MenuButton
|
||||||
|
android:id="@+id/menuButton"
|
||||||
|
android:layout_width="36dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_gravity="center" />
|
||||||
|
</LinearLayout>
|
||||||
|
</merge>
|
@ -28,6 +28,7 @@ object Versions {
|
|||||||
const val androidx_paging = "2.1.0"
|
const val androidx_paging = "2.1.0"
|
||||||
const val androidx_transition = "1.3.0"
|
const val androidx_transition = "1.3.0"
|
||||||
const val androidx_work = "2.2.0"
|
const val androidx_work = "2.2.0"
|
||||||
|
const val androidx_dynamic_animation = "1.0.0"
|
||||||
const val google_material = "1.1.0"
|
const val google_material = "1.1.0"
|
||||||
const val google_flexbox = "2.0.1"
|
const val google_flexbox = "2.0.1"
|
||||||
|
|
||||||
@ -170,6 +171,7 @@ object Deps {
|
|||||||
const val androidx_recyclerview = "androidx.recyclerview:recyclerview:${Versions.androidx_recyclerview}"
|
const val androidx_recyclerview = "androidx.recyclerview:recyclerview:${Versions.androidx_recyclerview}"
|
||||||
const val androidx_core = "androidx.core:core:${Versions.androidx_core}"
|
const val androidx_core = "androidx.core:core:${Versions.androidx_core}"
|
||||||
const val androidx_core_ktx = "androidx.core:core-ktx:${Versions.androidx_core}"
|
const val androidx_core_ktx = "androidx.core:core-ktx:${Versions.androidx_core}"
|
||||||
|
const val androidx_dynamic_animation = "androidx.dynamicanimation:dynamicanimation:${Versions.androidx_dynamic_animation}"
|
||||||
const val androidx_transition = "androidx.transition:transition:${Versions.androidx_transition}"
|
const val androidx_transition = "androidx.transition:transition:${Versions.androidx_transition}"
|
||||||
const val androidx_work_ktx = "androidx.work:work-runtime-ktx:${Versions.androidx_work}"
|
const val androidx_work_ktx = "androidx.work:work-runtime-ktx:${Versions.androidx_work}"
|
||||||
const val androidx_work_testing = "androidx.work:work-testing:${Versions.androidx_work}"
|
const val androidx_work_testing = "androidx.work:work-testing:${Versions.androidx_work}"
|
||||||
|
Loading…
Reference in New Issue
Block a user