2
0
mirror of https://github.com/fork-maintainers/iceraven-browser synced 2024-11-15 18:12:54 +00:00

Support tab movement/reordering (#22751)

* Adds basic support for tab reordering via drag-and-drop selected tabs

* ktlint/detekt formatting

* Use defaultTabsFilter (now not private) instead of getter

* Convert from position+filter API to target+placeAfter
Unfortunately I still need the filter passed around a bit

* Handle inactive tabs' holder being children of the RecyclerView of the normal tabs
Don't go through LayoutManager needlessly

* Non-working use tabID the whole way. Does not compile.

* Fix to do direct tab ID and use grid setting directly

* Remove non-null assertion. Now fully works for "other" tabs.

* Prevent grouped tabs from being dragged

* Remove unused import

* Add/fix comments

* Do API version check and use deprecated startDrag if too old.

* Build process fails: both outdated and too new, so reverting to just too new

* Use deprecated function and suppress warning

* fix space

* Suppress "TooManyFunctions" on DefaultTabsTrayController

* Repeatedly update tab movement during drag

* Remove multi-tab movement, only allow dragging if tab groups disabled, fix tab positioning during movement
I'm forced to suppress LongParameterList to get the settings information where it needs to go though

* Remove settings argument and corresponding long args suppression: instead get settings from parent AbstractBrowserTrayList's context

* New UI: Select a tab and then, while holding down, start dragging

* Revert to using before/after boolean to accomodate delays
Move drag transparency to start of drag

* Use new BlankDragShadowBuilder and DraggableItemAnimator to handle tab movement

* Replace Pair<>s with data classes

* Only drag if exactly 1 tab selected, don't consume drag event if not used

* Auto-scroll tab tray while dragging near top/bottom edge

* Remove unexpected scrolling on tab bind (triggered when tab is selected)

* Fix broken scroll behavior during dragging

* Cleanup for ktlint/detekt

* Constantly set elevation during drag in case of update
Clean code at drag start

* Add custom drag start behavior

* Add drag distance constant, do all touch-drag behavior in OnTouchListener

* Disable parent vertical scrolling on drag start, fix detekt ComplexCondition

* Minor cleanup/comments

* Revert removal of scroll on bind, this was related to something different

* Correction to prepareForDrop to match documentation- doesn't seem to have any effect

* Simplify via unchecked typecast, use ViewCompat

* Use ViewConfiguration.scaledTouchSlop instead of arbitrary 30px

* Added tabReorderingFeature flag, split drag interactor to separate function to satisfy complexity requirement

Co-authored-by: Steven Knipe <ssk97@case.edu>
Co-authored-by: ssk97 <knipesteven@gmail.com>
Co-authored-by: Sebastian Kaspari <s.kaspari@gmail.com>
This commit is contained in:
Christian Sadilek 2021-12-08 11:36:49 -05:00 committed by GitHub
parent f919e97445
commit 6637f61088
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 317 additions and 0 deletions

View File

@ -73,6 +73,11 @@ object FeatureFlags {
*/ */
val tabGroupFeature = Config.channel.isNightlyOrDebug val tabGroupFeature = Config.channel.isNightlyOrDebug
/**
* Allows tabs to be dragged around as long as tab groups are disabled
*/
val tabReorderingFeature = Config.channel.isNightlyOrDebug
/** /**
* Enables showing search groupings in the History. * Enables showing search groupings in the History.
*/ */

View File

@ -59,6 +59,15 @@ interface TabsTrayController {
*/ */
fun handleMultipleTabsDeletion(tabs: Collection<TabSessionState>) fun handleMultipleTabsDeletion(tabs: Collection<TabSessionState>)
/**
* Moves [tabId] next to before/after [targetId]
*
* @param tabId The tabs to be moved
* @param targetId The id of the tab that the [tab] will be placed next to
* @param placeAfter Place [tabs] before or after the target
*/
fun handleTabsMove(tabId: String, targetId: String?, placeAfter: Boolean)
/** /**
* Navigate from TabsTray to Recently Closed section in the History fragment. * Navigate from TabsTray to Recently Closed section in the History fragment.
*/ */
@ -82,6 +91,7 @@ interface TabsTrayController {
fun handleDeleteAllInactiveTabs() fun handleDeleteAllInactiveTabs()
} }
@Suppress("TooManyFunctions")
class DefaultTabsTrayController( class DefaultTabsTrayController(
private val trayStore: TabsTrayStore, private val trayStore: TabsTrayStore,
private val browserStore: BrowserStore, private val browserStore: BrowserStore,
@ -171,6 +181,23 @@ class DefaultTabsTrayController(
showUndoSnackbarForTab(isPrivate) showUndoSnackbarForTab(isPrivate)
} }
/**
* Moves [tabId] next to before/after [targetId]
*
* @param tabId The tabs to be moved
* @param targetId The id of the tab that the [tab] will be placed next to
* @param placeAfter Place [tabs] before or after the target
*/
override fun handleTabsMove(
tabId: String,
targetId: String?,
placeAfter: Boolean
) {
if (targetId != null && tabId != targetId) {
tabsUseCases.moveTabs(listOf(tabId), targetId, placeAfter)
}
}
/** /**
* Dismisses the tabs tray and navigates to the Recently Closed section in the History fragment. * Dismisses the tabs tray and navigates to the Recently Closed section in the History fragment.
*/ */

View File

@ -35,6 +35,15 @@ interface TabsTrayInteractor {
*/ */
fun onInactiveDebugClicked(tabs: Collection<TabSessionState>) fun onInactiveDebugClicked(tabs: Collection<TabSessionState>)
/**
* Invoked when [tabId] should be moved to before/after [targetId] from a drag-drop operation
*/
fun onTabsMove(
tabId: String,
targetId: String?,
placeAfter: Boolean
)
/** /**
* Deletes all inactive tabs. * Deletes all inactive tabs.
*/ */
@ -65,6 +74,14 @@ class DefaultTabsTrayInteractor(
controller.handleMultipleTabsDeletion(tabs) controller.handleMultipleTabsDeletion(tabs)
} }
override fun onTabsMove(
tabId: String,
targetId: String?,
placeAfter: Boolean
) {
controller.handleTabsMove(tabId, targetId, placeAfter)
}
override fun onInactiveDebugClicked(tabs: Collection<TabSessionState>) { override fun onInactiveDebugClicked(tabs: Collection<TabSessionState>) {
controller.forceTabsAsInactive(tabs) controller.forceTabsAsInactive(tabs)
} }

View File

@ -4,13 +4,18 @@
package org.mozilla.fenix.tabstray.browser package org.mozilla.fenix.tabstray.browser
import android.annotation.SuppressLint
import android.graphics.PointF
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewConfiguration
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.AppCompatImageButton import androidx.appcompat.widget.AppCompatImageButton
import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.browser.state.selector.findTabOrCustomTab
@ -24,6 +29,7 @@ import mozilla.components.browser.toolbar.MAX_URI_LENGTH
import mozilla.components.concept.base.images.ImageLoadRequest import mozilla.components.concept.base.images.ImageLoadRequest
import mozilla.components.concept.base.images.ImageLoader import mozilla.components.concept.base.images.ImageLoader
import mozilla.components.concept.engine.mediasession.MediaSession import mozilla.components.concept.engine.mediasession.MediaSession
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
@ -31,6 +37,7 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.removeAndDisable import org.mozilla.fenix.ext.removeAndDisable
import org.mozilla.fenix.ext.removeTouchDelegate import org.mozilla.fenix.ext.removeTouchDelegate
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showAndEnable import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.selection.SelectionHolder import org.mozilla.fenix.selection.SelectionHolder
@ -77,6 +84,9 @@ abstract class AbstractBrowserTabViewHolder(
override var tab: TabSessionState? = null override var tab: TabSessionState? = null
internal var beingDragged: Boolean = false
private var touchStartPoint: PointF? = null
/** /**
* Displays the data of the given session and notifies the given observable about events. * Displays the data of the given session and notifies the given observable about events.
*/ */
@ -88,6 +98,7 @@ abstract class AbstractBrowserTabViewHolder(
delegate: TabsTray.Delegate delegate: TabsTray.Delegate
) { ) {
this.tab = tab this.tab = tab
beingDragged = false
updateTitle(tab) updateTitle(tab)
updateUrl(tab) updateUrl(tab)
@ -226,6 +237,50 @@ abstract class AbstractBrowserTabViewHolder(
false false
} }
} }
setDragInteractor(item, holder, interactor)
}
@SuppressLint("ClickableViewAccessibility")
private fun setDragInteractor(
item: TabSessionState,
holder: SelectionHolder<TabSessionState>,
interactor: BrowserTrayInteractor
) {
// Since I immediately pass the event to onTouchEvent if it's not a move
// The ClickableViewAccessibility warning isn't useful
itemView.setOnTouchListener { view, motionEvent ->
when (motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN -> {
touchStartPoint = PointF(motionEvent.x, motionEvent.y)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
touchStartPoint = null
}
MotionEvent.ACTION_MOVE -> {
val parent = itemView.parent as AbstractBrowserTrayList
val touchStart = touchStartPoint
val selected = holder.selectedItems
val selectsOnlyThis = (selected.size == 1 && selected.contains(item))
val featureEnabled = FeatureFlags.tabReorderingFeature &&
!parent.context.settings().searchTermTabGroupsAreEnabled
if (featureEnabled && selectsOnlyThis && touchStart != null) {
// Prevent scrolling if the user tries to start drag vertically
parent.requestDisallowInterceptTouchEvent(true)
// Only start deselect+drag if the user drags far enough
val dist = PointF.length(touchStart.x - motionEvent.x, touchStart.y - motionEvent.y)
if (dist > ViewConfiguration.get(parent.context).scaledTouchSlop) {
interactor.deselect(item) // Exit selection mode
touchStartPoint = null
val dragOffset = PointF(motionEvent.x, motionEvent.y)
val shadow = BlankDragShadowBuilder()
ViewCompat.startDragAndDrop(itemView, null, shadow, TabDragData(item, dragOffset), 0)
}
return@setOnTouchListener true
}
}
}
view.onTouchEvent(motionEvent)
}
} }
companion object { companion object {

View File

@ -5,10 +5,17 @@
package org.mozilla.fenix.tabstray.browser package org.mozilla.fenix.tabstray.browser
import android.content.Context import android.content.Context
import android.graphics.PointF
import android.graphics.Rect
import android.util.AttributeSet import android.util.AttributeSet
import android.view.DragEvent
import android.view.View
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.tabstray.TabViewHolder
import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayStore import org.mozilla.fenix.tabstray.TabsTrayStore
import kotlin.math.abs
/** /**
* The base class for a tabs tray list that wants to display browser tabs. * The base class for a tabs tray list that wants to display browser tabs.
@ -22,6 +29,9 @@ abstract class AbstractBrowserTrayList @JvmOverloads constructor(
lateinit var interactor: TabsTrayInteractor lateinit var interactor: TabsTrayInteractor
lateinit var tabsTrayStore: TabsTrayStore lateinit var tabsTrayStore: TabsTrayStore
private var lastDragPos: PointF? = null
private var lastDragData: TabDragData? = null
protected val swipeToDelete by lazy { protected val swipeToDelete by lazy {
SwipeToDeleteBinding(tabsTrayStore) SwipeToDeleteBinding(tabsTrayStore)
} }
@ -32,6 +42,8 @@ abstract class AbstractBrowserTrayList @JvmOverloads constructor(
swipeToDelete.start() swipeToDelete.start()
adapter?.onAttachedToRecyclerView(this) adapter?.onAttachedToRecyclerView(this)
this.setOnDragListener(dragListen)
itemAnimator = DraggableItemAnimator()
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
@ -41,5 +53,148 @@ abstract class AbstractBrowserTrayList @JvmOverloads constructor(
// Notify the adapter that it is released from the view preemptively. // Notify the adapter that it is released from the view preemptively.
adapter?.onDetachedFromRecyclerView(this) adapter?.onDetachedFromRecyclerView(this)
this.setOnDragListener(null)
}
// Find the closest item to the x/y position of the drop.
private data class DropPositionData(val id: String, val placeAfter: Boolean, val view: View)
private fun getDropPosition(x: Float, y: Float, source: String): DropPositionData? {
if (childCount < 2) return null // If there's 0 or 1 tabs visible, can't reorder
var bestDist = Float.MAX_VALUE
var bestOut: DropPositionData? = null
var seenSource = false
for (i in 0 until childCount) {
val proposedTarget = getChildAt(i)
val targetHolder = findContainingViewHolder(proposedTarget)
if (targetHolder is TabViewHolder) {
val rect = Rect() // Get post-animation positioning
getDecoratedBoundsWithMargins(proposedTarget, rect)
val targetX = (rect.left + rect.right) / 2
val targetY = (rect.top + rect.bottom) / 2
val xDiff = x - targetX
val yDiff = y - targetY
val dist = abs(xDiff) + abs(yDiff)
val id = targetHolder.tab?.id
// Determine before/after drop placement
// based on if source tab is coming from before/after the target
if (id == source) seenSource = true
if (dist < bestDist && id != null) {
bestDist = dist
bestOut = DropPositionData(id, seenSource, proposedTarget)
}
}
}
return bestOut
}
private fun findSourceViewAndHolder(id: String): Pair<View, AbstractBrowserTabViewHolder>? {
for (i in 0 until childCount) {
val proposed = getChildAt(i)
val targetHolder = findContainingViewHolder(proposed)
if (targetHolder is AbstractBrowserTabViewHolder && targetHolder.tab?.id == id) {
return Pair(proposed, targetHolder)
}
}
return null
}
private val dragListen = OnDragListener { _, event ->
if (event.localState is TabDragData) {
val (tab, _) = event.localState as TabDragData
val sourceId = tab.id
val sources = findSourceViewAndHolder(sourceId)
when (event.action) {
DragEvent.ACTION_DRAG_STARTED -> {
// Put the dragged tab on top of all other tabs
if (sources != null) {
val (sourceView, sourceViewHolder) = sources
sourceViewHolder.beingDragged = true
sourceView.elevation = DRAGGED_TAB_ELEVATION
}
// Setup the scrolling/updating loop
lastDragPos = PointF(event.x, event.y)
lastDragData = event.localState as TabDragData
handler.postDelayed(dragRunnable, DRAG_UPDATE_PERIOD_MS)
true
}
DragEvent.ACTION_DRAG_ENTERED -> {
true
}
DragEvent.ACTION_DRAG_LOCATION -> {
lastDragPos = PointF(event.x, event.y)
true
}
DragEvent.ACTION_DRAG_EXITED -> {
true
}
DragEvent.ACTION_DROP -> {
true
}
DragEvent.ACTION_DRAG_ENDED -> {
// Move tab to center, set dragging to false, return tab to normal height
if (sources != null) {
val (sourceView, sourceViewHolder) = sources
sourceViewHolder.beingDragged = false
sourceView.elevation = 0f
sourceView.animate()
.translationX(0f).translationY(0f).duration =
itemAnimator?.moveDuration ?: 0
}
// This will stop the scroll/update loop
lastDragPos = null
lastDragData = null
true
}
else -> { // Unknown action
false
}
}
} else false
}
private val dragRunnable: Runnable = object : Runnable {
override fun run() {
val pos = lastDragPos
val data = lastDragData
if (pos == null || data == null) return
val (tab, dragOffset) = data
val sourceId = tab.id
val sources = findSourceViewAndHolder(sourceId)
// Move the tab's visual position
if (sources != null) {
val (sourceView, sourceViewHolder) = sources
sourceView.x = pos.x - dragOffset.x
sourceView.y = pos.y - dragOffset.y
sourceViewHolder.beingDragged = true
sourceView.elevation = DRAGGED_TAB_ELEVATION
// Move the tab's position in the list
val target = getDropPosition(pos.x, pos.y, tab.id)
if (target != null) {
val (targetId, placeAfter, targetView) = target
if (sourceView != targetView) {
interactor.onTabsMove(tab.id, targetId, placeAfter)
// Deal with https://issuetracker.google.com/issues/37018279
// See also https://stackoverflow.com/questions/27992427
(layoutManager as? ItemTouchHelper.ViewDropHandler)?.prepareForDrop(
sourceView, targetView, sourceView.left, sourceView.top
)
}
}
}
// Scroll the tray
var scroll = 0
if (pos.y < SCROLL_AREA) scroll = -SCROLL_SPEED
if (pos.y > height - SCROLL_AREA) scroll = SCROLL_SPEED
scrollBy(0, scroll)
// Repeats forever, until lastDragPos/Data are null
handler.postDelayed(this, DRAG_UPDATE_PERIOD_MS)
}
}
companion object {
internal const val DRAGGED_TAB_ELEVATION = 10f
internal const val DRAG_UPDATE_PERIOD_MS = 10L
internal const val SCROLL_SPEED = 20
internal const val SCROLL_AREA = 200
} }
} }

View File

@ -0,0 +1,22 @@
/* 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.tabstray.browser
import android.graphics.Canvas
import android.graphics.Point
import android.view.View
class BlankDragShadowBuilder : View.DragShadowBuilder() {
override fun onProvideShadowMetrics(outShadowSize: Point?, outShadowTouchPoint: Point?) {
outShadowSize?.x = 1
outShadowSize?.y = 1
outShadowTouchPoint?.x = 0
outShadowTouchPoint?.y = 0
}
override fun onDrawShadow(canvas: Canvas?) {
// Do nothing
}
}

View File

@ -0,0 +1,26 @@
/* 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.tabstray.browser
import androidx.annotation.NonNull
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.RecyclerView
class DraggableItemAnimator : DefaultItemAnimator() {
override fun animatePersistence(
@NonNull viewHolder: RecyclerView.ViewHolder,
@NonNull preLayoutInfo: RecyclerView.ItemAnimator.ItemHolderInfo,
@NonNull postLayoutInfo: RecyclerView.ItemAnimator.ItemHolderInfo
): Boolean {
// While being dragged, keep the tab visually in place
if (viewHolder is AbstractBrowserTabViewHolder && viewHolder.beingDragged) {
viewHolder.itemView.translationX -= postLayoutInfo.left - preLayoutInfo.left
viewHolder.itemView.translationY -= postLayoutInfo.top - preLayoutInfo.top
dispatchAnimationFinished(viewHolder)
return false
}
return super.animatePersistence(viewHolder, preLayoutInfo, postLayoutInfo)
}
}

View File

@ -0,0 +1,10 @@
/* 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.tabstray.browser
import android.graphics.PointF
import mozilla.components.browser.state.state.TabSessionState
data class TabDragData(val tab: TabSessionState, val dragOffset: PointF)