For #21894: Move Tabs Tray to compose: Individual tab viewholders: ListViewHolder.

pull/543/head
Arturo Mejia 2 years ago committed by mergify[bot]
parent 017d1c4d1c
commit 5f1448460e

@ -466,6 +466,7 @@ dependencies {
implementation Deps.androidx_appcompat
implementation Deps.androidx_constraintlayout
implementation Deps.androidx_coordinatorlayout
implementation Deps.google_accompanist_drawablepainter
implementation Deps.sentry

@ -98,4 +98,8 @@ object FeatureFlags {
* Enables receiving from the messaging framework.
*/
const val messagingFeature = true
/**
* Enables compose on the tabs tray items.
*/
val composeTabsTray = Config.channel.isDebug
}

@ -32,6 +32,7 @@ import mozilla.components.concept.base.images.ImageLoadRequest
import org.mozilla.fenix.R
import org.mozilla.fenix.components.components
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.theme.Theme
/**
* Card which will display a thumbnail. If a thumbnail is not available for [url], the favicon
@ -40,6 +41,8 @@ import org.mozilla.fenix.theme.FirefoxTheme
* @param url Url to display thumbnail for.
* @param key Key used to remember the thumbnail for future compositions.
* @param modifier [Modifier] used to draw the image content.
* @param contentDescription Text used by accessibility services
* to describe what this image represents.
* @param contentScale [ContentScale] used to draw image content.
* @param alignment [Alignment] used to draw the image content.
*/
@ -48,6 +51,7 @@ fun ThumbnailCard(
url: String,
key: String,
modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.FillWidth,
alignment: Alignment = Alignment.TopCenter
) {
@ -55,36 +59,42 @@ fun ThumbnailCard(
modifier = modifier,
backgroundColor = colorResource(id = R.color.photonGrey20)
) {
components.core.icons.Loader(url) {
Placeholder {
Box(
modifier = Modifier.background(color = FirefoxTheme.colors.layer3)
)
}
WithIcon { icon ->
Box(
modifier = Modifier.size(36.dp),
contentAlignment = Alignment.Center
) {
Image(
painter = icon.painter,
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit
if (inComposePreview) {
Box(
modifier = Modifier.background(color = FirefoxTheme.colors.layer3)
)
} else {
components.core.icons.Loader(url) {
Placeholder {
Box(
modifier = Modifier.background(color = FirefoxTheme.colors.layer3)
)
}
WithIcon { icon ->
Box(
modifier = Modifier.size(36.dp),
contentAlignment = Alignment.Center
) {
Image(
painter = icon.painter,
contentDescription = contentDescription,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit
)
}
}
}
}
ThumbnailImage(
key = key,
modifier = modifier,
contentScale = contentScale,
alignment = alignment
)
ThumbnailImage(
key = key,
modifier = modifier,
contentScale = contentScale,
alignment = alignment
)
}
}
}
@ -120,11 +130,13 @@ private fun ThumbnailImage(
@Preview
@Composable
private fun ThumbnailCardPreview() {
ThumbnailCard(
url = "https://mozilla.com",
key = "123",
modifier = Modifier
.size(108.dp, 80.dp)
.clip(RoundedCornerShape(8.dp))
)
FirefoxTheme(theme = Theme.getTheme(isPrivate = false)) {
ThumbnailCard(
url = "https://mozilla.com",
key = "123",
modifier = Modifier
.size(108.dp, 80.dp)
.clip(RoundedCornerShape(8.dp))
)
}
}

@ -0,0 +1,71 @@
/* 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.compose.tabstray
import android.content.res.Configuration
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.state.createTab
import mozilla.components.concept.engine.mediasession.MediaSession.PlaybackState
import org.mozilla.fenix.R
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.theme.Theme
/**
* Controller buttons for the media (play/pause) state for the given [tab].
*
* @param tab [TabSessionState] which the image should be shown.
* @param onMediaIconClicked handles the click event when tab has media session like play/pause.
* @param modifier [Modifier] to be applied to the layout.
*/
@Composable
fun MediaImage(
tab: TabSessionState,
onMediaIconClicked: ((TabSessionState) -> Unit),
modifier: Modifier,
) {
val (icon, contentDescription) = when (tab.mediaSessionState?.playbackState) {
PlaybackState.PAUSED -> {
R.drawable.media_state_play to R.string.mozac_feature_media_notification_action_play
}
PlaybackState.PLAYING -> {
R.drawable.media_state_pause to R.string.mozac_feature_media_notification_action_pause
}
else -> return
}
val drawable = AppCompatResources.getDrawable(LocalContext.current, icon)
// Follow up ticket https://github.com/mozilla-mobile/fenix/issues/25774
Image(
painter = rememberDrawablePainter(drawable = drawable),
contentDescription = stringResource(contentDescription),
modifier = modifier.clickable { onMediaIconClicked(tab) },
)
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun ImagePreview() {
FirefoxTheme(theme = Theme.getTheme(isPrivate = false)) {
MediaImage(
tab = createTab(url = "https://mozilla.com"),
onMediaIconClicked = {},
modifier = Modifier
.height(100.dp)
.width(200.dp)
)
}
}

@ -0,0 +1,202 @@
/* 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.compose.tabstray
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.state.createTab
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.ThumbnailCard
import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.theme.Theme
/**
* List item used to display a tab that supports clicks,
* long clicks, multiselection, and media controls.
*
* @param tab The given tab to be render as view a list item.
* @param isSelected Indicates if the item should be render as selected.
* @param multiSelectionEnabled Indicates if the item should be render with multi selection options,
* enabled.
* @param multiSelectionSelected Indicates if the item should be render as multi selection selected
* option.
* @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.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
@Suppress("MagicNumber")
fun TabListItem(
tab: TabSessionState,
isSelected: Boolean = false,
multiSelectionEnabled: Boolean = false,
multiSelectionSelected: Boolean = false,
onCloseClick: (tab: TabSessionState) -> Unit,
onMediaClick: (tab: TabSessionState) -> Unit,
onClick: (tab: TabSessionState) -> Unit,
onLongClick: (tab: TabSessionState) -> Unit,
) {
val contentBackgroundColor = if (isSelected) {
FirefoxTheme.colors.layerAccentNonOpaque
} else {
FirefoxTheme.colors.layer1
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(contentBackgroundColor)
.combinedClickable(
onLongClick = { onLongClick(tab) },
onClick = { onClick(tab) }
)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Thumbnail(
tab = tab,
multiSelectionEnabled = multiSelectionEnabled,
isSelected = multiSelectionSelected,
onMediaIconClicked = { onMediaClick(it) }
)
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.weight(weight = 1f)
) {
Text(
text = tab.content.title,
fontSize = 16.sp,
maxLines = 2,
color = FirefoxTheme.colors.textPrimary,
)
Text(
text = tab.content.url.toShortUrl(),
fontSize = 12.sp,
color = FirefoxTheme.colors.textSecondary,
)
}
if (!multiSelectionEnabled) {
IconButton(
onClick = { onCloseClick(tab) },
modifier = Modifier.size(size = 24.dp),
) {
Icon(
painter = painterResource(id = R.drawable.mozac_ic_close),
contentDescription = stringResource(
id = R.string.close_tab_title,
tab.content.title
),
tint = FirefoxTheme.colors.iconPrimary
)
}
}
}
}
@Composable
private fun Thumbnail(
tab: TabSessionState,
multiSelectionEnabled: Boolean,
isSelected: Boolean,
onMediaIconClicked: ((TabSessionState) -> Unit)
) {
Box {
ThumbnailCard(
url = tab.content.url,
key = tab.id,
modifier = Modifier.size(width = 92.dp, height = 72.dp),
contentDescription = stringResource(id = R.string.mozac_browser_tabstray_open_tab),
)
if (isSelected) {
Card(
modifier = Modifier
.size(size = 40.dp)
.align(alignment = Alignment.Center),
shape = CircleShape,
backgroundColor = FirefoxTheme.colors.layerAccent,
) {
Icon(
painter = painterResource(id = R.drawable.mozac_ic_check),
modifier = Modifier
.matchParentSize()
.padding(all = 8.dp),
contentDescription = null,
tint = colorResource(id = R.color.mozac_ui_icons_fill)
)
}
}
if (!multiSelectionEnabled) {
MediaImage(
tab = tab,
onMediaIconClicked = onMediaIconClicked,
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun TabListItemPreview() {
FirefoxTheme(theme = Theme.getTheme(isPrivate = false)) {
TabListItem(
tab = createTab(url = "www.mozilla.com", title = "Mozilla"),
onCloseClick = {},
onMediaClick = {},
onClick = {},
onLongClick = {},
)
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun SelectedTabListItemPreview() {
FirefoxTheme(theme = Theme.getTheme(isPrivate = false)) {
TabListItem(
tab = createTab(url = "www.mozilla.com", title = "Mozilla"),
onCloseClick = {},
onMediaClick = {},
onClick = {},
onLongClick = {},
multiSelectionEnabled = true,
multiSelectionSelected = true,
)
}
}

@ -10,12 +10,15 @@ import mozilla.components.browser.state.action.DebugAction
import mozilla.components.browser.state.action.LastAccessAction
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.base.profiler.Profiler
import mozilla.components.concept.engine.mediasession.MediaSession.PlaybackState
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.lib.state.DelicateAction
import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.Tab
import org.mozilla.fenix.GleanMetrics.TabsTray
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
@ -97,6 +100,10 @@ interface TabsTrayController {
tabs: Collection<TabSessionState>,
numOfDays: Long = DEFAULT_ACTIVE_DAYS + 1
)
/**
* Handles when a tab item is click either to play/pause.
*/
fun handleMediaClicked(tab: SessionState)
}
@Suppress("TooManyFunctions")
@ -268,4 +275,21 @@ class DefaultTabsTrayController(
dismissTray()
navigateToHomeAndDeleteSession(sessionId)
}
override fun handleMediaClicked(tab: SessionState) {
when (tab.mediaSessionState?.playbackState) {
PlaybackState.PLAYING -> {
Tab.mediaPause.record(NoExtras())
tab.mediaSessionState?.controller?.pause()
}
PlaybackState.PAUSED -> {
Tab.mediaPlay.record(NoExtras())
tab.mediaSessionState?.controller?.play()
}
else -> throw AssertionError(
"Play/Pause button clicked without play/pause state."
)
}
}
}

@ -51,9 +51,9 @@ class TrayPagerAdapter(
inactiveTabsInteractor = inactiveTabsInteractor,
featureName = INACTIVE_TABS_FEATURE_NAME,
),
TabGroupAdapter(context, browserInteractor, tabsTrayStore, TAB_GROUP_FEATURE_NAME),
TabGroupAdapter(context, browserInteractor, tabsTrayStore, TAB_GROUP_FEATURE_NAME, lifecycleOwner),
TitleHeaderAdapter(),
BrowserTabsAdapter(context, browserInteractor, tabsTrayStore, TABS_TRAY_FEATURE_NAME)
BrowserTabsAdapter(context, browserInteractor, tabsTrayStore, TABS_TRAY_FEATURE_NAME, lifecycleOwner)
)
}
@ -62,7 +62,8 @@ class TrayPagerAdapter(
context,
browserInteractor,
tabsTrayStore,
TABS_TRAY_FEATURE_NAME
TABS_TRAY_FEATURE_NAME,
lifecycleOwner
)
}

@ -21,7 +21,7 @@ import androidx.core.view.isVisible
import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.tabstray.TabViewHolder
import mozilla.components.browser.tabstray.SelectableTabViewHolder
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.browser.tabstray.TabsTrayStyling
import mozilla.components.browser.tabstray.thumbnail.TabThumbnailView
@ -31,7 +31,6 @@ import mozilla.components.concept.base.images.ImageLoader
import mozilla.components.concept.engine.mediasession.MediaSession
import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.GleanMetrics.Collections
import org.mozilla.fenix.GleanMetrics.Tab
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
@ -44,7 +43,6 @@ import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.ext.isSelect
/**
* A RecyclerView ViewHolder implementation for "tab" items.
@ -61,10 +59,9 @@ abstract class AbstractBrowserTabViewHolder(
private val imageLoader: ImageLoader,
private val trayStore: TabsTrayStore,
private val selectionHolder: SelectionHolder<TabSessionState>?,
@VisibleForTesting
internal val featureName: String,
private val store: BrowserStore = itemView.context.components.core.store,
) : TabViewHolder(itemView) {
) : SelectableTabViewHolder(itemView) {
private val faviconView: ImageView? =
itemView.findViewById(R.id.mozac_browser_tabstray_favicon_icon)
@ -109,7 +106,9 @@ abstract class AbstractBrowserTabViewHolder(
if (selectionHolder != null) {
setSelectionInteractor(tab, selectionHolder, browserTrayInteractor)
} else {
itemView.setOnClickListener { browserTrayInteractor.onTabSelected(tab, featureName) }
itemView.setOnClickListener {
browserTrayInteractor.onTabSelected(tab, featureName)
}
}
if (tab.content.thumbnail != null) {
@ -119,7 +118,7 @@ abstract class AbstractBrowserTabViewHolder(
}
}
fun showTabIsMultiSelectEnabled(selectedMaskView: View?, isSelected: Boolean) {
override fun showTabIsMultiSelectEnabled(selectedMaskView: View?, isSelected: Boolean) {
selectedMaskView?.isVisible = isSelected
closeView.isInvisible = trayStore.state.mode is TabsTrayState.Mode.Select
}
@ -217,24 +216,11 @@ abstract class AbstractBrowserTabViewHolder(
interactor: BrowserTrayInteractor
) {
itemView.setOnClickListener {
val selected = holder.selectedItems
when {
selected.isEmpty() && trayStore.state.mode.isSelect().not() -> {
interactor.onTabSelected(item, featureName)
}
item.id in selected.map { item -> item.id } -> interactor.deselect(item)
else -> interactor.select(item)
}
interactor.onMultiSelectClicked(item, holder, featureName)
}
itemView.setOnLongClickListener {
if (holder.selectedItems.isEmpty()) {
Collections.longPress.record(NoExtras())
interactor.select(item)
true
} else {
false
}
interactor.onLongClicked(item, holder)
}
setDragInteractor(item, holder, interactor)
}

@ -8,17 +8,22 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.SelectableTabViewHolder
import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM
import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM
import mozilla.components.browser.thumbnails.loader.ThumbnailLoader
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.databinding.TabTrayGridItemBinding
import org.mozilla.fenix.databinding.TabTrayItemBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.browser.compose.ComposeListViewHolder
/**
* A [RecyclerView.Adapter] for browser tabs.
@ -27,19 +32,22 @@ import org.mozilla.fenix.tabstray.TabsTrayStore
* @param interactor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that.
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
* @param viewLifecycleOwner [LifecycleOwner] life cycle owner for the view.
*/
class BrowserTabsAdapter(
private val context: Context,
val interactor: BrowserTrayInteractor,
private val store: TabsTrayStore,
override val featureName: String
) : TabsAdapter<AbstractBrowserTabViewHolder>(interactor), FeatureNameHolder {
override val featureName: String,
internal val viewLifecycleOwner: LifecycleOwner
) : TabsAdapter<SelectableTabViewHolder>(interactor), FeatureNameHolder {
/**
* The layout types for the tabs.
*/
enum class ViewType(val layoutRes: Int) {
LIST(BrowserTabViewHolder.ListViewHolder.LAYOUT_ID),
COMPOSE_LIST(ComposeListViewHolder.LAYOUT_ID),
GRID(BrowserTabViewHolder.GridViewHolder.LAYOUT_ID)
}
@ -57,23 +65,52 @@ class BrowserTabsAdapter(
ViewType.GRID.layoutRes
}
else -> {
ViewType.LIST.layoutRes
if (FeatureFlags.composeTabsTray) {
ViewType.COMPOSE_LIST.layoutRes
} else {
ViewType.LIST.layoutRes
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbstractBrowserTabViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectableTabViewHolder {
return when (viewType) {
ViewType.GRID.layoutRes ->
BrowserTabViewHolder.GridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName)
else ->
BrowserTabViewHolder.ListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName)
ViewType.COMPOSE_LIST.layoutRes ->
ComposeListViewHolder(
interactor = interactor,
tabsTrayStore = store,
selectionHolder = selectionHolder,
composeItemView = ComposeView(parent.context),
featureName = featureName,
viewLifecycleOwner = viewLifecycleOwner
)
else -> {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
if (viewType == ViewType.GRID.layoutRes) {
BrowserTabViewHolder.GridViewHolder(
imageLoader,
interactor,
store,
selectionHolder,
view,
featureName
)
} else {
BrowserTabViewHolder.ListViewHolder(
imageLoader,
interactor,
store,
selectionHolder,
view,
featureName
)
}
}
}
}
override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int) {
override fun onBindViewHolder(holder: SelectableTabViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
var selectedMaskView: View? = null
holder.tab?.let { tab ->
@ -103,7 +140,7 @@ class BrowserTabsAdapter(
* Over-ridden [onBindViewHolder] that uses the payloads to notify the selected tab how to
* display itself.
*/
override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int, payloads: List<Any>) {
override fun onBindViewHolder(holder: SelectableTabViewHolder, position: Int, payloads: List<Any>) {
if (currentList.isEmpty()) return
if (payloads.isEmpty()) {

@ -8,12 +8,16 @@ import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.Collections
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.selection.SelectionInteractor
import org.mozilla.fenix.tabstray.TabsTrayAction
import org.mozilla.fenix.tabstray.TabsTrayController
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayState.Mode
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.ext.isSelect
/**
* For interacting with UI that is specifically for [AbstractBrowserTrayList] and other browser
@ -46,6 +50,29 @@ interface BrowserTrayInteractor : SelectionInteractor<TabSessionState>, UserInte
* Recently Closed item is clicked.
*/
fun onRecentlyClosedClicked()
/**
* Indicates Play/Pause item is clicked.
* @param tab [TabSessionState] to close.
*/
fun onMediaClicked(tab: TabSessionState)
/**
* Handles clicks when multi-selection is enabled.
*/
fun onMultiSelectClicked(
tab: TabSessionState,
holder: SelectionHolder<TabSessionState>,
source: String?
)
/**
* Handles long click events when tab item is clicked.
*/
fun onLongClicked(
tab: TabSessionState,
holder: SelectionHolder<TabSessionState>
): Boolean
}
/**
@ -135,6 +162,47 @@ class DefaultBrowserTrayInteractor(
controller.handleNavigateToRecentlyClosed()
}
/**
* See [BrowserTrayInteractor.onMultiSelectClicked]
*/
override fun onMediaClicked(tab: TabSessionState) {
controller.handleMediaClicked(tab)
}
/**
* See [BrowserTrayInteractor.onMultiSelectClicked]
*/
override fun onMultiSelectClicked(
tab: TabSessionState,
holder: SelectionHolder<TabSessionState>,
source: String?
) {
val selected = holder.selectedItems
when {
selected.isEmpty() && store.state.mode.isSelect().not() -> {
onTabSelected(tab, source)
}
tab.id in selected.map { it.id } -> deselect(tab)
else -> select(tab)
}
}
/**
* See [BrowserTrayInteractor.onLongClicked]
*/
override fun onLongClicked(
tab: TabSessionState,
holder: SelectionHolder<TabSessionState>
): Boolean {
return if (holder.selectedItems.isEmpty()) {
Collections.longPress.record(NoExtras())
select(tab)
true
} else {
false
}
}
private fun selectTab(tab: TabSessionState, source: String? = null) {
selectTabWrapper.invoke(tab.id, source)
}

@ -7,6 +7,7 @@ package org.mozilla.fenix.tabstray.browser
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
@ -27,6 +28,7 @@ import org.mozilla.fenix.tabstray.TabsTrayStore
* @param context [Context] used for various platform interactions or accessing [Components]
* @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
* @param viewLifecycleOwner [LifecycleOwner] life cycle owner for the view.
*/
@Suppress("TooManyFunctions")
class TabGroupAdapter(
@ -34,6 +36,7 @@ class TabGroupAdapter(
private val browserTrayInteractor: BrowserTrayInteractor,
private val store: TabsTrayStore,
override val featureName: String,
private val viewLifecycleOwner: LifecycleOwner
) : ListAdapter<TabGroup, TabGroupViewHolder>(DiffCallback), TabsTray, FeatureNameHolder {
/**
@ -44,14 +47,19 @@ class TabGroupAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabGroupViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return when {
context.components.settings.gridTabView -> {
TabGroupViewHolder(view, HORIZONTAL, browserTrayInteractor, store, selectionHolder)
}
else -> {
TabGroupViewHolder(view, VERTICAL, browserTrayInteractor, store, selectionHolder)
}
val orientation = if (context.components.settings.gridTabView) {
HORIZONTAL
} else {
VERTICAL
}
return TabGroupViewHolder(
view,
orientation,
browserTrayInteractor,
store,
selectionHolder,
viewLifecycleOwner
)
}
override fun onBindViewHolder(holder: TabGroupViewHolder, position: Int) {

@ -8,14 +8,18 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.SelectableTabViewHolder
import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM
import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM
import mozilla.components.browser.tabstray.TabsTrayStyling
import mozilla.components.browser.thumbnails.loader.ThumbnailLoader
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.TabTrayGridItemBinding
import org.mozilla.fenix.databinding.TabTrayItemBinding
@ -23,6 +27,7 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.home.topsites.dpToPx
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.browser.compose.ComposeListViewHolder
import org.mozilla.fenix.tabstray.ext.MIN_COLUMN_WIDTH_DP
/**
@ -32,6 +37,7 @@ import org.mozilla.fenix.tabstray.ext.MIN_COLUMN_WIDTH_DP
* @param interactor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that.
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
* @param viewLifecycleOwner [LifecycleOwner] life cycle owner for the view.
*/
class TabGroupListAdapter(
private val context: Context,
@ -39,14 +45,15 @@ class TabGroupListAdapter(
private val store: TabsTrayStore,
private val selectionHolder: SelectionHolder<TabSessionState>?,
private val featureName: String,
) : ListAdapter<TabSessionState, AbstractBrowserTabViewHolder>(DiffCallback) {
private val viewLifecycleOwner: LifecycleOwner
) : ListAdapter<TabSessionState, SelectableTabViewHolder>(DiffCallback) {
private val selectedItemAdapterBinding = SelectedItemAdapterBinding(store, this)
private val imageLoader = ThumbnailLoader(context.components.core.thumbnailStorage)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): AbstractBrowserTabViewHolder {
): SelectableTabViewHolder {
return when {
context.components.settings.gridTabView -> {
val view = LayoutInflater.from(parent.context).inflate(R.layout.tab_tray_grid_item, parent, false)
@ -54,13 +61,32 @@ class TabGroupListAdapter(
BrowserTabViewHolder.GridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName)
}
else -> {
val view = LayoutInflater.from(parent.context).inflate(R.layout.tab_tray_item, parent, false)
BrowserTabViewHolder.ListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName)
if (FeatureFlags.composeTabsTray) {
ComposeListViewHolder(
interactor = interactor,
tabsTrayStore = store,
selectionHolder = selectionHolder,
composeItemView = ComposeView(parent.context),
featureName = featureName,
viewLifecycleOwner = viewLifecycleOwner
)
} else {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.tab_tray_item, parent, false)
BrowserTabViewHolder.ListViewHolder(
imageLoader,
interactor,
store,
selectionHolder,
view,
featureName
)
}
}
}
}
override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int) {
override fun onBindViewHolder(holder: SelectableTabViewHolder, position: Int) {
val tab = getItem(position)
val selectedTabId = context.components.core.store.state.selectedTabId
holder.bind(tab, tab.id == selectedTabId, TabsTrayStyling(), interactor)
@ -88,7 +114,7 @@ class TabGroupListAdapter(
*
* N.B: this is a modified version of [BrowserTabsAdapter.onBindViewHolder].
*/
override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int, payloads: List<Any>) {
override fun onBindViewHolder(holder: SelectableTabViewHolder, position: Int, payloads: List<Any>) {
val tabs = currentList
val selectedTabId = context.components.core.store.state.selectedTabId
val selectedIndex = tabs.indexOfFirst { it.id == selectedTabId }
@ -120,7 +146,10 @@ class TabGroupListAdapter(
selectedMaskView = listBinding.checkboxInclude.selectedMask
}
}
holder.showTabIsMultiSelectEnabled(selectedMaskView, it.selectedItems.contains(holder.tab))
holder.showTabIsMultiSelectEnabled(
selectedMaskView,
it.selectedItems.contains(holder.tab)
)
}
}
@ -130,7 +159,11 @@ class TabGroupListAdapter(
BrowserTabsAdapter.ViewType.GRID.layoutRes
}
else -> {
BrowserTabsAdapter.ViewType.LIST.layoutRes
if (FeatureFlags.composeTabsTray) {
BrowserTabsAdapter.ViewType.COMPOSE_LIST.layoutRes
} else {
BrowserTabsAdapter.ViewType.LIST.layoutRes
}
}
}
}

@ -5,6 +5,7 @@
package org.mozilla.fenix.tabstray.browser
import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.selector.normalTabs
@ -25,13 +26,15 @@ import org.mozilla.fenix.tabstray.TrayPagerAdapter
* @param interactor the [BrowserTrayInteractor] for tab interactions.
* @param store the [TabsTrayStore] instance.
* @param selectionHolder the store that holds the currently selected tabs.
* @param viewLifecycleOwner [LifecycleOwner] life cycle owner for the view.
*/
class TabGroupViewHolder(
itemView: View,
val orientation: Int,
val interactor: BrowserTrayInteractor,
val store: TabsTrayStore,
val selectionHolder: SelectionHolder<TabSessionState>? = null
val selectionHolder: SelectionHolder<TabSessionState>? = null,
private val viewLifecycleOwner: LifecycleOwner
) : RecyclerView.ViewHolder(itemView) {
private val binding = TabGroupItemBinding.bind(itemView)
@ -51,7 +54,8 @@ class TabGroupViewHolder(
interactor = interactor,
store = store,
selectionHolder = selectionHolder,
featureName = TrayPagerAdapter.TAB_GROUP_FEATURE_NAME
featureName = TrayPagerAdapter.TAB_GROUP_FEATURE_NAME,
viewLifecycleOwner
)
adapter = groupListAdapter

@ -0,0 +1,52 @@
/* 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.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewTreeLifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.ViewTreeSavedStateRegistryOwner
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.SelectableTabViewHolder
import org.mozilla.fenix.compose.ComposeViewHolder
import org.mozilla.fenix.theme.FirefoxTheme
/**
* [RecyclerView.ViewHolder] used for Jetpack Compose UI content .
*
* @param composeView [ComposeView] which will be populated with Jetpack Compose UI content.
* @param viewLifecycleOwner [LifecycleOwner] life cycle owner for the view.
*/
abstract class ComposeAbstractTabViewHolder(
private val composeView: ComposeView,
private val viewLifecycleOwner: LifecycleOwner
) : SelectableTabViewHolder(composeView) {
/**
* Composable that contains the content for a specific [ComposeViewHolder] implementation.
*/
@Composable
abstract fun Content(tab: TabSessionState)
/**
* Binds the a composable to the [composeView].
*/
fun bind(tab: TabSessionState) {
composeView.setContent {
FirefoxTheme {
Content(tab)
}
}
ViewTreeLifecycleOwner.set(composeView, viewLifecycleOwner)
ViewTreeSavedStateRegistryOwner.set(
composeView,
viewLifecycleOwner as SavedStateRegistryOwner
)
}
}

@ -0,0 +1,112 @@
/* 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 android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.browser.tabstray.TabsTrayStyling
import mozilla.components.lib.state.ext.observeAsComposableState
import org.mozilla.fenix.compose.tabstray.TabListItem
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor
/**
* A Compose ViewHolder implementation for "tab" items with list layout.
*
* @param interactor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param tabsTrayStore [TabsTrayStore] containing the complete state of tabs tray and methods to update that.
* @param selectionHolder [SelectionHolder]<[TabSessionState]> for helping with selecting
* any number of displayed [TabSessionState]s.
* @param composeItemView that displays a "tab".
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
* @param viewLifecycleOwner [LifecycleOwner] to which this Composable will be tied to.
*/
class ComposeListViewHolder(
private val interactor: BrowserTrayInteractor,
private val tabsTrayStore: TabsTrayStore,
private val selectionHolder: SelectionHolder<TabSessionState>? = null,
composeItemView: ComposeView,
private val featureName: String,
viewLifecycleOwner: LifecycleOwner,
) : ComposeAbstractTabViewHolder(composeItemView, viewLifecycleOwner) {
private var delegate: TabsTray.Delegate? = null
override var tab: TabSessionState? = null
private val isMultiSelectionSelected = MutableStateFlow(false)
private val isSelectedTab = MutableStateFlow(false)
override fun bind(
tab: TabSessionState,
isSelected: Boolean,
styling: TabsTrayStyling,
delegate: TabsTray.Delegate
) {
this.tab = tab
this.delegate = delegate
isSelectedTab.value = isSelected
bind(tab)
}
override fun updateSelectedTabIndicator(showAsSelected: Boolean) {
isSelectedTab.value = showAsSelected
}
override fun showTabIsMultiSelectEnabled(selectedMaskView: View?, isSelected: Boolean) {
isMultiSelectionSelected.value = isSelected
}
private fun onCloseClicked(tab: TabSessionState) {
delegate?.onTabClosed(tab, featureName)
}
private fun onClick(tab: TabSessionState) {
val holder = selectionHolder
if (holder != null) {
interactor.onMultiSelectClicked(tab, holder, featureName)
} else {
interactor.onTabSelected(tab, featureName)
}
}
private fun onLongClick(tab: TabSessionState) {
val holder = selectionHolder ?: return
interactor.onLongClicked(tab, holder)
}
@Composable
override fun Content(tab: TabSessionState) {
val multiSelectionEnabled = tabsTrayStore.observeAsComposableState {
state ->
state.mode is TabsTrayState.Mode.Select
}.value ?: false
val isSelectedTabState by isSelectedTab.collectAsState()
val multiSelectionSelected by isMultiSelectionSelected.collectAsState()
TabListItem(
tab = tab,
isSelected = isSelectedTabState,
multiSelectionEnabled = multiSelectionEnabled,
multiSelectionSelected = multiSelectionSelected,
onCloseClick = ::onCloseClicked,
onMediaClick = interactor::onMediaClicked,
onClick = ::onClick,
onLongClick = ::onLongClick,
)
}
companion object {
val LAYOUT_ID = View.generateViewId()
}
}

@ -87,7 +87,8 @@ class TabsTrayFragmentTest {
fragment._tabsTrayDialogBinding = tabsTrayDialogBinding
fragment._fabButtonBinding = fabButtonBinding
every { fragment.context } returns context
every { fragment.view } returns view
every { fragment.context } returns context
every { fragment.viewLifecycleOwner } returns mockk(relaxed = true)
}
@Test
@ -100,6 +101,7 @@ class TabsTrayFragmentTest {
fabButtonBinding.newTabButton.isVisible = true
every { fragment.context } returns testContext // needed for getString()
every { any<CoroutineScope>().allowUndo(any(), any(), any(), any(), any(), any(), any(), any()) } just Runs
every { fragment.requireView() } returns view
fragment.showUndoSnackbarForTab(true)
@ -130,6 +132,7 @@ class TabsTrayFragmentTest {
every { any<LifecycleOwner>().lifecycleScope } returns lifecycleScope
every { fragment.context } returns testContext // needed for getString()
every { any<CoroutineScope>().allowUndo(any(), any(), any(), any(), any(), any(), any(), any()) } just Runs
every { fragment.requireView() } returns view
fragment.showUndoSnackbarForTab(true)
@ -161,6 +164,7 @@ class TabsTrayFragmentTest {
fabButtonBinding.newTabButton.isVisible = true
every { fragment.context } returns testContext // needed for getString()
every { any<CoroutineScope>().allowUndo(any(), any(), any(), any(), any(), any(), any(), any()) } just Runs
every { fragment.requireView() } returns view
fragment.showUndoSnackbarForTab(false)
@ -191,6 +195,7 @@ class TabsTrayFragmentTest {
every { any<LifecycleOwner>().lifecycleScope } returns lifecycleScope
every { fragment.context } returns testContext // needed for getString()
every { any<CoroutineScope>().allowUndo(any(), any(), any(), any(), any(), any(), any(), any()) } just Runs
every { fragment.requireView() } returns view
fragment.showUndoSnackbarForTab(false)

@ -16,7 +16,6 @@ import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.base.images.ImageLoader
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
@ -77,12 +76,12 @@ class AbstractBrowserTabViewHolderTest {
interactor
)
holder.bind(createTab(url = "url"), false, mockk(), mockk())
val tab = createTab(url = "url")
holder.bind(tab, false, mockk(), mockk())
holder.itemView.performClick()
verify { interactor.onTabSelected(any(), holder.featureName) }
assertTrue(selectionHolder.invoked)
verify { interactor.onMultiSelectClicked(tab, any(), holder.featureName) }
}
@Test

@ -33,7 +33,7 @@ class BrowserTabsAdapterTest {
@Test
fun `WHEN bind with payloads is called THEN update the holder`() {
every { testContext.components.core.thumbnailStorage } returns mockk()
val adapter = BrowserTabsAdapter(context, interactor, store, "Test")
val adapter = BrowserTabsAdapter(context, interactor, store, "Test", mockk())
val holder = mockk<AbstractBrowserTabViewHolder>(relaxed = true)
adapter.updateTabs(
@ -59,7 +59,7 @@ class BrowserTabsAdapterTest {
every { testContext.components.core.store } returns BrowserStore()
every { testContext.components.analytics } returns mockk(relaxed = true)
every { testContext.components.settings } returns mockk(relaxed = true)
val adapter = BrowserTabsAdapter(context, interactor, store, "Test")
val adapter = BrowserTabsAdapter(context, interactor, store, "Test", mockk())
val binding = TabTrayItemBinding.inflate(LayoutInflater.from(testContext))
val holder = spyk(
BrowserTabViewHolder.ListViewHolder(

@ -35,7 +35,9 @@ class AbstractBrowserPageViewHolderTest {
every { testContext.components.core.thumbnailStorage } returns mockk()
every { testContext.components.settings } returns mockk(relaxed = true)
}
val adapter = BrowserTabsAdapter(testContext, browserTrayInteractor, tabsTrayStore, "Test")
val adapter =
BrowserTabsAdapter(testContext, browserTrayInteractor, tabsTrayStore, "Test", mockk())
@Test
fun `WHEN tabs inserted THEN show tray`() {

@ -39,6 +39,7 @@ object Versions {
const val androidx_work = "2.7.1"
const val androidx_datastore = "1.0.0"
const val google_material = "1.2.1"
const val accompanist_drawablepainter = "0.23.1"
const val mozilla_android_components = AndroidComponents.VERSION
@ -209,6 +210,8 @@ object Deps {
const val androidx_work_testing = "androidx.work:work-testing:${Versions.androidx_work}"
const val androidx_datastore = "androidx.datastore:datastore:${Versions.androidx_datastore}"
const val google_material = "com.google.android.material:material:${Versions.google_material}"
const val google_accompanist_drawablepainter =
"com.google.accompanist:accompanist-drawablepainter:${Versions.accompanist_drawablepainter}"
const val protobuf_javalite = "com.google.protobuf:protobuf-javalite:${Versions.protobuf}"
const val protobuf_compiler = "com.google.protobuf:protoc:${Versions.protobuf}"

Loading…
Cancel
Save