Bug 1814991 - Implement Tabs Tray single select banner UI

fenix/113.0
Noah Bond 1 year ago committed by mergify[bot]
parent 20d3de7c8e
commit 9b9616a596

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -19,6 +20,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.unit.dp
@ -66,6 +68,7 @@ fun TabsTray(
tabsTrayStore: TabsTrayStore,
displayTabsInGrid: Boolean,
shouldShowInactiveTabsAutoCloseDialog: (Int) -> Boolean,
onTabPageClick: (Page) -> Unit,
onTabClose: (TabSessionState) -> Unit,
onTabMediaClick: (TabSessionState) -> Unit,
onTabClick: (TabSessionState) -> Unit,
@ -81,6 +84,8 @@ fun TabsTray(
) {
val multiselectMode = tabsTrayStore
.observeAsComposableState { state -> state.mode }.value ?: TabsTrayState.Mode.Normal
val selectedPage = tabsTrayStore
.observeAsComposableState { state -> state.selectedPage }.value ?: Page.NormalTabs
val normalTabs = tabsTrayStore
.observeAsComposableState { state -> state.normalTabs }.value ?: emptyList()
val privateTabs = tabsTrayStore
@ -93,7 +98,8 @@ fun TabsTray(
val scope = rememberCoroutineScope()
val isInMultiSelectMode = multiselectMode is TabsTrayState.Mode.Select
val animateScrollToPage: ((Page) -> Unit) = { page ->
val onTabPageIndicatorClicked: ((Page) -> Unit) = { page ->
onTabPageClick(page)
scope.launch {
pagerState.animateScrollToPage(page.ordinal)
}
@ -109,12 +115,15 @@ fun TabsTray(
Column(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
.background(FirefoxTheme.colors.layer1),
) {
Box(modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())) {
TabsTrayBanner(
isInMultiSelectMode = isInMultiSelectMode,
onTabPageIndicatorClicked = animateScrollToPage,
selectedPage = selectedPage,
normalTabCount = normalTabs.size + inactiveTabs.size,
onTabPageIndicatorClicked = onTabPageIndicatorClicked,
)
}
@ -230,6 +239,7 @@ private fun TabsTrayInactiveTabsPreview() {
@Composable
private fun TabsTrayPreviewRoot(
displayTabsInGrid: Boolean = true,
selectedPage: Page = Page.NormalTabs,
mode: TabsTrayState.Mode = TabsTrayState.Mode.Normal,
normalTabs: List<TabSessionState> = emptyList(),
inactiveTabs: List<TabSessionState> = emptyList(),
@ -237,6 +247,7 @@ private fun TabsTrayPreviewRoot(
inactiveTabsExpanded: Boolean = false,
showInactiveTabsAutoCloseDialog: Boolean = false,
) {
var selectedPageState by remember { mutableStateOf(selectedPage) }
val normalTabsState = remember { normalTabs.toMutableStateList() }
val inactiveTabsState = remember { inactiveTabs.toMutableStateList() }
val privateTabsState = remember { privateTabs.toMutableStateList() }
@ -250,6 +261,7 @@ private fun TabsTrayPreviewRoot(
)
val tabsTrayStore = TabsTrayStore(
initialState = TabsTrayState(
selectedPage = selectedPageState,
mode = mode,
inactiveTabs = inactiveTabsState,
normalTabs = normalTabsState,
@ -263,6 +275,9 @@ private fun TabsTrayPreviewRoot(
tabsTrayStore = tabsTrayStore,
displayTabsInGrid = displayTabsInGrid,
shouldShowInactiveTabsAutoCloseDialog = { true },
onTabPageClick = { page ->
selectedPageState = page
},
onTabClose = { tab ->
if (tab.isNormalTab()) {
normalTabsState.remove(tab)

@ -7,28 +7,57 @@ package org.mozilla.fenix.tabstray
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.Text
import androidx.compose.material.ripple.LocalRippleTheme
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material.ripple.RippleTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import mozilla.components.ui.tabcounter.TabCounter
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.annotation.LightDarkPreview
import org.mozilla.fenix.compose.button.PrimaryButton
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Top-level UI for displaying the banner in [TabsTray].
*
* @param isInMultiSelectMode Whether the tab list is in multi-select mode.
* @param selectedPage The active [Page] of the Tabs Tray.
* @param normalTabCount The total amount of normal browsing tabs currently open.
* @param onTabPageIndicatorClicked Invoked when the user clicks on a tab page indicator.
*/
@Composable
fun TabsTrayBanner(
isInMultiSelectMode: Boolean,
selectedPage: Page,
normalTabCount: Int,
onTabPageIndicatorClicked: (Page) -> Unit,
) {
if (isInMultiSelectMode) {
@ -36,41 +65,139 @@ fun TabsTrayBanner(
} else {
SingleSelectBanner(
onTabPageIndicatorClicked = onTabPageIndicatorClicked,
selectedPage = selectedPage,
normalTabCount = normalTabCount,
)
}
}
@Suppress("LongMethod")
@Composable
private fun SingleSelectBanner(
selectedPage: Page,
normalTabCount: Int,
onTabPageIndicatorClicked: (Page) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.padding(all = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(modifier = Modifier.weight(1.0f)) {
PrimaryButton(text = "Normal tabs") {
onTabPageIndicatorClicked(Page.NormalTabs)
}
}
val selectedColor = FirefoxTheme.colors.iconActive
val inactiveColor = FirefoxTheme.colors.iconPrimaryInactive
Box(modifier = Modifier.weight(1.0f)) {
PrimaryButton(text = "Private tabs") {
onTabPageIndicatorClicked(Page.PrivateTabs)
Column {
Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.bottom_sheet_handle_top_margin)))
Divider(
modifier = Modifier
.fillMaxWidth(DRAG_INDICATOR_WIDTH_PERCENT)
.align(Alignment.CenterHorizontally),
color = FirefoxTheme.colors.textSecondary,
thickness = dimensionResource(id = R.dimen.bottom_sheet_handle_height),
)
Row(
modifier = Modifier
.fillMaxWidth()
.height(80.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
CompositionLocalProvider(LocalRippleTheme provides DisabledRippleTheme) {
TabRow(
selectedTabIndex = selectedPage.ordinal,
modifier = Modifier.width(180.dp),
backgroundColor = Color.Transparent,
contentColor = selectedColor,
divider = {},
) {
Tab(
selected = selectedPage == Page.NormalTabs,
onClick = { onTabPageIndicatorClicked(Page.NormalTabs) },
modifier = Modifier.fillMaxHeight(),
selectedContentColor = selectedColor,
unselectedContentColor = inactiveColor,
) {
NormalTabsTabIcon(normalTabCount = normalTabCount)
}
Tab(
selected = selectedPage == Page.PrivateTabs,
onClick = { onTabPageIndicatorClicked(Page.PrivateTabs) },
modifier = Modifier.fillMaxHeight(),
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_private_browsing),
contentDescription = stringResource(id = R.string.tabs_header_private_tabs_title),
)
},
selectedContentColor = selectedColor,
unselectedContentColor = inactiveColor,
)
Tab(
selected = selectedPage == Page.SyncedTabs,
onClick = { onTabPageIndicatorClicked(Page.SyncedTabs) },
modifier = Modifier.fillMaxHeight(),
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_synced_tabs),
contentDescription = stringResource(id = R.string.tabs_header_synced_tabs_title),
)
},
selectedContentColor = selectedColor,
unselectedContentColor = inactiveColor,
)
}
}
}
Box(modifier = Modifier.weight(1.0f)) {
PrimaryButton(text = "Synced tabs") {
onTabPageIndicatorClicked(Page.SyncedTabs)
Spacer(modifier = Modifier.weight(1.0f))
IconButton(
onClick = {},
modifier = Modifier.align(Alignment.CenterVertically),
) {
Icon(
painter = painterResource(R.drawable.ic_menu),
contentDescription = stringResource(id = R.string.open_tabs_menu),
tint = FirefoxTheme.colors.iconPrimary,
)
}
}
}
}
@Composable
private fun NormalTabsTabIcon(normalTabCount: Int) {
val normalTabCountText: String
val normalTabCountTextModifier: Modifier
if (normalTabCount > TabCounter.MAX_VISIBLE_TABS) {
normalTabCountText = TabCounter.SO_MANY_TABS_OPEN
normalTabCountTextModifier = Modifier.padding(bottom = 1.dp)
} else {
normalTabCountText = normalTabCount.toString()
normalTabCountTextModifier = Modifier
}
val normalTabsContentDescription = if (normalTabCount == 1) {
stringResource(id = R.string.mozac_tab_counter_open_tab_tray_single)
} else {
stringResource(id = R.string.mozac_tab_counter_open_tab_tray_plural, normalTabCount.toString())
}
Box {
Icon(
painter = painterResource(
id = mozilla.components.ui.tabcounter.R.drawable.mozac_ui_tabcounter_box,
),
contentDescription = normalTabsContentDescription,
modifier = Modifier.align(Alignment.Center),
)
Text(
text = normalTabCountText,
modifier = normalTabCountTextModifier.align(Alignment.Center),
color = LocalContentColor.current,
fontSize = with(LocalDensity.current) { 12.dp.toSp() },
fontWeight = FontWeight.W700,
)
}
}
@Composable
private fun MultiSelectBanner() {
Box(
@ -91,23 +218,56 @@ private fun MultiSelectBanner() {
@LightDarkPreview
@Composable
private fun TabsTrayBannerPreview() {
TabsTrayBannerPreviewRoot(
selectedPage = Page.PrivateTabs,
normalTabCount = 5,
)
}
@LightDarkPreview
@Composable
private fun TabsTrayBannerInfinityPreview() {
TabsTrayBannerPreviewRoot(
normalTabCount = TabCounter.MAX_VISIBLE_TABS + 1,
)
}
@LightDarkPreview
@Composable
private fun TabsTrayBannerMultiselectPreview() {
TabsTrayBannerPreviewRoot(
isInMultiSelectMode = true,
)
}
@Composable
private fun TabsTrayBannerPreviewRoot(
isInMultiSelectMode: Boolean = false,
selectedPage: Page = Page.NormalTabs,
normalTabCount: Int = 10,
) {
var selectedPageState by remember { mutableStateOf(selectedPage) }
FirefoxTheme {
Box(modifier = Modifier.background(color = FirefoxTheme.colors.layer1)) {
TabsTrayBanner(
isInMultiSelectMode = false,
onTabPageIndicatorClicked = {},
isInMultiSelectMode = isInMultiSelectMode,
selectedPage = selectedPageState,
normalTabCount = normalTabCount,
onTabPageIndicatorClicked = { page ->
selectedPageState = page
},
)
}
}
}
@LightDarkPreview
@Composable
private fun TabsTrayBannerMultiselectPreview() {
FirefoxTheme {
TabsTrayBanner(
isInMultiSelectMode = true,
onTabPageIndicatorClicked = {},
)
}
private const val DRAG_INDICATOR_WIDTH_PERCENT = 0.1f
private object DisabledRippleTheme : RippleTheme {
@Composable
override fun defaultColor() = Color.Unspecified
@Composable
override fun rippleAlpha(): RippleAlpha = RippleAlpha(0.0f, 0.0f, 0.0f, 0.0f)
}

@ -224,6 +224,9 @@ class TabsTrayFragment : AppCompatDialogFragment() {
displayTabsInGrid = requireContext().settings().gridTabView,
shouldShowInactiveTabsAutoCloseDialog =
requireContext().settings()::shouldShowInactiveTabsAutoCloseDialog,
onTabPageClick = { page ->
tabsTrayInteractor.onTrayPositionSelected(page.ordinal, false)
},
onTabClose = { tab ->
tabsTrayInteractor.onTabClosed(tab, TABS_TRAY_FEATURE_NAME)
},
@ -619,8 +622,10 @@ class TabsTrayFragment : AppCompatDialogFragment() {
@VisibleForTesting
internal fun selectTabPosition(position: Int, smoothScroll: Boolean) {
tabsTrayBinding.tabsTray.setCurrentItem(position, smoothScroll)
tabsTrayBinding.tabLayout.getTabAt(position)?.select()
if (!requireContext().settings().enableTabsTrayToCompose) {
tabsTrayBinding.tabsTray.setCurrentItem(position, smoothScroll)
tabsTrayBinding.tabLayout.getTabAt(position)?.select()
}
}
@VisibleForTesting

@ -341,6 +341,9 @@ class TabsTrayFragmentTest {
every { getTabAt(any()) } returns tab
}
every { fragment.context } returns testContext
every { testContext.settings().enableTabsTrayToCompose } returns false
mockkStatic(ViewBindings::class) {
every { ViewBindings.findChildViewById<View>(tabsTrayBinding.root, tabsTrayBinding.tabsTray.id) } returns tabsTray
every { ViewBindings.findChildViewById<View>(tabsTrayBinding.root, tabsTrayBinding.tabLayout.id) } returns tabLayout

Loading…
Cancel
Save