Bug 1810780 - Add reorder gesture to composed tabs tray

fenix/119.0
Alexandru2909 10 months ago committed by mergify[bot]
parent 26f92d8590
commit ed303f6814

@ -9,6 +9,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
@ -82,12 +83,13 @@ import org.mozilla.fenix.theme.FirefoxTheme
* enabled.
* @param multiSelectionSelected Indicates if the item should be render as multi selection selected
* option.
* @param shouldClickListen Whether or not the item should stop listening to click events.
* @param onCloseClick Callback to handle the click event of the close button.
* @param onMediaClick Callback to handle when the media item is clicked.
* @param onClick Callback to handle when item is clicked.
* @param onLongClick Callback to handle when item is long clicked.
* @param onLongClick Optional callback to handle when item is long clicked.
*/
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
@Composable
@Suppress("MagicNumber", "LongParameterList", "LongMethod")
fun TabGridItem(
@ -97,10 +99,11 @@ fun TabGridItem(
isSelected: Boolean = false,
multiSelectionEnabled: Boolean = false,
multiSelectionSelected: Boolean = false,
shouldClickListen: Boolean = true,
onCloseClick: (tab: TabSessionState) -> Unit,
onMediaClick: (tab: TabSessionState) -> Unit,
onClick: (tab: TabSessionState) -> Unit,
onLongClick: (tab: TabSessionState) -> Unit,
onLongClick: ((tab: TabSessionState) -> Unit)? = null,
) {
val tabBorderModifier = if (isSelected) {
Modifier.border(
@ -143,6 +146,26 @@ fun TabGridItem(
.wrapContentSize()
.testTag(TabsTrayTestTag.tabItemRoot),
) {
val clickableModifier = if (onLongClick == null) {
Modifier.clickable(
enabled = shouldClickListen,
interactionSource = interactionSource,
indication = rememberRipple(
color = clickableColor(),
),
onClick = { onClick(tab) },
)
} else {
Modifier.combinedClickable(
enabled = shouldClickListen,
interactionSource = interactionSource,
indication = rememberRipple(
color = clickableColor(),
),
onLongClick = { onLongClick(tab) },
onClick = { onClick(tab) },
)
}
Card(
modifier = Modifier
.fillMaxWidth()
@ -150,17 +173,7 @@ fun TabGridItem(
.padding(4.dp)
.then(tabBorderModifier)
.padding(4.dp)
.combinedClickable(
interactionSource = interactionSource,
indication = rememberRipple(
color = when (isSystemInDarkTheme()) {
true -> PhotonColors.White
false -> PhotonColors.Black
},
),
onLongClick = { onLongClick(tab) },
onClick = { onClick(tab) },
),
.then(clickableModifier),
elevation = 0.dp,
shape = RoundedCornerShape(dimensionResource(id = R.dimen.tab_tray_grid_item_border_radius)),
border = BorderStroke(1.dp, FirefoxTheme.colors.borderPrimary),
@ -251,6 +264,12 @@ fun TabGridItem(
}
}
@Composable
private fun clickableColor() = when (isSystemInDarkTheme()) {
true -> PhotonColors.White
false -> PhotonColors.Black
}
/**
* Thumbnail specific for the [TabGridItem], which can be selected.
*
@ -320,7 +339,6 @@ private fun TabGridItemPreview() {
onCloseClick = {},
onMediaClick = {},
onClick = {},
onLongClick = {},
)
}
}

@ -6,6 +6,7 @@ package org.mozilla.fenix.compose.tabstray
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
@ -67,10 +68,11 @@ import org.mozilla.fenix.theme.FirefoxTheme
* enabled.
* @param multiSelectionSelected Indicates if the item should be render as multi selection selected
* option.
* @param shouldClickListen Whether or not the item should stop listening to click events.
* @param onCloseClick Callback to handle the click event of the close button.
* @param onMediaClick Callback to handle when the media item is clicked.
* @param onClick Callback to handle when item is clicked.
* @param onLongClick Callback to handle when item is long clicked.
* @param onLongClick Optional callback to handle when item is long clicked.
*/
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
@Composable
@ -82,10 +84,11 @@ fun TabListItem(
isSelected: Boolean = false,
multiSelectionEnabled: Boolean = false,
multiSelectionSelected: Boolean = false,
shouldClickListen: Boolean = true,
onCloseClick: (tab: TabSessionState) -> Unit,
onMediaClick: (tab: TabSessionState) -> Unit,
onClick: (tab: TabSessionState) -> Unit,
onLongClick: (tab: TabSessionState) -> Unit,
onLongClick: ((tab: TabSessionState) -> Unit)? = null,
) {
val contentBackgroundColor = if (isSelected) {
FirefoxTheme.colors.layerAccentNonOpaque
@ -107,6 +110,27 @@ fun TabListItem(
// Used to propagate the ripple effect to the whole tab
val interactionSource = remember { MutableInteractionSource() }
val clickableModifier = if (onLongClick == null) {
Modifier.clickable(
enabled = shouldClickListen,
interactionSource = interactionSource,
indication = rememberRipple(
color = clickableColor(),
),
onClick = { onClick(tab) },
)
} else {
Modifier.combinedClickable(
enabled = shouldClickListen,
interactionSource = interactionSource,
indication = rememberRipple(
color = clickableColor(),
),
onLongClick = { onLongClick(tab) },
onClick = { onClick(tab) },
)
}
SwipeToDismiss(
state = dismissState,
enabled = !multiSelectionEnabled,
@ -119,17 +143,7 @@ fun TabListItem(
.fillMaxWidth()
.background(FirefoxTheme.colors.layer3)
.background(contentBackgroundColor)
.combinedClickable(
interactionSource = interactionSource,
indication = rememberRipple(
color = when (isSystemInDarkTheme()) {
true -> PhotonColors.White
false -> PhotonColors.Black
},
),
onLongClick = { onLongClick(tab) },
onClick = { onClick(tab) },
)
.then(clickableModifier)
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp)
.testTag(TabsTrayTestTag.tabItemRoot),
verticalAlignment = Alignment.CenterVertically,
@ -191,6 +205,12 @@ fun TabListItem(
}
}
@Composable
private fun clickableColor() = when (isSystemInDarkTheme()) {
true -> PhotonColors.White
false -> PhotonColors.Black
}
@Composable
@Suppress("LongParameterList")
private fun Thumbnail(
@ -263,7 +283,6 @@ private fun TabListItemPreview() {
onCloseClick = {},
onMediaClick = {},
onClick = {},
onLongClick = {},
)
}
}
@ -279,7 +298,6 @@ private fun SelectedTabListItemPreview() {
onCloseClick = {},
onMediaClick = {},
onClick = {},
onLongClick = {},
multiSelectionEnabled = true,
multiSelectionSelected = true,
)

@ -94,7 +94,9 @@ import mozilla.components.browser.storage.sync.Tab as SyncTab
* @param onTabAutoCloseBannerViewOptionsClick Invoked when the user clicks to view the auto close options.
* @param onTabAutoCloseBannerDismiss Invoked when the user clicks to dismiss the auto close banner.
* @param onTabAutoCloseBannerShown Invoked when the auto close banner has been shown to the user.
* @param onMove Invoked after the drag and drop gesture completed. Swaps positions of two tabs.
*/
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "LongParameterList", "ComplexMethod")
@Composable
fun TabsTray(
@ -133,6 +135,7 @@ fun TabsTray(
onTabAutoCloseBannerViewOptionsClick: () -> Unit,
onTabAutoCloseBannerDismiss: () -> Unit,
onTabAutoCloseBannerShown: () -> Unit,
onMove: (String, String?, Boolean) -> Unit,
) {
val multiselectMode = tabsTrayStore
.observeAsComposableState { state -> state.mode }.value ?: TabsTrayState.Mode.Normal
@ -211,6 +214,7 @@ fun TabsTray(
onEnableInactiveTabAutoCloseClick = onEnableInactiveTabAutoCloseClick,
onInactiveTabClick = onInactiveTabClick,
onInactiveTabClose = onInactiveTabClose,
onMove = onMove,
)
}
@ -224,6 +228,7 @@ fun TabsTray(
onTabMediaClick = onTabMediaClick,
onTabClick = onTabClick,
onTabLongClick = onTabLongClick,
onMove = onMove,
)
}
@ -259,6 +264,7 @@ private fun NormalTabsPage(
onEnableInactiveTabAutoCloseClick: () -> Unit,
onInactiveTabClick: (TabSessionState) -> Unit,
onInactiveTabClose: (TabSessionState) -> Unit,
onMove: (String, String?, Boolean) -> Unit,
) {
val inactiveTabsExpanded = appStore
.observeAsComposableState { state -> state.inactiveTabsExpanded }.value ?: false
@ -316,6 +322,8 @@ private fun NormalTabsPage(
onTabClick = onTabClick,
onTabLongClick = onTabLongClick,
header = optionalInactiveTabsHeader,
onTabDragStart = { tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) },
onMove = onMove,
)
} else {
EmptyTabPage(isPrivate = false)
@ -333,6 +341,7 @@ private fun PrivateTabsPage(
onTabMediaClick: (TabSessionState) -> Unit,
onTabClick: (TabSessionState) -> Unit,
onTabLongClick: (TabSessionState) -> Unit,
onMove: (String, String?, Boolean) -> Unit,
) {
val selectedTabId = browserStore
.observeAsComposableState { state -> state.selectedTabId }.value
@ -353,6 +362,11 @@ private fun PrivateTabsPage(
onTabMediaClick = onTabMediaClick,
onTabClick = onTabClick,
onTabLongClick = onTabLongClick,
onTabDragStart = {
// Because we don't currently support selection mode for private tabs,
// there's no need to exit selection mode when dragging tabs.
},
onMove = onMove,
)
} else {
EmptyTabPage(isPrivate = true)
@ -577,6 +591,7 @@ private fun TabsTrayPreviewRoot(
onTabAutoCloseBannerViewOptionsClick = {},
onTabAutoCloseBannerDismiss = {},
onTabAutoCloseBannerShown = {},
onMove = { _, _, _ -> },
)
}
}

@ -304,6 +304,7 @@ class TabsTrayFragment : AppCompatDialogFragment() {
onTabAutoCloseBannerShown = {
requireContext().settings().lastCfrShownTimeInMillis = System.currentTimeMillis()
},
onMove = tabsTrayInteractor::onTabsMove,
)
}
}

@ -4,6 +4,7 @@
package org.mozilla.fenix.tabstray
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
@ -18,7 +19,11 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -31,10 +36,21 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.compose.annotation.LightDarkPreview
import org.mozilla.fenix.compose.tabstray.TabGridItem
import org.mozilla.fenix.compose.tabstray.TabListItem
import org.mozilla.fenix.tabstray.browser.compose.DragItemContainer
import org.mozilla.fenix.tabstray.browser.compose.createGridReorderState
import org.mozilla.fenix.tabstray.browser.compose.createListReorderState
import org.mozilla.fenix.tabstray.browser.compose.detectGridPressAndDragGestures
import org.mozilla.fenix.tabstray.browser.compose.detectVerticalPressAndDrag
import org.mozilla.fenix.tabstray.ext.MIN_COLUMN_WIDTH_DP
import org.mozilla.fenix.theme.FirefoxTheme
import kotlin.math.max
// Key for the span item at the bottom of the tray, used to make the item not reorderable.
const val SPAN_ITEM_KEY = "span"
// Key for the header item at the top of the tray, used to make the item not reorderable.
const val HEADER_ITEM_KEY = "header"
/**
* Top-level UI for displaying a list of tabs.
*
@ -49,6 +65,8 @@ import kotlin.math.max
* @param onTabMediaClick Invoked when the user interacts with a tab's media controls.
* @param onTabClick Invoked when the user clicks on a tab.
* @param onTabLongClick Invoked when the user long clicks a tab.
* @param onMove Invoked when the user moves a tab.
* @param onTabDragStart Invoked when starting to drag a tab.
* @param header Optional layout to display before [tabs].
*/
@Suppress("LongParameterList")
@ -64,6 +82,8 @@ fun TabLayout(
onTabMediaClick: (TabSessionState) -> Unit,
onTabClick: (TabSessionState) -> Unit,
onTabLongClick: (TabSessionState) -> Unit,
onMove: (String, String?, Boolean) -> Unit,
onTabDragStart: () -> Unit,
header: (@Composable () -> Unit)? = null,
) {
var selectedTabIndex = 0
@ -87,6 +107,8 @@ fun TabLayout(
onTabMediaClick = onTabMediaClick,
onTabClick = onTabClick,
onTabLongClick = onTabLongClick,
onMove = onMove,
onTabDragStart = onTabDragStart,
header = header,
)
} else {
@ -101,11 +123,14 @@ fun TabLayout(
onTabMediaClick = onTabMediaClick,
onTabClick = onTabClick,
onTabLongClick = onTabLongClick,
onMove = onMove,
onTabDragStart = onTabDragStart,
header = header,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongParameterList")
@Composable
private fun TabGrid(
@ -119,6 +144,8 @@ private fun TabGrid(
onTabMediaClick: (TabSessionState) -> Unit,
onTabClick: (TabSessionState) -> Unit,
onTabLongClick: (TabSessionState) -> Unit,
onMove: (String, String?, Boolean) -> Unit,
onTabDragStart: () -> Unit,
header: (@Composable () -> Unit)? = null,
) {
val state = rememberLazyGridState(initialFirstVisibleItemIndex = selectedTabIndex)
@ -129,13 +156,43 @@ private fun TabGrid(
)
val isInMultiSelectMode = selectionMode is TabsTrayState.Mode.Select
val reorderState = createGridReorderState(
gridState = state,
onMove = { initialTab, newTab ->
onMove(
(initialTab.key as String),
(newTab.key as String),
initialTab.index < newTab.index,
)
},
onLongPress = { itemInfo ->
tabs.firstOrNull { tab -> tab.id == itemInfo.key }?.let { tab ->
onTabLongClick(tab)
}
},
onExitLongPress = onTabDragStart,
ignoredItems = listOf(HEADER_ITEM_KEY, SPAN_ITEM_KEY),
)
var shouldLongPress by remember { mutableStateOf(!isInMultiSelectMode) }
LaunchedEffect(selectionMode, reorderState.draggingItemKey) {
if (reorderState.draggingItemKey == null) {
shouldLongPress = selectionMode == TabsTrayState.Mode.Normal
}
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = MIN_COLUMN_WIDTH_DP.dp),
modifier = modifier.fillMaxSize(),
modifier = modifier
.fillMaxSize()
.detectGridPressAndDragGestures(
gridState = state,
reorderState = reorderState,
shouldLongPressToDrag = shouldLongPress,
),
state = state,
) {
header?.let {
item(span = { GridItemSpan(maxLineSpan) }) {
item(key = HEADER_ITEM_KEY, span = { GridItemSpan(maxLineSpan) }) {
header()
}
}
@ -144,26 +201,29 @@ private fun TabGrid(
items = tabs,
key = { tab -> tab.id },
) { tab ->
TabGridItem(
tab = tab,
thumbnailSize = tabThumbnailSize,
storage = storage,
isSelected = tab.id == selectedTabId,
multiSelectionEnabled = isInMultiSelectMode,
multiSelectionSelected = selectionMode.selectedTabs.contains(tab),
onCloseClick = onTabClose,
onMediaClick = onTabMediaClick,
onClick = onTabClick,
onLongClick = onTabLongClick,
)
DragItemContainer(state = reorderState, key = tab.id) {
TabGridItem(
tab = tab,
thumbnailSize = tabThumbnailSize,
storage = storage,
isSelected = tab.id == selectedTabId,
multiSelectionEnabled = isInMultiSelectMode,
multiSelectionSelected = selectionMode.selectedTabs.contains(tab),
shouldClickListen = reorderState.draggingItemKey != tab.id,
onCloseClick = onTabClose,
onMediaClick = onTabMediaClick,
onClick = onTabClick,
)
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
item(key = SPAN_ITEM_KEY, span = { GridItemSpan(maxLineSpan) }) {
Spacer(modifier = Modifier.height(tabListBottomPadding))
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongParameterList")
@Composable
private fun TabList(
@ -177,7 +237,9 @@ private fun TabList(
onTabMediaClick: (TabSessionState) -> Unit,
onTabClick: (TabSessionState) -> Unit,
onTabLongClick: (TabSessionState) -> Unit,
onMove: (String, String?, Boolean) -> Unit,
header: (@Composable () -> Unit)? = null,
onTabDragStart: () -> Unit = {},
) {
val state = rememberLazyListState(initialFirstVisibleItemIndex = selectedTabIndex)
val tabListBottomPadding = dimensionResource(id = R.dimen.tab_tray_list_bottom_padding)
@ -186,13 +248,42 @@ private fun TabList(
LocalContext.current.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width),
)
val isInMultiSelectMode = selectionMode is TabsTrayState.Mode.Select
val reorderState = createListReorderState(
listState = state,
onMove = { initialTab, newTab ->
onMove(
(initialTab.key as String),
(newTab.key as String),
initialTab.index < newTab.index,
)
},
onLongPress = {
tabs.firstOrNull { tab -> tab.id == it.key }?.let { tab ->
onTabLongClick(tab)
}
},
onExitLongPress = onTabDragStart,
ignoredItems = listOf(HEADER_ITEM_KEY, SPAN_ITEM_KEY),
)
var shouldLongPress by remember { mutableStateOf(!isInMultiSelectMode) }
LaunchedEffect(selectionMode, reorderState.draggingItemKey) {
if (reorderState.draggingItemKey == null) {
shouldLongPress = selectionMode == TabsTrayState.Mode.Normal
}
}
LazyColumn(
modifier = modifier.fillMaxSize(),
modifier = modifier
.fillMaxSize()
.detectVerticalPressAndDrag(
listState = state,
reorderState = reorderState,
shouldLongPressToDrag = shouldLongPress,
),
state = state,
) {
header?.let {
item {
item(key = HEADER_ITEM_KEY) {
header()
}
}
@ -201,21 +292,23 @@ private fun TabList(
items = tabs,
key = { tab -> tab.id },
) { tab ->
TabListItem(
tab = tab,
thumbnailSize = tabThumbnailSize,
storage = storage,
isSelected = tab.id == selectedTabId,
multiSelectionEnabled = isInMultiSelectMode,
multiSelectionSelected = selectionMode.selectedTabs.contains(tab),
onCloseClick = onTabClose,
onMediaClick = onTabMediaClick,
onClick = onTabClick,
onLongClick = onTabLongClick,
)
DragItemContainer(state = reorderState, key = tab.id) {
TabListItem(
tab = tab,
thumbnailSize = tabThumbnailSize,
storage = storage,
isSelected = tab.id == selectedTabId,
multiSelectionEnabled = isInMultiSelectMode,
multiSelectionSelected = selectionMode.selectedTabs.contains(tab),
shouldClickListen = reorderState.draggingItemKey != tab.id,
onCloseClick = onTabClose,
onMediaClick = onTabMediaClick,
onClick = onTabClick,
)
}
}
item {
item(key = SPAN_ITEM_KEY) {
Spacer(modifier = Modifier.height(tabListBottomPadding))
}
}
@ -242,6 +335,8 @@ private fun TabListPreview() {
onTabMediaClick = {},
onTabClick = {},
onTabLongClick = {},
onTabDragStart = {},
onMove = { _, _, _ -> },
)
}
}
@ -268,6 +363,8 @@ private fun TabGridPreview() {
onTabMediaClick = {},
onTabClick = {},
onTabLongClick = {},
onTabDragStart = {},
onMove = { _, _, _ -> },
)
}
}
@ -302,12 +399,17 @@ private fun TabGridMultiSelectPreview() {
}
},
onTabLongClick = {},
onTabDragStart = {},
onMove = { _, _, _ -> },
)
}
}
}
private fun generateFakeTabsList(tabCount: Int = 10, isPrivate: Boolean = false): List<TabSessionState> =
private fun generateFakeTabsList(
tabCount: Int = 10,
isPrivate: Boolean = false,
): List<TabSessionState> =
List(tabCount) { index ->
TabSessionState(
id = "tabId$index-$isPrivate",

@ -69,10 +69,6 @@ class ComposeGridViewHolder(
interactor.onTabSelected(tab, featureName)
}
private fun onLongClick(tab: TabSessionState) {
interactor.onTabLongClicked(tab)
}
@Composable
override fun Content(tab: TabSessionState) {
val multiSelectionEnabled = store.observeAsComposableState { state ->
@ -91,7 +87,6 @@ class ComposeGridViewHolder(
onCloseClick = ::onCloseClicked,
onMediaClick = interactor::onMediaClicked,
onClick = ::onClick,
onLongClick = ::onLongClick,
)
}

@ -72,10 +72,6 @@ class ComposeListViewHolder(
interactor.onTabSelected(tab, featureName)
}
private fun onLongClick(tab: TabSessionState) {
interactor.onTabLongClicked(tab)
}
@Composable
override fun Content(tab: TabSessionState) {
val multiSelectionEnabled = tabsTrayStore.observeAsComposableState {
@ -95,7 +91,6 @@ class ComposeListViewHolder(
onCloseClick = ::onCloseClicked,
onMediaClick = interactor::onMediaClicked,
onClick = ::onClick,
onLongClick = ::onLongClick,
)
}

@ -0,0 +1,294 @@
/* 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.compose
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Remember the reordering state for reordering grid items.
*
* @param gridState State of the grid.
* @param onMove Callback to be invoked when switching between two items.
* @param ignoredItems List of keys for non-draggable items.
* @param onLongPress Optional callback to be invoked when long pressing an item.
* @param onExitLongPress Optional callback to be invoked when the item is dragged after long press.
*/
@Composable
fun createGridReorderState(
gridState: LazyGridState,
onMove: (LazyGridItemInfo, LazyGridItemInfo) -> Unit,
ignoredItems: List<Any>,
onLongPress: (LazyGridItemInfo) -> Unit = {},
onExitLongPress: () -> Unit = {},
): GridReorderState {
val scope = rememberCoroutineScope()
val touchSlop = LocalViewConfiguration.current.touchSlop
val hapticFeedback = LocalHapticFeedback.current
val state = remember(gridState) {
GridReorderState(
gridState = gridState,
onMove = onMove,
scope = scope,
touchSlop = touchSlop,
ignoredItems = ignoredItems,
onLongPress = onLongPress,
hapticFeedback = hapticFeedback,
onExitLongPress = onExitLongPress,
)
}
return state
}
/**
* Class containing details about the current state of dragging in grid.
*
* @param gridState State of the grid.
* @param scope [CoroutineScope] used for scrolling to the target item.
* @param hapticFeedback [HapticFeedback] used for performing haptic feedback on item long press.
* @param touchSlop Distance in pixels the user can wander until we consider they started dragging.
* @param onMove Callback to be invoked when switching between two items.
* @param onLongPress Optional callback to be invoked when long pressing an item.
* @param onExitLongPress Optional callback to be invoked when the item is dragged after long press.
* @param ignoredItems List of keys for non-draggable items.
*/
class GridReorderState internal constructor(
private val gridState: LazyGridState,
private val scope: CoroutineScope,
private val hapticFeedback: HapticFeedback,
private val touchSlop: Float,
private val onMove: (LazyGridItemInfo, LazyGridItemInfo) -> Unit,
private val onLongPress: (LazyGridItemInfo) -> Unit = {},
private val onExitLongPress: () -> Unit = {},
private val ignoredItems: List<Any> = emptyList(),
) {
internal var draggingItemKey by mutableStateOf<Any?>(null)
private set
private var draggingItemCumulatedOffset by mutableStateOf(Offset.Zero)
private var draggingItemInitialOffset by mutableStateOf(Offset.Zero)
internal var moved by mutableStateOf(false)
internal val draggingItemOffset: Offset
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemCumulatedOffset - item.offset.toOffset()
} ?: Offset.Zero
private val draggingItemLayoutInfo: LazyGridItemInfo?
get() = gridState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == draggingItemKey }
internal var previousKeyOfDraggedItem by mutableStateOf<Any?>(null)
private set
internal var previousItemOffset = Animatable(Offset.Zero, Offset.VectorConverter)
private set
internal fun onTouchSlopPassed(offset: Offset, shouldLongPress: Boolean) {
gridState.findItem(offset)?.also {
draggingItemKey = it.key
if (shouldLongPress) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onLongPress(it)
}
draggingItemInitialOffset = it.offset.toOffset()
moved = !shouldLongPress
}
}
internal fun onDragInterrupted() {
if (draggingItemKey != null) {
previousKeyOfDraggedItem = draggingItemKey
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
Offset.Zero,
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Offset.VisibilityThreshold,
),
)
previousKeyOfDraggedItem = null
}
}
draggingItemCumulatedOffset = Offset.Zero
draggingItemKey = null
draggingItemInitialOffset = Offset.Zero
}
internal fun onDrag(offset: Offset) {
draggingItemCumulatedOffset += offset
if (draggingItemLayoutInfo == null) {
moved = false
}
val draggingItem = draggingItemLayoutInfo ?: return
if (!moved && draggingItemCumulatedOffset.getDistance() > touchSlop) {
onExitLongPress()
}
val startOffset = draggingItem.offset.toOffset() + draggingItemOffset
val endOffset = Offset(
startOffset.x + draggingItem.size.toSize().width,
startOffset.y + draggingItem.size.toSize().height,
)
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = gridState.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.x.toInt() in item.offset.x..item.endOffset.x &&
middleOffset.y.toInt() in item.offset.y..item.endOffset.y &&
draggingItemKey != item.key
}
if (targetItem != null && targetItem.key !in ignoredItems) {
if (draggingItem.index == gridState.firstVisibleItemIndex) {
scope.launch {
gridState.scrollBy(-draggingItem.size.height.toFloat())
}
}
onMove.invoke(draggingItem, targetItem)
} else {
val overscroll = when {
draggingItemCumulatedOffset.y > 0 ->
(endOffset.y - gridState.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemCumulatedOffset.y < 0 ->
(startOffset.y - gridState.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scope.launch {
gridState.scrollBy(overscroll)
}
}
}
}
}
/**
* Container for draggable grid item.
*
* @param state State of the lazy grid.
* @param key Key of the item to be displayed.
* @param content Content of the item to be displayed.
*/
@ExperimentalFoundationApi
@Composable
fun LazyGridItemScope.DragItemContainer(
state: GridReorderState,
key: Any,
content: @Composable () -> Unit,
) {
val modifier = when (key) {
state.draggingItemKey -> {
Modifier
.zIndex(1f)
.graphicsLayer {
translationX = state.draggingItemOffset.x
translationY = state.draggingItemOffset.y
}
}
state.previousKeyOfDraggedItem -> {
Modifier
.zIndex(1f)
.graphicsLayer {
translationX = state.previousItemOffset.value.x
translationY = state.previousItemOffset.value.y
}
}
else -> {
Modifier
.zIndex(0f)
.animateItemPlacement(tween())
}
}
Box(modifier = modifier, propagateMinConstraints = true) {
content()
}
}
/**
* Calculate the offset of an item taking its width and height into account.
*/
private val LazyGridItemInfo.endOffset: IntOffset
get() = IntOffset(offset.x + size.width, offset.y + size.height)
/**
* Find item based on position on screen.
*
* @param offset Position on screen used to find the item.
*/
private fun LazyGridState.findItem(offset: Offset) =
layoutInfo.visibleItemsInfo.firstOrNull { item ->
offset.x.toInt() in item.offset.x..item.endOffset.x && offset.y.toInt() in item.offset.y..item.endOffset.y
}
/**
* Detects press, long press and drag gestures.
*
* @param gridState State of the grid.
* @param reorderState Grid reordering state used for dragging callbacks.
* @param shouldLongPressToDrag Whether or not an item should be long pressed to start the dragging gesture.
*/
fun Modifier.detectGridPressAndDragGestures(
gridState: LazyGridState,
reorderState: GridReorderState,
shouldLongPressToDrag: Boolean,
): Modifier = pointerInput(gridState, shouldLongPressToDrag) {
if (shouldLongPressToDrag) {
detectDragGesturesAfterLongPress(
onDragStart = { offset -> reorderState.onTouchSlopPassed(offset, true) },
onDrag = { change, dragAmount ->
change.consume()
reorderState.onDrag(dragAmount)
},
onDragEnd = reorderState::onDragInterrupted,
onDragCancel = reorderState::onDragInterrupted,
)
} else {
detectDragGestures(
onDragStart = { offset -> reorderState.onTouchSlopPassed(offset, false) },
onDrag = { change, dragAmount ->
change.consume()
reorderState.onDrag(dragAmount)
},
onDragEnd = reorderState::onDragInterrupted,
onDragCancel = reorderState::onDragInterrupted,
)
}
}

@ -0,0 +1,271 @@
/* 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.compose
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Remember the reordering state for reordering list items.
*
* @param listState State of the list.
* @param onMove Callback to be invoked when switching between two items.
* @param ignoredItems List of keys for non-draggable items.
* @param onLongPress Callback to be invoked when long pressing an item.
* @param onExitLongPress Callback to be invoked when the item is dragged after long press.
*/
@Composable
fun createListReorderState(
listState: LazyListState,
onMove: (LazyListItemInfo, LazyListItemInfo) -> Unit,
ignoredItems: List<Any>,
onLongPress: (LazyListItemInfo) -> Unit = {},
onExitLongPress: () -> Unit = {},
): ListReorderState {
val scope = rememberCoroutineScope()
val touchSlop = LocalViewConfiguration.current.touchSlop
val hapticFeedback = LocalHapticFeedback.current
val state = remember(listState) {
ListReorderState(
listState = listState,
onMove = onMove,
scope = scope,
touchSlop = touchSlop,
hapticFeedback = hapticFeedback,
ignoredItems = ignoredItems,
onLongPress = onLongPress,
onExitLongPress = onExitLongPress,
)
}
return state
}
/**
* Class containing details about the current state of dragging in list.
*
* @param listState State of the list.
* @param scope [CoroutineScope] used for scrolling to the target item.
* @param hapticFeedback [HapticFeedback] used for performing haptic feedback on item long press.
* @param touchSlop Distance in pixels the user can wander until we consider they started dragging.
* @param onMove Callback to be invoked when switching between two items.
* @param onLongPress Optional callback to be invoked when long pressing an item.
* @param onExitLongPress Optional callback to be invoked when the item is dragged after long press.
* @param ignoredItems List of keys for non-draggable items.
*/
@Suppress("LongParameterList")
class ListReorderState internal constructor(
private val listState: LazyListState,
private val scope: CoroutineScope,
private val hapticFeedback: HapticFeedback,
private val touchSlop: Float,
private val onMove: (LazyListItemInfo, LazyListItemInfo) -> Unit,
private val ignoredItems: List<Any>,
private val onLongPress: (LazyListItemInfo) -> Unit,
private val onExitLongPress: () -> Unit,
) {
var draggingItemKey by mutableStateOf<Any?>(null)
private set
private var draggingItemCumulatedOffset by mutableStateOf(0f)
private var draggingItemInitialOffset by mutableStateOf(0f)
internal var moved by mutableStateOf(false)
internal val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemCumulatedOffset - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == draggingItemKey }
internal var previousKeyOfDraggedItem by mutableStateOf<Any?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
internal fun onTouchSlopPassed(offset: Float, shouldLongPress: Boolean) {
listState.findItem(offset)?.also {
draggingItemKey = it.key
if (shouldLongPress) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onLongPress(it)
}
draggingItemInitialOffset = it.offset.toFloat()
moved = !shouldLongPress
}
}
internal fun onDragInterrupted() {
if (draggingItemKey != null) {
previousKeyOfDraggedItem = draggingItemKey
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f,
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = 1f,
),
)
previousKeyOfDraggedItem = null
}
}
draggingItemCumulatedOffset = 0f
draggingItemKey = null
draggingItemInitialOffset = 0f
}
internal fun onDrag(offset: Float) {
draggingItemCumulatedOffset += offset
if (draggingItemLayoutInfo == null) {
moved = false
}
val draggingItem = draggingItemLayoutInfo ?: return
if (!moved && draggingItemCumulatedOffset > touchSlop) {
onExitLongPress()
}
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = listState.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.endOffset && draggingItemKey != item.key
}
if (targetItem != null && targetItem.key !in ignoredItems) {
if (draggingItem.index == listState.firstVisibleItemIndex) {
scope.launch {
listState.scrollBy(-draggingItem.size.toFloat())
}
}
onMove.invoke(draggingItem, targetItem)
} else {
val overscroll = when {
draggingItemCumulatedOffset > 0 ->
(endOffset - listState.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemCumulatedOffset < 0 ->
(startOffset - listState.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scope.launch {
listState.scrollBy(overscroll)
}
}
}
}
}
/**
* Container for draggable list item.
*
* @param state List reordering state.
* @param key Key of the item to be displayed.
* @param content Content of the item to be displayed.
*/
@ExperimentalFoundationApi
@Composable
fun LazyItemScope.DragItemContainer(
state: ListReorderState,
key: Any,
content: @Composable () -> Unit,
) {
val modifier = when (key) {
state.draggingItemKey -> {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = state.draggingItemOffset
}
}
state.previousKeyOfDraggedItem -> {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = state.previousItemOffset.value
}
}
else -> {
Modifier
.zIndex(0f)
.animateItemPlacement(tween())
}
}
Box(modifier = modifier, propagateMinConstraints = true) {
content()
}
}
/**
* Calculates the offset of an item taking its height into account.
*/
private val LazyListItemInfo.endOffset: Int
get() = offset + size
/**
* Find item based on position on screen.
*
* @param offset Position on screen used to find the item.
*/
private fun LazyListState.findItem(offset: Float) =
layoutInfo.visibleItemsInfo.firstOrNull { item ->
offset.toInt() in item.offset..item.endOffset
}
/**
* Detects press, long press and drag gestures.
*
* @param listState State of the list.
* @param reorderState List reordering state used for dragging callbacks.
* @param shouldLongPressToDrag Whether or not an item should be long pressed to start the dragging gesture.
*/
fun Modifier.detectVerticalPressAndDrag(
listState: LazyListState,
reorderState: ListReorderState,
shouldLongPressToDrag: Boolean,
): Modifier = pointerInput(listState, shouldLongPressToDrag) {
if (shouldLongPressToDrag) {
detectDragGesturesAfterLongPress(
onDragStart = { offset -> reorderState.onTouchSlopPassed(offset.y, true) },
onDrag = { change, dragAmount ->
change.consume()
reorderState.onDrag(dragAmount.y)
},
onDragEnd = reorderState::onDragInterrupted,
onDragCancel = reorderState::onDragInterrupted,
)
}
}
Loading…
Cancel
Save