Issue #20663: Make inactive card collapsible

We use make the inactive tabs section of the tabstray collapsible in
this change, with a technical quirk: we want to make the "isExpanded"
state of the tabs stay for the lifetime of the app and not the tabs
tray, but this functionality does not exist.

In this patch, we're storing the UI state in a singleton class that
exists for the lifetime of the app, but a more concrete solution is to
use an AppStore that holds content like this, which we can land in a
future patch.
upstream-sync
Jonathan Almeida 3 years ago committed by mergify[bot]
parent 0bc64e8ca8
commit d6bc93981d

@ -74,7 +74,7 @@ sealed class RecentTabViewDecorator {
val context = itemView.context
itemView.background =
AppCompatResources.getDrawable(context, R.drawable.home_list_row_background)
AppCompatResources.getDrawable(context, R.drawable.card_list_row_background)
return itemView
}

@ -12,6 +12,7 @@ import mozilla.components.concept.tabstray.Tab
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.InactiveFooterItemBinding
import org.mozilla.fenix.databinding.InactiveRecentlyClosedItemBinding
import org.mozilla.fenix.databinding.InactiveHeaderItemBinding
import org.mozilla.fenix.databinding.InactiveTabListItemBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView
@ -22,9 +23,32 @@ import org.mozilla.fenix.tabstray.browser.AutoCloseInterval.OneMonth
import org.mozilla.fenix.tabstray.browser.AutoCloseInterval.OneWeek
sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
class HeaderHolder(itemView: View) : InactiveTabViewHolder(itemView) {
class HeaderHolder(
itemView: View,
interactor: InactiveTabsInteractor
) : InactiveTabViewHolder(itemView) {
private val binding = InactiveHeaderItemBinding.bind(itemView)
init {
itemView.apply {
isActivated = InactiveTabsState.isExpanded
setOnClickListener {
val newState = !it.isActivated
interactor.onHeaderClicked(newState)
it.isActivated = newState
binding.chevron.rotation = ROTATION_DEGREE
}
}
}
companion object {
const val LAYOUT_ID = R.layout.inactive_header_item
private const val ROTATION_DEGREE = 180F
}
}

@ -39,12 +39,14 @@ class InactiveTabsAdapter(
delegate: Observable = ObserverRegistry()
) : Adapter(DiffCallback), TabsTray, Observable by delegate {
internal lateinit var inactiveTabsInteractor: InactiveTabsInteractor
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InactiveTabViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(viewType, parent, false)
return when (viewType) {
HeaderHolder.LAYOUT_ID -> HeaderHolder(view)
HeaderHolder.LAYOUT_ID -> HeaderHolder(view, inactiveTabsInteractor)
TabViewHolder.LAYOUT_ID -> TabViewHolder(view, browserTrayInteractor)
FooterHolder.LAYOUT_ID -> FooterHolder(view)
RecentlyClosedHolder.LAYOUT_ID -> RecentlyClosedHolder(view, browserTrayInteractor)
@ -81,12 +83,18 @@ class InactiveTabsAdapter(
}
override fun updateTabs(tabs: Tabs) {
// Early return with an empty list to remove the header/footer items.
if (tabs.list.isEmpty()) {
// Early return with an empty list to remove the header/footer items.
submitList(emptyList())
return
}
// If we have items, but we should be in a collapsed state.
if (!InactiveTabsState.isExpanded) {
submitList(listOf(Item.Header))
return
}
val items = tabs.list.map { Item.Tab(it) }
val footer = Item.Footer(context.autoCloseInterval)

@ -0,0 +1,28 @@
/* 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
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.feature.tabs.ext.toTabs
class InactiveTabsController(
private val browserStore: BrowserStore,
private val tabFilter: (TabSessionState) -> Boolean,
private val tray: TabsTray
) {
/**
* Updates the inactive card to be expanded to display all the tabs, or collapsed with only
* the title showing.
*/
fun updateCardExpansion(isExpanded: Boolean) {
InactiveTabsState.isExpanded = isExpanded
val tabs = browserStore.state.toTabs { tabFilter.invoke(it) }
tray.updateTabs(tabs)
}
}

@ -0,0 +1,26 @@
/* 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
interface InactiveTabsInteractor {
fun onHeaderClicked(activated: Boolean)
}
class DefaultInactiveTabsInteractor(
private val controller: InactiveTabsController
) : InactiveTabsInteractor {
override fun onHeaderClicked(activated: Boolean) {
controller.updateCardExpansion(activated)
}
}
/**
* An experimental state holder for [InactiveTabsAdapter] that lives at the application lifetime.
*
* TODO This should be replaced with the AppStore.
*/
object InactiveTabsState {
var isExpanded = true
}

@ -7,6 +7,7 @@ package org.mozilla.fenix.tabstray.browser
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.ConcatAdapter
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabViewHolder
import mozilla.components.feature.tabs.tabstray.TabsFeature
import org.mozilla.fenix.FeatureFlags
@ -53,20 +54,30 @@ class NormalBrowserTrayList @JvmOverloads constructor(
)
}
/**
* NB: The setup for this feature is a bit complicated without a better dependency injection
* solution to scope it down to just this view.
*/
private val inactiveFeature by lazy {
val tabsAdapter = concatAdapter.inactiveTabsAdapter
val store = context.components.core.store
val tabFilter: (TabSessionState) -> Boolean = filter@{
if (!FeatureFlags.inactiveTabs) {
return@filter false
}
it.isNormalTabInactive(maxActiveTime)
}
val tabsAdapter = concatAdapter.inactiveTabsAdapter.apply {
inactiveTabsInteractor = DefaultInactiveTabsInteractor(
InactiveTabsController(store, tabFilter, this)
)
}
TabsFeature(
tabsAdapter,
context.components.core.store,
store,
selectTabUseCase,
removeTabUseCase,
{ state ->
if (!FeatureFlags.inactiveTabs) {
return@TabsFeature false
}
state.isNormalTabInactive(maxActiveTime)
},
tabFilter,
{}
)
}

@ -8,7 +8,7 @@
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="12dp"
android:background="@drawable/home_list_row_background"
android:background="@drawable/card_list_row_background"
android:clickable="true"
android:clipToPadding="false"
android:elevation="@dimen/home_item_elevation"

@ -9,7 +9,7 @@
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:background="@drawable/rounded_top_corners"
android:background="@drawable/card_list_row_background"
android:clickable="false"
android:clipToPadding="false"
android:focusable="true"
@ -31,4 +31,16 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Inactive tabs" />
<ImageView
android:id="@+id/chevron"
android:layout_width="24dp"
android:layout_height="24dp"
android:rotation="180"
android:contentDescription="@string/tab_menu"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_chevron" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -63,7 +63,7 @@ class RecentTabViewDecoratorTest {
RecentTabViewDecorator.SingleTabDecoration(view)
verify { view.background = drawable }
assertEquals(R.drawable.home_list_row_background, drawableResCaptor.captured)
assertEquals(R.drawable.card_list_row_background, drawableResCaptor.captured)
} finally {
unmockkStatic(AppCompatResources::class)
}

@ -0,0 +1,22 @@
/* 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
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
class DefaultInactiveTabsInteractorTest {
@Test
fun `WHEN onHeaderClicked THEN updateCardExpansion`() {
val controller: InactiveTabsController = mockk(relaxed = true)
val interactor = DefaultInactiveTabsInteractor(controller)
interactor.onHeaderClicked(true)
verify { controller.updateCardExpansion(true) }
}
}

@ -0,0 +1,33 @@
/* 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
import android.view.LayoutInflater
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.HeaderHolder
@RunWith(FenixRobolectricTestRunner::class)
class InactiveTabViewHolderTest {
@Test
fun `HeaderHolder - WHEN clicked THEN notify the interactor`() {
val view = LayoutInflater.from(testContext).inflate(HeaderHolder.LAYOUT_ID, null)
val interactor: InactiveTabsInteractor = mockk(relaxed = true)
val viewHolder = HeaderHolder(view, interactor)
val initialActivatedState = view.isActivated
viewHolder.itemView.performClick()
verify { interactor.onHeaderClicked(any()) }
assertEquals(!initialActivatedState, view.isActivated)
}
}

@ -0,0 +1,43 @@
/* 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
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.tabstray.Tabs
import mozilla.components.concept.tabstray.TabsTray
import org.junit.Assert.assertEquals
import mozilla.components.browser.state.state.createTab as createTabState
import org.junit.Test
class InactiveTabsControllerTest {
@Test
fun `WHEN expanded THEN notify filtered card`() {
val filter: (TabSessionState) -> Boolean = { !it.content.private }
val store = BrowserStore(
BrowserState(
tabs = listOf(
createTabState("https://mozilla.org", id = "1"),
createTabState("https://firefox.com", id = "2"),
createTabState("https://getpocket.com", id = "3", private = true)
)
)
)
val tray: TabsTray = mockk(relaxed = true)
val tabsSlot = slot<Tabs>()
val controller = InactiveTabsController(store, filter, tray)
controller.updateCardExpansion(true)
verify { tray.updateTabs(capture(tabsSlot)) }
assertEquals(2, tabsSlot.captured.list.size)
assertEquals("1", tabsSlot.captured.list.first().id)
}
}
Loading…
Cancel
Save