Skip to content
This repository has been archived by the owner on Feb 20, 2023. It is now read-only.

Support tab movement/reordering #22751

Merged
merged 50 commits into from
Dec 8, 2021
Merged
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
6628943
Adds basic support for tab reordering via drag-and-drop selected tabs
ssk97 Sep 5, 2021
99c3b97
Merge branch 'mozilla-mobile:main' into main
ssk97 Sep 5, 2021
8841c19
ktlint/detekt formatting
ssk97 Sep 5, 2021
c213d76
Merge branch 'main' of https://github.com/ssk97/fenix
ssk97 Sep 5, 2021
1881122
Use defaultTabsFilter (now not private) instead of getter
ssk97 Sep 5, 2021
d0030d8
Convert from position+filter API to target+placeAfter
ssk97 Sep 16, 2021
867cbee
Merge branch 'HEAD' of https://github.com/mozilla-mobile/fenix
ssk97 Sep 16, 2021
63c3a89
Handle inactive tabs' holder being children of the RecyclerView of th…
ssk97 Sep 17, 2021
adfd670
Non-working use tabID the whole way. Does not compile.
ssk97 Sep 21, 2021
8cf466a
Merge branch 'HEAD' of https://github.com/mozilla-mobile/fenix
ssk97 Sep 21, 2021
3b87483
Fix to do direct tab ID and use grid setting directly
ssk97 Sep 21, 2021
92d3208
Remove non-null assertion. Now fully works for "other" tabs.
ssk97 Sep 21, 2021
499b80b
Merge branch 'HEAD' of https://github.com/mozilla-mobile/fenix
ssk97 Sep 23, 2021
ea082b3
Prevent grouped tabs from being dragged
ssk97 Sep 23, 2021
0c3513e
Remove unused import
ssk97 Sep 23, 2021
3dc6ac4
Merge branch 'HEAD' of https://github.com/mozilla-mobile/fenix
ssk97 Sep 23, 2021
6402086
Add/fix comments
ssk97 Sep 23, 2021
3fd6f62
Do API version check and use deprecated startDrag if too old.
ssk97 Sep 23, 2021
a0c0547
Build process fails: both outdated and too new, so reverting to just …
ssk97 Sep 23, 2021
97498b2
Use deprecated function and suppress warning
ssk97 Sep 24, 2021
7f1b63f
fix space
ssk97 Sep 24, 2021
2f70006
Merge branch 'HEAD' of https://github.com/mozilla-mobile/fenix
ssk97 Oct 1, 2021
5259837
Suppress "TooManyFunctions" on DefaultTabsTrayController
ssk97 Oct 1, 2021
cbc7563
Merge branch 'HEAD' of https://github.com/mozilla-mobile/fenix
ssk97 Oct 31, 2021
bff73b6
Repeatedly update tab movement during drag
ssk97 Nov 4, 2021
8456f8a
Merge branch 'HEAD' of https://github.com/mozilla-mobile/fenix
ssk97 Nov 4, 2021
0a5a60f
Remove multi-tab movement, only allow dragging if tab groups disabled…
ssk97 Nov 5, 2021
f3bfca7
Remove settings argument and corresponding long args suppression: ins…
ssk97 Nov 5, 2021
ae79fc3
New UI: Select a tab and then, while holding down, start dragging
ssk97 Nov 5, 2021
9d371f7
Revert to using before/after boolean to accomodate delays
ssk97 Nov 5, 2021
1db1c7d
Merge branch 'HEAD' of https://github.com/mozilla-mobile/fenix
ssk97 Nov 9, 2021
5a715fd
Use new BlankDragShadowBuilder and DraggableItemAnimator to handle ta…
ssk97 Nov 9, 2021
c4964ad
Replace Pair<>s with data classes
ssk97 Nov 9, 2021
85c5e08
Only drag if exactly 1 tab selected, don't consume drag event if not …
ssk97 Nov 9, 2021
ca3c844
Auto-scroll tab tray while dragging near top/bottom edge
ssk97 Nov 17, 2021
f317064
Remove unexpected scrolling on tab bind (triggered when tab is selected)
ssk97 Nov 17, 2021
bfb57dd
Fix broken scroll behavior during dragging
ssk97 Nov 17, 2021
d364971
Cleanup for ktlint/detekt
ssk97 Nov 17, 2021
c72675e
Constantly set elevation during drag in case of update
ssk97 Nov 17, 2021
f76afeb
Merge branch 'main' of https://github.com/mozilla-mobile/fenix
ssk97 Nov 23, 2021
42842cd
Add custom drag start behavior
ssk97 Nov 23, 2021
d718241
Add drag distance constant, do all touch-drag behavior in OnTouchList…
ssk97 Nov 24, 2021
d9408d9
Disable parent vertical scrolling on drag start, fix detekt ComplexCo…
ssk97 Nov 24, 2021
4e32e1b
Minor cleanup/comments
ssk97 Nov 24, 2021
1d90735
Revert removal of scroll on bind, this was related to something diffe…
ssk97 Nov 24, 2021
b154485
Correction to prepareForDrop to match documentation- doesn't seem to …
ssk97 Nov 24, 2021
377df59
Simplify via unchecked typecast, use ViewCompat
ssk97 Nov 25, 2021
9c810b9
Use ViewConfiguration.scaledTouchSlop instead of arbitrary 30px
ssk97 Nov 26, 2021
bf4670c
Added tabReorderingFeature flag, split drag interactor to separate fu…
ssk97 Dec 1, 2021
dadea75
Merge branch 'main' into land-21157
pocmo Dec 8, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/main/java/org/mozilla/fenix/FeatureFlags.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ object FeatureFlags {
*/
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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ interface TabsTrayController {
*/
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.
*/
Expand All @@ -82,6 +91,7 @@ interface TabsTrayController {
fun handleDeleteAllInactiveTabs()
}

@Suppress("TooManyFunctions")
class DefaultTabsTrayController(
private val trayStore: TabsTrayStore,
private val browserStore: BrowserStore,
Expand Down Expand Up @@ -171,6 +181,23 @@ class DefaultTabsTrayController(
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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ interface TabsTrayInteractor {
*/
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.
*/
Expand Down Expand Up @@ -65,6 +74,14 @@ class DefaultTabsTrayInteractor(
controller.handleMultipleTabsDeletion(tabs)
}

override fun onTabsMove(
tabId: String,
targetId: String?,
placeAfter: Boolean
) {
controller.handleTabsMove(tabId, targetId, placeAfter)
}

override fun onInactiveDebugClicked(tabs: Collection<TabSessionState>) {
controller.forceTabsAsInactive(tabs)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@

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.ViewConfiguration
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.AppCompatImageButton
import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import mozilla.components.browser.state.selector.findTabOrCustomTab
Expand All @@ -24,13 +29,15 @@ import mozilla.components.browser.toolbar.MAX_URI_LENGTH
import mozilla.components.concept.base.images.ImageLoadRequest
import mozilla.components.concept.base.images.ImageLoader
import mozilla.components.concept.engine.mediasession.MediaSession
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.removeAndDisable
import org.mozilla.fenix.ext.removeTouchDelegate
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.selection.SelectionHolder
Expand Down Expand Up @@ -77,6 +84,9 @@ abstract class AbstractBrowserTabViewHolder(

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.
*/
Expand All @@ -88,6 +98,7 @@ abstract class AbstractBrowserTabViewHolder(
delegate: TabsTray.Delegate
) {
this.tab = tab
beingDragged = false

updateTitle(tab)
updateUrl(tab)
Expand Down Expand Up @@ -226,6 +237,50 @@ abstract class AbstractBrowserTabViewHolder(
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@
package org.mozilla.fenix.tabstray.browser

import android.content.Context
import android.graphics.PointF
import android.graphics.Rect
import android.util.AttributeSet
import android.view.DragEvent
import android.view.View
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.tabstray.TabViewHolder
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayStore
import kotlin.math.abs

/**
* The base class for a tabs tray list that wants to display browser tabs.
Expand All @@ -22,6 +29,9 @@ abstract class AbstractBrowserTrayList @JvmOverloads constructor(
lateinit var interactor: TabsTrayInteractor
lateinit var tabsTrayStore: TabsTrayStore

private var lastDragPos: PointF? = null
private var lastDragData: TabDragData? = null

protected val swipeToDelete by lazy {
SwipeToDeleteBinding(tabsTrayStore)
}
Expand All @@ -32,6 +42,8 @@ abstract class AbstractBrowserTrayList @JvmOverloads constructor(
swipeToDelete.start()

adapter?.onAttachedToRecyclerView(this)
this.setOnDragListener(dragListen)
itemAnimator = DraggableItemAnimator()
}

override fun onDetachedFromWindow() {
Expand All @@ -41,5 +53,148 @@ abstract class AbstractBrowserTrayList @JvmOverloads constructor(

// Notify the adapter that it is released from the view preemptively.
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
}
}
Loading