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.
iceraven-browser/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStrip.kt

453 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.browser.tabstrip
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.coerceIn
import androidx.compose.ui.unit.dp
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.lib.state.ext.observeAsState
import org.mozilla.fenix.R
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.components
import org.mozilla.fenix.compose.Favicon
import org.mozilla.fenix.tabstray.browser.compose.DragItemContainer
import org.mozilla.fenix.tabstray.browser.compose.createListReorderState
import org.mozilla.fenix.tabstray.browser.compose.detectListPressAndDrag
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.theme.Theme
private val minTabStripItemWidth = 160.dp
private val maxTabStripItemWidth = 280.dp
private val tabStripIconSize = 24.dp
private val spaceBetweenTabs = 4.dp
private val tabStripStartPadding = 8.dp
private val addTabIconSize = 20.dp
/**
* Top level composable for the tabs strip.
*
* @param onHome Whether or not the tabs strip is in the home screen.
* @param browserStore The [BrowserStore] instance used to observe tabs state.
* @param appStore The [AppStore] instance used to observe browsing mode.
* @param tabsUseCases The [TabsUseCases] instance to perform tab actions.
* @param onAddTabClick Invoked when the add tab button is clicked.
* @param onCloseTabClick Invoked when a tab is closed.
* @param onLastTabClose Invoked when the last remaining open tab is closed.
* @param onSelectedTabClick Invoked when a tab is selected.
*/
@Composable
fun TabStrip(
onHome: Boolean = false,
browserStore: BrowserStore = components.core.store,
appStore: AppStore = components.appStore,
tabsUseCases: TabsUseCases = components.useCases.tabsUseCases,
onAddTabClick: () -> Unit,
onCloseTabClick: (isPrivate: Boolean) -> Unit,
onLastTabClose: (isPrivate: Boolean) -> Unit,
onSelectedTabClick: () -> Unit,
) {
val isPrivateMode by appStore.observeAsState(false) { it.mode.isPrivate }
val state by browserStore.observeAsState(TabStripState.initial) {
it.toTabStripState(isSelectDisabled = onHome, isPrivateMode = isPrivateMode)
}
TabStripContent(
state = state,
onAddTabClick = onAddTabClick,
onCloseTabClick = { id, isPrivate ->
if (state.tabs.size == 1) {
onLastTabClose(isPrivate)
}
tabsUseCases.removeTab(id)
onCloseTabClick(isPrivate)
},
onSelectedTabClick = {
tabsUseCases.selectTab(it)
onSelectedTabClick()
},
onMove = { tabId, targetId, placeAfter ->
if (tabId != targetId) {
tabsUseCases.moveTabs(listOf(tabId), targetId, placeAfter)
}
},
)
}
@Composable
private fun TabStripContent(
state: TabStripState,
onAddTabClick: () -> Unit,
onCloseTabClick: (id: String, isPrivate: Boolean) -> Unit,
onSelectedTabClick: (id: String) -> Unit,
onMove: (tabId: String, targetId: String, placeAfter: Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxSize()
.background(FirefoxTheme.colors.layer1)
.systemGestureExclusion(),
verticalAlignment = Alignment.CenterVertically,
) {
TabsList(
state = state,
modifier = Modifier.weight(1f, fill = false),
onCloseTabClick = onCloseTabClick,
onSelectedTabClick = onSelectedTabClick,
onMove = onMove,
)
IconButton(onClick = onAddTabClick) {
Icon(
painter = painterResource(R.drawable.mozac_ic_plus_24),
modifier = Modifier.size(addTabIconSize),
tint = FirefoxTheme.colors.iconPrimary,
contentDescription = stringResource(R.string.add_tab),
)
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun TabsList(
state: TabStripState,
modifier: Modifier = Modifier,
onCloseTabClick: (id: String, isPrivate: Boolean) -> Unit,
onSelectedTabClick: (id: String) -> Unit,
onMove: (tabId: String, targetId: String, placeAfter: Boolean) -> Unit,
) {
BoxWithConstraints(modifier = modifier) {
val listState = rememberLazyListState()
// Calculate the width of each tab item based on available width and the number of tabs and
// taking into account the space between tabs.
val availableWidth = maxWidth - tabStripStartPadding
val tabWidth = (availableWidth / state.tabs.size) - spaceBetweenTabs
val reorderState = createListReorderState(
listState = listState,
onMove = { movedTab, adjacentTab ->
onMove(
(movedTab.key as String),
(adjacentTab.key as String),
movedTab.index < adjacentTab.index,
)
},
ignoredItems = emptyList(),
)
LazyRow(
modifier = Modifier.detectListPressAndDrag(
reorderState = reorderState,
listState = listState,
shouldLongPressToDrag = true,
),
state = listState,
contentPadding = PaddingValues(start = tabStripStartPadding),
) {
itemsIndexed(
items = state.tabs,
key = { _, item -> item.id },
) { index, itemState ->
DragItemContainer(
state = reorderState,
key = itemState.id,
position = index,
) {
TabItem(
state = itemState,
onCloseTabClick = onCloseTabClick,
onSelectedTabClick = onSelectedTabClick,
modifier = Modifier
.padding(end = spaceBetweenTabs)
.animateItemPlacement()
.width(
tabWidth.coerceIn(
minimumValue = minTabStripItemWidth,
maximumValue = maxTabStripItemWidth,
),
),
)
}
}
}
if (state.tabs.isNotEmpty()) {
// When a new tab is added, scroll to the end of the list. This is done here instead of
// in onCloseTabClick so this acts on state change which can occur from any other
// place e.g. tabs tray.
LaunchedEffect(state.tabs.last().id) {
listState.scrollToItem(state.tabs.size)
}
// When a tab is selected, scroll to the selected tab. This is done here instead of
// in onSelectedTabClick so this acts on state change which can occur from any other
// place e.g. tabs tray.
val selectedTab = state.tabs.firstOrNull { it.isSelected }
LaunchedEffect(selectedTab?.id) {
if (selectedTab != null) {
val selectedItemInfo =
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == selectedTab.id }
if (listState.isItemPartiallyVisible(selectedItemInfo) || selectedItemInfo == null) {
listState.animateScrollToItem(state.tabs.indexOf(selectedTab))
}
}
}
}
}
}
private fun LazyListState.isItemPartiallyVisible(itemInfo: LazyListItemInfo?) =
itemInfo != null &&
(itemInfo.offset + itemInfo.size > layoutInfo.viewportEndOffset || itemInfo.offset < 0)
@Composable
private fun TabItem(
state: TabStripItem,
modifier: Modifier = Modifier,
onCloseTabClick: (id: String, isPrivate: Boolean) -> Unit,
onSelectedTabClick: (id: String) -> Unit,
) {
TabStripCard(
modifier = modifier.fillMaxSize(),
backgroundColor =
if (state.isPrivate) {
if (state.isSelected) {
FirefoxTheme.colors.layer3
} else {
FirefoxTheme.colors.layer2
}
} else {
if (state.isSelected) {
FirefoxTheme.colors.layer2
} else {
FirefoxTheme.colors.layer3
}
},
elevation = if (state.isSelected) {
selectedTabStripCardElevation
} else {
defaultTabStripCardElevation
},
) {
Row(
modifier = Modifier
.fillMaxSize()
.clickable { onSelectedTabClick(state.id) },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
modifier = Modifier.weight(1f, fill = false),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.size(8.dp))
TabStripIcon(state.url)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = state.title,
color = FirefoxTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = FirefoxTheme.typography.subtitle2,
)
}
IconButton(onClick = { onCloseTabClick(state.id, state.isPrivate) }) {
Icon(
painter = painterResource(R.drawable.mozac_ic_cross_20),
tint = FirefoxTheme.colors.iconPrimary,
contentDescription = stringResource(R.string.close_tab),
)
}
}
}
}
@Composable
private fun TabStripIcon(url: String) {
Box(
modifier = Modifier
.size(tabStripIconSize)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
Favicon(
url = url,
size = tabStripIconSize,
)
}
}
private class TabUIStateParameterProvider : PreviewParameterProvider<TabStripState> {
override val values: Sequence<TabStripState>
get() = sequenceOf(
TabStripState(
listOf(
TabStripItem(
id = "1",
title = "Tab 1",
url = "https://www.mozilla.org",
isPrivate = false,
isSelected = false,
),
TabStripItem(
id = "2",
title = "Tab 2 with a very long title that should be truncated",
url = "https://www.mozilla.org",
isPrivate = false,
isSelected = false,
),
TabStripItem(
id = "3",
title = "Selected tab",
url = "https://www.mozilla.org",
isPrivate = false,
isSelected = true,
),
TabStripItem(
id = "p1",
title = "Private tab 1",
url = "https://www.mozilla.org",
isPrivate = true,
isSelected = false,
),
TabStripItem(
id = "p2",
title = "Private selected tab",
url = "https://www.mozilla.org",
isPrivate = true,
isSelected = true,
),
),
),
)
}
@Preview(device = Devices.TABLET)
@Composable
private fun TabStripPreview(
@PreviewParameter(TabUIStateParameterProvider::class) tabStripState: TabStripState,
) {
FirefoxTheme {
TabStripContentPreview(tabStripState.tabs.filter { !it.isPrivate })
}
}
@Preview(device = Devices.TABLET)
@Composable
private fun TabStripPreviewDarkMode(
@PreviewParameter(TabUIStateParameterProvider::class) tabStripState: TabStripState,
) {
FirefoxTheme(theme = Theme.Dark) {
TabStripContentPreview(tabStripState.tabs.filter { !it.isPrivate })
}
}
@Preview(device = Devices.TABLET)
@Composable
private fun TabStripPreviewPrivateMode(
@PreviewParameter(TabUIStateParameterProvider::class) tabStripState: TabStripState,
) {
FirefoxTheme(theme = Theme.Private) {
TabStripContentPreview(tabStripState.tabs.filter { it.isPrivate })
}
}
@Composable
private fun TabStripContentPreview(tabs: List<TabStripItem>) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(dimensionResource(id = R.dimen.tab_strip_height)),
contentAlignment = Alignment.Center,
) {
TabStripContent(
state = TabStripState(
tabs = tabs,
),
onAddTabClick = {},
onCloseTabClick = { _, _ -> },
onSelectedTabClick = {},
onMove = { _, _, _ -> },
)
}
}
@Preview(device = Devices.TABLET)
@Composable
private fun TabStripPreview() {
val browserStore = BrowserStore()
FirefoxTheme {
Box(
modifier = Modifier
.fillMaxWidth()
.height(dimensionResource(id = R.dimen.tab_strip_height)),
contentAlignment = Alignment.Center,
) {
TabStrip(
appStore = AppStore(),
browserStore = browserStore,
tabsUseCases = TabsUseCases(browserStore),
onAddTabClick = {
val tab = createTab(
url = "www.example.com",
)
browserStore.dispatch(TabListAction.AddTabAction(tab))
},
onLastTabClose = {},
onCloseTabClick = {},
onSelectedTabClick = {},
)
}
}
}