Close #18774: Migrate mutli-selection to store
Removes the recyclerview-selection library and replaces it with the SelectionHolder/SelectionInteractor with a Store. This is an implementation that's similar to what we have in other UI lists (library).upstream-sync
parent
499aa858b2
commit
9078139e40
@ -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
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor
|
||||
|
||||
/**
|
||||
* Default tabs tray dialog implementation for overriding the default on back pressed.
|
||||
*/
|
||||
class TabsTrayDialog(
|
||||
context: Context,
|
||||
theme: Int,
|
||||
private val interactor: () -> BrowserTrayInteractor
|
||||
) : Dialog(context, theme) {
|
||||
override fun onBackPressed() {
|
||||
if (interactor.invoke().onBackPressed()) {
|
||||
return
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
/* 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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
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.lib.state.ext.flowScoped
|
||||
import mozilla.components.support.base.feature.LifecycleAwareFeature
|
||||
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
|
||||
import org.mozilla.fenix.tabstray.TabsTrayState.Mode
|
||||
import org.mozilla.fenix.tabstray.TabsTrayStore
|
||||
|
||||
/**
|
||||
* Notifies the adapter when the selection mode changes.
|
||||
*/
|
||||
class SelectedItemAdapterBinding(
|
||||
val store: TabsTrayStore,
|
||||
val adapter: BrowserTabsAdapter
|
||||
) : LifecycleAwareFeature {
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun start() {
|
||||
scope = store.flowScoped { flow ->
|
||||
flow.map { it.mode }
|
||||
// ignore initial mode update; the adapter is already in an updated state.
|
||||
.drop(1)
|
||||
.ifChanged()
|
||||
.collect { mode ->
|
||||
notifyAdapter(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
scope?.cancel()
|
||||
}
|
||||
|
||||
private fun notifyAdapter(mode: Mode) = with(adapter) {
|
||||
if (mode == Mode.Normal) {
|
||||
notifyItemRangeChanged(0, itemCount, PAYLOAD_HIGHLIGHT_SELECTED_ITEM)
|
||||
} else {
|
||||
notifyItemRangeChanged(0, itemCount, PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
/* 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.util.LruCache
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import mozilla.components.concept.tabstray.Tab
|
||||
|
||||
internal const val INITIAL_NUMBER_OF_TABS = 20
|
||||
internal const val CACHE_SIZE_MULTIPLIER = 1.5
|
||||
|
||||
/**
|
||||
* Storage for Browser tabs that need a stable ID for each item in a [RecyclerView.Adapter].
|
||||
* This ID is commonly needed by [RecyclerView.Adapter.getItemId] when
|
||||
* enabling [RecyclerView.Adapter.setHasStableIds].
|
||||
*/
|
||||
internal class TabAdapterIdStorage(initialSize: Int = INITIAL_NUMBER_OF_TABS) {
|
||||
private val uniqueTabIds = LruCache<String, Long>(initialSize)
|
||||
private var lastUsedSuggestionId = 0L
|
||||
|
||||
/**
|
||||
* Returns a unique tab ID for the given [Tab].
|
||||
*/
|
||||
fun getStableId(tab: Tab): Long {
|
||||
val key = tab.id
|
||||
return uniqueTabIds[key] ?: run {
|
||||
lastUsedSuggestionId += 1
|
||||
uniqueTabIds.put(key, lastUsedSuggestionId)
|
||||
lastUsedSuggestionId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes the internal cache size if the [count] is larger than what is currently available.
|
||||
*/
|
||||
fun resizeCacheIfNeeded(count: Int) {
|
||||
val currentMaxSize = uniqueTabIds.maxSize()
|
||||
if (count > currentMaxSize) {
|
||||
val newMaxSize = (count * CACHE_SIZE_MULTIPLIER).toInt()
|
||||
uniqueTabIds.resize(newMaxSize)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
/* 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.MotionEvent
|
||||
import androidx.recyclerview.selection.ItemDetailsLookup
|
||||
import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.mozilla.fenix.tabstray.TabsTrayViewHolder
|
||||
|
||||
/**
|
||||
* An [ItemDetailsLookup] for retrieving the [ItemDetails] of a [TabsTrayViewHolder].
|
||||
*/
|
||||
class TabsDetailsLookup(
|
||||
private val recyclerView: RecyclerView
|
||||
) : ItemDetailsLookup<Long>() {
|
||||
|
||||
override fun getItemDetails(event: MotionEvent): ItemDetails<Long>? {
|
||||
val view = recyclerView.findChildViewUnder(event.x, event.y)
|
||||
if (view != null) {
|
||||
val viewHolder = recyclerView.getChildViewHolder(view) as TabsTrayViewHolder
|
||||
return viewHolder.getItemDetails()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
/* 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 androidx.recyclerview.selection.ItemKeyProvider
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* A key provider for the browser tabs.
|
||||
*/
|
||||
class TabsItemKeyProvider(private val recyclerView: RecyclerView) :
|
||||
ItemKeyProvider<Long>(SCOPE_MAPPED) {
|
||||
|
||||
override fun getKey(position: Int): Long? {
|
||||
return recyclerView.adapter?.getItemId(position)
|
||||
}
|
||||
|
||||
override fun getPosition(key: Long): Int {
|
||||
val viewHolder = recyclerView.findViewHolderForItemId(key)
|
||||
return viewHolder?.layoutPosition ?: RecyclerView.NO_POSITION
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
/* 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 android.content.Context
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Test
|
||||
import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor
|
||||
|
||||
class TabsTrayDialogTest {
|
||||
@Test
|
||||
fun `WHEN onBackPressed THEN invoke interactor`() {
|
||||
val context = mockk<Context>(relaxed = true)
|
||||
val interactor = mockk<BrowserTrayInteractor>(relaxed = true)
|
||||
val dialog = TabsTrayDialog(context, 0) { interactor }
|
||||
|
||||
dialog.onBackPressed()
|
||||
|
||||
verify { interactor.onBackPressed() }
|
||||
}
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
/* 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.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
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.concept.tabstray.Tab
|
||||
import mozilla.components.concept.tabstray.Tabs
|
||||
import mozilla.components.support.test.robolectric.testContext
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
import org.mozilla.fenix.selection.SelectionHolder
|
||||
import org.mozilla.fenix.tabstray.TabsTrayStore
|
||||
import org.mozilla.fenix.tabstray.TabsTrayViewHolder
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class BrowserTabsAdapterTest {
|
||||
|
||||
private val context = testContext
|
||||
private val interactor = mockk<BrowserTrayInteractor>(relaxed = true)
|
||||
private val store = TabsTrayStore()
|
||||
|
||||
@Test
|
||||
fun `WHEN bind with payloads is called THEN update the holder`() {
|
||||
val adapter = BrowserTabsAdapter(context, interactor, store)
|
||||
val holder = mockk<TabsTrayViewHolder>(relaxed = true)
|
||||
|
||||
adapter.updateTabs(Tabs(
|
||||
list = listOf(
|
||||
createTab("tab1")
|
||||
),
|
||||
selectedIndex = 0
|
||||
))
|
||||
|
||||
adapter.onBindViewHolder(holder, 0, listOf(PAYLOAD_HIGHLIGHT_SELECTED_ITEM))
|
||||
|
||||
verify { holder.updateSelectedTabIndicator(true) }
|
||||
|
||||
adapter.onBindViewHolder(holder, 0, listOf(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM))
|
||||
|
||||
verify { holder.updateSelectedTabIndicator(false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN the selection holder is set THEN update the selected tab`() {
|
||||
val adapter = BrowserTabsAdapter(context, interactor, store)
|
||||
val holder = mockk<TabsTrayViewHolder>(relaxed = true)
|
||||
val tab = createTab("tab1")
|
||||
|
||||
every { holder.tab }.answers { tab }
|
||||
testSelectionHolder.internalState.add(tab)
|
||||
adapter.selectionHolder = testSelectionHolder
|
||||
|
||||
adapter.updateTabs(Tabs(
|
||||
list = listOf(
|
||||
tab
|
||||
),
|
||||
selectedIndex = 0
|
||||
))
|
||||
|
||||
adapter.onBindViewHolder(holder, 0, listOf(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM))
|
||||
|
||||
verify { holder.showTabIsMultiSelectEnabled(true) }
|
||||
}
|
||||
|
||||
private val testSelectionHolder = object : SelectionHolder<Tab> {
|
||||
override val selectedItems: Set<Tab>
|
||||
get() = internalState
|
||||
|
||||
val internalState = mutableSetOf<Tab>()
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
/* 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.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||
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.support.test.libstate.ext.waitUntilIdle
|
||||
import mozilla.components.support.test.rule.MainCoroutineRule
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.mozilla.fenix.tabstray.TabsTrayAction
|
||||
import org.mozilla.fenix.tabstray.TabsTrayStore
|
||||
|
||||
class SelectedItemAdapterBindingTest {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@get:Rule
|
||||
val coroutinesTestRule = MainCoroutineRule(TestCoroutineDispatcher())
|
||||
|
||||
private val adapter = mockk<BrowserTabsAdapter>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
every { adapter.itemCount }.answers { 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN observing on start THEN ignore the initial state update`() {
|
||||
val store = TabsTrayStore()
|
||||
val binding = SelectedItemAdapterBinding(store, adapter)
|
||||
|
||||
binding.start()
|
||||
|
||||
verify(exactly = 0) {
|
||||
adapter.notifyItemRangeChanged(any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN mode changes THEN notify the adapter`() {
|
||||
val store = TabsTrayStore()
|
||||
val binding = SelectedItemAdapterBinding(store, adapter)
|
||||
|
||||
binding.start()
|
||||
|
||||
store.dispatch(TabsTrayAction.EnterSelectMode)
|
||||
|
||||
store.waitUntilIdle()
|
||||
|
||||
verify {
|
||||
adapter.notifyItemRangeChanged(eq(0), eq(1), eq(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM))
|
||||
}
|
||||
|
||||
store.dispatch(TabsTrayAction.ExitSelectMode)
|
||||
|
||||
store.waitUntilIdle()
|
||||
|
||||
verify {
|
||||
adapter.notifyItemRangeChanged(eq(0), eq(1), eq(PAYLOAD_HIGHLIGHT_SELECTED_ITEM))
|
||||
}
|
||||
}
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
/* 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 org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class TabAdapterIdStorageTest {
|
||||
|
||||
@Test
|
||||
fun `the same ID is returned when queried multiple times`() {
|
||||
val storage = TabAdapterIdStorage()
|
||||
val tab = createTab()
|
||||
|
||||
val id1 = storage.getStableId(tab)
|
||||
val id2 = storage.getStableId(tab)
|
||||
|
||||
assertEquals(id1, id2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same ID is returned when the cache is at max`() {
|
||||
val storage = TabAdapterIdStorage(2)
|
||||
val tab1 = createTab()
|
||||
val tab2 = createTab()
|
||||
|
||||
val id1 = storage.getStableId(tab1)
|
||||
val id2 = storage.getStableId(tab2)
|
||||
val id1Again = storage.getStableId(tab1)
|
||||
|
||||
assertEquals(id1, id1Again)
|
||||
assertNotEquals(id1, id2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same ID is NOT returned if the cache is over max`() {
|
||||
val storage = TabAdapterIdStorage(2)
|
||||
val tab1 = createTab()
|
||||
val tab2 = createTab()
|
||||
val tab3 = createTab()
|
||||
|
||||
val id1 = storage.getStableId(tab1)
|
||||
val id2 = storage.getStableId(tab2)
|
||||
val id3 = storage.getStableId(tab3)
|
||||
val id1Again = storage.getStableId(tab1)
|
||||
|
||||
assertNotEquals(id1, id1Again)
|
||||
assertNotEquals(id1, id2)
|
||||
assertNotEquals(id1, id3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same ID is returned if the cache is resized when full`() {
|
||||
val storage = TabAdapterIdStorage(2)
|
||||
val tab1 = createTab()
|
||||
val tab2 = createTab()
|
||||
val tab3 = createTab()
|
||||
|
||||
val id1 = storage.getStableId(tab1)
|
||||
val id2 = storage.getStableId(tab2)
|
||||
|
||||
storage.resizeCacheIfNeeded(3)
|
||||
|
||||
val id3 = storage.getStableId(tab3)
|
||||
val id1Again = storage.getStableId(tab1)
|
||||
|
||||
assertEquals(id1, id1Again)
|
||||
assertNotEquals(id1, id2)
|
||||
assertNotEquals(id1, id3)
|
||||
assertNotEquals(id2, id3)
|
||||
}
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
/* 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.View
|
||||
import android.view.ViewGroup
|
||||
import mozilla.components.browser.tabstray.TabViewHolder
|
||||
import mozilla.components.browser.tabstray.TabsTrayStyling
|
||||
import mozilla.components.concept.tabstray.Tab
|
||||
import mozilla.components.concept.tabstray.Tabs
|
||||
import mozilla.components.concept.tabstray.TabsTray
|
||||
import mozilla.components.support.base.observer.Observable
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class TabsAdapterTest {
|
||||
|
||||
lateinit var adapter: TabsAdapter<TestTabsAdapter.ViewHolder>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
adapter = TestTabsAdapter()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getItemId gives a new ID for each position`() {
|
||||
val (tab1, tab2, tab3) = Triple(createTab(), createTab(), createTab())
|
||||
val tabs = Tabs(
|
||||
list = listOf(tab1, tab2, tab3),
|
||||
selectedIndex = 0
|
||||
)
|
||||
|
||||
adapter.updateTabs(tabs)
|
||||
|
||||
val id1 = adapter.getItemId(0)
|
||||
val id2 = adapter.getItemId(1)
|
||||
val id3 = adapter.getItemId(2)
|
||||
val id1Again = adapter.getItemId(0)
|
||||
|
||||
assertEquals(id1, id1Again)
|
||||
assertNotEquals(id1, id2)
|
||||
assertNotEquals(id1, id3)
|
||||
assertNotEquals(id2, id3)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun `getItemId throws if a tab does not exist for the position`() {
|
||||
adapter.getItemId(4)
|
||||
}
|
||||
|
||||
class TestTabsAdapter : TabsAdapter<TestTabsAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(view: View) : TabViewHolder(view) {
|
||||
override var tab: Tab? = null
|
||||
|
||||
override fun bind(
|
||||
tab: Tab,
|
||||
isSelected: Boolean,
|
||||
styling: TabsTrayStyling,
|
||||
observable: Observable<TabsTray.Observer>
|
||||
) = throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): ViewHolder = throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue