You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
464 lines
16 KiB
Kotlin
464 lines
16 KiB
Kotlin
/* 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
|
|
|
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxHeight
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.grid.GridCells
|
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
|
import androidx.compose.foundation.lazy.itemsIndexed
|
|
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
|
|
import androidx.compose.ui.res.dimensionResource
|
|
import androidx.compose.ui.unit.dp
|
|
import mozilla.components.browser.state.state.ContentState
|
|
import mozilla.components.browser.state.state.TabSessionState
|
|
import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
|
|
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.tabstray.ext.numberOfGridColumns
|
|
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.
|
|
*
|
|
* @param tabs The list of [TabSessionState] to display.
|
|
* @param storage [ThumbnailStorage] to obtain tab thumbnail bitmaps from.
|
|
* @param displayTabsInGrid Whether the tabs should be displayed in a grid.
|
|
* @param selectedTabId The ID of the currently selected tab.
|
|
* @param selectionMode [TabsTrayState.Mode] indicating whether the Tabs Tray is in single selection
|
|
* or multi-selection and contains the set of selected tabs.
|
|
* @param modifier [Modifier] to be applied to the layout.
|
|
* @param onTabClose Invoked when the user clicks to close a tab.
|
|
* @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")
|
|
@Composable
|
|
fun TabLayout(
|
|
tabs: List<TabSessionState>,
|
|
storage: ThumbnailStorage,
|
|
displayTabsInGrid: Boolean,
|
|
selectedTabId: String?,
|
|
selectionMode: TabsTrayState.Mode,
|
|
modifier: Modifier = Modifier,
|
|
onTabClose: (TabSessionState) -> Unit,
|
|
onTabMediaClick: (TabSessionState) -> Unit,
|
|
onTabClick: (TabSessionState) -> Unit,
|
|
onTabLongClick: (TabSessionState) -> Unit,
|
|
onMove: (String, String?, Boolean) -> Unit,
|
|
onTabDragStart: () -> Unit,
|
|
header: (@Composable () -> Unit)? = null,
|
|
) {
|
|
var selectedTabIndex = 0
|
|
selectedTabId?.let {
|
|
tabs.forEachIndexed { index, tab ->
|
|
if (tab.id == selectedTabId) {
|
|
selectedTabIndex = index
|
|
return@forEachIndexed
|
|
}
|
|
}
|
|
}
|
|
|
|
if (displayTabsInGrid) {
|
|
TabGrid(
|
|
tabs = tabs,
|
|
storage = storage,
|
|
selectedTabId = selectedTabId,
|
|
selectedTabIndex = selectedTabIndex,
|
|
selectionMode = selectionMode,
|
|
modifier = modifier,
|
|
onTabClose = onTabClose,
|
|
onTabMediaClick = onTabMediaClick,
|
|
onTabClick = onTabClick,
|
|
onTabLongClick = onTabLongClick,
|
|
onMove = onMove,
|
|
onTabDragStart = onTabDragStart,
|
|
header = header,
|
|
)
|
|
} else {
|
|
TabList(
|
|
tabs = tabs,
|
|
storage = storage,
|
|
selectedTabId = selectedTabId,
|
|
selectedTabIndex = selectedTabIndex,
|
|
selectionMode = selectionMode,
|
|
modifier = modifier,
|
|
onTabClose = onTabClose,
|
|
onTabMediaClick = onTabMediaClick,
|
|
onTabClick = onTabClick,
|
|
onTabLongClick = onTabLongClick,
|
|
onMove = onMove,
|
|
onTabDragStart = onTabDragStart,
|
|
header = header,
|
|
)
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Suppress("LongParameterList", "LongMethod")
|
|
@Composable
|
|
private fun TabGrid(
|
|
tabs: List<TabSessionState>,
|
|
storage: ThumbnailStorage,
|
|
selectedTabId: String?,
|
|
selectedTabIndex: Int,
|
|
selectionMode: TabsTrayState.Mode,
|
|
modifier: Modifier = Modifier,
|
|
onTabClose: (TabSessionState) -> Unit,
|
|
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)
|
|
val tabListBottomPadding = dimensionResource(id = R.dimen.tab_tray_list_bottom_padding)
|
|
val tabThumbnailSize = max(
|
|
LocalContext.current.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_height),
|
|
LocalContext.current.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_width),
|
|
)
|
|
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.Fixed(count = LocalContext.current.numberOfGridColumns),
|
|
modifier = modifier
|
|
.fillMaxSize()
|
|
.detectGridPressAndDragGestures(
|
|
gridState = state,
|
|
reorderState = reorderState,
|
|
shouldLongPressToDrag = shouldLongPress,
|
|
),
|
|
state = state,
|
|
) {
|
|
header?.let {
|
|
item(key = HEADER_ITEM_KEY, span = { GridItemSpan(maxLineSpan) }) {
|
|
header()
|
|
}
|
|
}
|
|
|
|
itemsIndexed(
|
|
items = tabs,
|
|
key = { _, tab -> tab.id },
|
|
) { index, tab ->
|
|
DragItemContainer(
|
|
state = reorderState,
|
|
position = index + if (header != null) 1 else 0,
|
|
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(key = SPAN_ITEM_KEY, span = { GridItemSpan(maxLineSpan) }) {
|
|
Spacer(modifier = Modifier.height(tabListBottomPadding))
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Suppress("LongParameterList")
|
|
@Composable
|
|
private fun TabList(
|
|
tabs: List<TabSessionState>,
|
|
storage: ThumbnailStorage,
|
|
selectedTabId: String?,
|
|
selectedTabIndex: Int,
|
|
selectionMode: TabsTrayState.Mode,
|
|
modifier: Modifier = Modifier,
|
|
onTabClose: (TabSessionState) -> Unit,
|
|
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)
|
|
val tabThumbnailSize = max(
|
|
LocalContext.current.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height),
|
|
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()
|
|
.detectVerticalPressAndDrag(
|
|
listState = state,
|
|
reorderState = reorderState,
|
|
shouldLongPressToDrag = shouldLongPress,
|
|
),
|
|
state = state,
|
|
) {
|
|
header?.let {
|
|
item(key = HEADER_ITEM_KEY) {
|
|
header()
|
|
}
|
|
}
|
|
|
|
itemsIndexed(
|
|
items = tabs,
|
|
key = { _, tab -> tab.id },
|
|
) { index, tab ->
|
|
DragItemContainer(
|
|
state = reorderState,
|
|
position = index + if (header != null) 1 else 0,
|
|
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(key = SPAN_ITEM_KEY) {
|
|
Spacer(modifier = Modifier.height(tabListBottomPadding))
|
|
}
|
|
}
|
|
}
|
|
|
|
@LightDarkPreview
|
|
@Composable
|
|
private fun TabListPreview() {
|
|
val tabs = remember { generateFakeTabsList().toMutableStateList() }
|
|
|
|
FirefoxTheme {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(FirefoxTheme.colors.layer1),
|
|
) {
|
|
TabLayout(
|
|
tabs = tabs,
|
|
storage = ThumbnailStorage(LocalContext.current),
|
|
selectedTabId = tabs[1].id,
|
|
selectionMode = TabsTrayState.Mode.Normal,
|
|
displayTabsInGrid = false,
|
|
onTabClose = tabs::remove,
|
|
onTabMediaClick = {},
|
|
onTabClick = {},
|
|
onTabLongClick = {},
|
|
onTabDragStart = {},
|
|
onMove = { _, _, _ -> },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@LightDarkPreview
|
|
@Composable
|
|
private fun TabGridPreview() {
|
|
val tabs = remember { generateFakeTabsList().toMutableStateList() }
|
|
|
|
FirefoxTheme {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(FirefoxTheme.colors.layer1),
|
|
) {
|
|
TabLayout(
|
|
tabs = tabs,
|
|
storage = ThumbnailStorage(LocalContext.current),
|
|
selectedTabId = tabs[0].id,
|
|
selectionMode = TabsTrayState.Mode.Normal,
|
|
displayTabsInGrid = false,
|
|
onTabClose = tabs::remove,
|
|
onTabMediaClick = {},
|
|
onTabClick = {},
|
|
onTabLongClick = {},
|
|
onTabDragStart = {},
|
|
onMove = { _, _, _ -> },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@LightDarkPreview
|
|
@Composable
|
|
private fun TabGridSmallPreview() {
|
|
val tabs = remember { generateFakeTabsList().toMutableStateList() }
|
|
val width = MIN_COLUMN_WIDTH_DP.dp + 50.dp
|
|
|
|
FirefoxTheme {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxHeight()
|
|
.width(width)
|
|
.background(FirefoxTheme.colors.layer1),
|
|
) {
|
|
TabLayout(
|
|
tabs = tabs,
|
|
storage = ThumbnailStorage(LocalContext.current),
|
|
selectedTabId = tabs[0].id,
|
|
selectionMode = TabsTrayState.Mode.Normal,
|
|
displayTabsInGrid = true,
|
|
onTabClose = tabs::remove,
|
|
onTabMediaClick = {},
|
|
onTabClick = {},
|
|
onTabLongClick = {},
|
|
onTabDragStart = {},
|
|
onMove = { _, _, _ -> },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suppress("MagicNumber")
|
|
@LightDarkPreview
|
|
@Composable
|
|
private fun TabGridMultiSelectPreview() {
|
|
val tabs = generateFakeTabsList()
|
|
val selectedTabs = remember { tabs.take(4).toMutableStateList() }
|
|
|
|
FirefoxTheme {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(FirefoxTheme.colors.layer1),
|
|
) {
|
|
TabLayout(
|
|
tabs = tabs,
|
|
storage = ThumbnailStorage(LocalContext.current),
|
|
selectedTabId = tabs[0].id,
|
|
selectionMode = TabsTrayState.Mode.Select(selectedTabs.toSet()),
|
|
displayTabsInGrid = false,
|
|
onTabClose = {},
|
|
onTabMediaClick = {},
|
|
onTabClick = { tab ->
|
|
if (selectedTabs.contains(tab)) {
|
|
selectedTabs.remove(tab)
|
|
} else {
|
|
selectedTabs.add(tab)
|
|
}
|
|
},
|
|
onTabLongClick = {},
|
|
onTabDragStart = {},
|
|
onMove = { _, _, _ -> },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun generateFakeTabsList(
|
|
tabCount: Int = 10,
|
|
isPrivate: Boolean = false,
|
|
): List<TabSessionState> =
|
|
List(tabCount) { index ->
|
|
TabSessionState(
|
|
id = "tabId$index-$isPrivate",
|
|
content = ContentState(
|
|
url = "www.mozilla.com",
|
|
private = isPrivate,
|
|
),
|
|
)
|
|
}
|