diff --git a/app/build.gradle b/app/build.gradle index ff6ef3d6a3..ddf6ce535c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -503,6 +503,7 @@ dependencies { implementation Deps.androidx_lifecycle_viewmodel implementation Deps.androidx_core implementation Deps.androidx_core_ktx + implementation Deps.androidx_dynamic_animation implementation Deps.androidx_transition implementation Deps.androidx_work_ktx implementation Deps.google_material diff --git a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt index 2f76237edf..6e6d4a04d6 100644 --- a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt +++ b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -24,4 +24,9 @@ object FeatureFlags { * Enables new tab tray pref */ val tabTray = Config.channel.isNightlyOrDebug + + /** + * Enables swipe on toolbar to switch tabs + */ + val swipeToSwitchTabs = Config.channel.isNightlyOrDebug } diff --git a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt index 4c3b5882bd..ae3f9a1586 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt @@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.Observer import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.Snackbar +import kotlinx.android.synthetic.main.fragment_browser.* import kotlinx.android.synthetic.main.fragment_browser.view.* import kotlinx.coroutines.ExperimentalCoroutinesApi 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.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R import org.mozilla.fenix.addons.runIfFragmentIsAttached import org.mozilla.fenix.components.FenixSnackbar @@ -66,11 +68,24 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { return view } + @Suppress("LongMethod") override fun initializeUI(view: View): Session? { val context = requireContext() val components = context.components 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 = BrowserToolbar.ToggleButton( image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_readermode)!!, diff --git a/app/src/main/java/org/mozilla/fenix/browser/SwipeGestureLayout.kt b/app/src/main/java/org/mozilla/fenix/browser/SwipeGestureLayout.kt new file mode 100644 index 0000000000..c1bccab6c6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/browser/SwipeGestureLayout.kt @@ -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() + 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) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt b/app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt new file mode 100644 index 0000000000..2c14e76611 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/browser/TabPreview.kt @@ -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 { + 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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/browser/ToolbarGestureHandler.kt b/app/src/main/java/org/mozilla/fenix/browser/ToolbarGestureHandler.kt new file mode 100644 index 0000000000..00508690d7 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/browser/ToolbarGestureHandler.kt @@ -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 + } +} diff --git a/app/src/main/res/layout/fragment_browser.xml b/app/src/main/res/layout/fragment_browser.xml index 08dc10d49e..63e9fde8dc 100644 --- a/app/src/main/res/layout/fragment_browser.xml +++ b/app/src/main/res/layout/fragment_browser.xml @@ -2,51 +2,66 @@ - - + + + + + + + + + + + + + + + + - - - - - - - - - - + diff --git a/app/src/main/res/layout/tab_preview.xml b/app/src/main/res/layout/tab_preview.xml new file mode 100644 index 0000000000..7f98690604 --- /dev/null +++ b/app/src/main/res/layout/tab_preview.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 4421c1b0a0..060e72ecf6 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -28,6 +28,7 @@ object Versions { const val androidx_paging = "2.1.0" const val androidx_transition = "1.3.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_flexbox = "2.0.1" @@ -170,6 +171,7 @@ object Deps { const val androidx_recyclerview = "androidx.recyclerview:recyclerview:${Versions.androidx_recyclerview}" 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_dynamic_animation = "androidx.dynamicanimation:dynamicanimation:${Versions.androidx_dynamic_animation}" 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_testing = "androidx.work:work-testing:${Versions.androidx_work}"