1
0
Fork 0

For #12856: Add save to collections button to Tabs Tray

Using the ConcatAdapter, we're now able to insert multiple data sources
of information into one RecyclerView and preserve layout/scrolling in
addition to adding the 'Save to Collection' button.
master
Jonathan Almeida 2020-07-27 21:55:09 -04:00 committed by Jeff Boek
parent 33bb86b1aa
commit 239e3de5e9
8 changed files with 305 additions and 70 deletions

View File

@ -96,7 +96,7 @@ internal class MigrationStatusItemDecoration(
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildViewHolder(view).adapterPosition
val position = parent.getChildViewHolder(view).bindingAdapterPosition
val itemCount = state.itemCount
outRect.left = spacing

View File

@ -0,0 +1,67 @@
/* 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.tabtray
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.Item
import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.ViewHolder
/**
* An adapter to display a single 'Save to Collections' button that can be used to display between
* multiple [RecyclerView.Adapter] in one [RecyclerView].
*/
class SaveToCollectionsButtonAdapter(
private val interactor: TabTrayInteractor
) : ListAdapter<Item, ViewHolder>(DiffCallback) {
init {
submitList(listOf(Item))
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val itemView = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return ViewHolder(itemView, interactor)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) = Unit
override fun getItemViewType(position: Int): Int {
return ViewHolder.LAYOUT_ID
}
private object DiffCallback : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item) = true
override fun areContentsTheSame(oldItem: Item, newItem: Item) = true
}
/**
* An object to identify the data type.
*/
object Item
class ViewHolder(
itemView: View,
private val interactor: TabTrayInteractor
) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
init {
itemView.setOnClickListener(this)
}
override fun onClick(v: View?) {
interactor.onEnterMultiselect()
}
companion object {
const val LAYOUT_ID = R.layout.tabs_tray_save_to_collections_item
}
}
}

View File

@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.tabs.TabLayout
@ -34,6 +35,7 @@ import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.tabstray.TabViewHolder
import mozilla.components.support.ktx.android.util.dpToPx
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
@ -69,6 +71,7 @@ class TabTrayView(
private var menu: BrowserMenu? = null
private var tabsTouchHelper: TabsTouchHelper
private val collectionsButtonAdapter = SaveToCollectionsButtonAdapter(interactor)
private var hasLoaded = false
@ -131,9 +134,13 @@ class TabTrayView(
reverseLayout = true
stackFromEnd = true
}
adapter = tabsAdapter
adapter = ConcatAdapter(collectionsButtonAdapter, tabsAdapter)
tabsTouchHelper = TabsTouchHelper(
observable = tabsAdapter,
onViewHolderTouched = { it is TabViewHolder }
)
tabsTouchHelper = TabsTouchHelper(tabsAdapter)
tabsTouchHelper.attachToRecyclerView(this)
tabsAdapter.tabTrayInteractor = interactor
@ -468,7 +475,11 @@ class TabTrayView(
val selectedBrowserTabIndex = tabs
.indexOfFirst { it.id == sessionId }
layoutManager?.scrollToPosition(selectedBrowserTabIndex)
// We offset the tab index by the number of items in the other adapters.
// We add the offset, because the layoutManager is initialized with `reverseLayout`.
val recyclerViewIndex = selectedBrowserTabIndex + collectionsButtonAdapter.itemCount
layoutManager?.scrollToPosition(recyclerViewIndex)
}
}

View File

@ -8,6 +8,7 @@ import android.graphics.Canvas
import android.graphics.drawable.Drawable
import androidx.appcompat.content.res.AppCompatResources
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_IDLE
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.tabstray.TabTouchCallback
import mozilla.components.concept.tabstray.TabsTray
@ -18,72 +19,109 @@ import mozilla.components.support.ktx.android.util.dpToPx
import org.mozilla.fenix.R
import org.mozilla.fenix.home.sessioncontrol.SwipeToDeleteCallback
class TabsTouchHelper(observable: Observable<TabsTray.Observer>) :
ItemTouchHelper(object : TabTouchCallback(observable) {
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
val icon = recyclerView.context.getDrawableWithTint(
R.drawable.ic_delete,
recyclerView.context.getColorFromAttr(R.attr.destructive)
)!!
val background = AppCompatResources.getDrawable(
recyclerView.context,
R.drawable.swipe_delete_background
)!!
val itemView = viewHolder.itemView
val iconLeft: Int
val iconRight: Int
val margin =
SwipeToDeleteCallback.MARGIN.dpToPx(recyclerView.context.resources.displayMetrics)
val iconWidth = icon.intrinsicWidth
val iconHeight = icon.intrinsicHeight
val cellHeight = itemView.bottom - itemView.top
val iconTop = itemView.top + (cellHeight - iconHeight) / 2
val iconBottom = iconTop + iconHeight
/**
* A callback for consumers to know when a [RecyclerView.ViewHolder] is about to be touched.
* Return false if the default behaviour should be ignored.
*/
typealias OnViewHolderTouched = (RecyclerView.ViewHolder) -> Boolean
when {
dX > 0 -> { // Swiping to the right
iconLeft = itemView.left + margin
iconRight = itemView.left + margin + iconWidth
background.setBounds(
itemView.left, itemView.top,
(itemView.left + dX).toInt() + SwipeToDeleteCallback.BACKGROUND_CORNER_OFFSET,
itemView.bottom
)
icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
draw(background, icon, c)
}
dX < 0 -> { // Swiping to the left
iconLeft = itemView.right - margin - iconWidth
iconRight = itemView.right - margin
background.setBounds(
(itemView.right + dX).toInt() - SwipeToDeleteCallback.BACKGROUND_CORNER_OFFSET,
itemView.top, itemView.right, itemView.bottom
)
icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
draw(background, icon, c)
}
else -> { // View not swiped
background.setBounds(0, 0, 0, 0)
icon.setBounds(0, 0, 0, 0)
}
/**
* An [ItemTouchHelper] for handling tab swiping to delete.
*
* @param onViewHolderTouched See [OnViewHolderTouched].
*/
class TabsTouchHelper(
observable: Observable<TabsTray.Observer>,
onViewHolderTouched: OnViewHolderTouched = { true },
delegate: Callback = TouchCallback(observable, onViewHolderTouched)
) : ItemTouchHelper(delegate)
/**
* An [ItemTouchHelper.Callback] for drawing custom layouts on [RecyclerView.ViewHolder] interactions.
*
* @param onViewHolderTouched invoked when a tab is about to be swiped. See [OnViewHolderTouched].
*/
class TouchCallback(
observable: Observable<TabsTray.Observer>,
private val onViewHolderTouched: OnViewHolderTouched
) : TabTouchCallback(observable) {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
if (!onViewHolderTouched.invoke(viewHolder)) {
return ItemTouchHelper.Callback.makeFlag(ACTION_STATE_IDLE, 0)
}
return super.getMovementFlags(recyclerView, viewHolder)
}
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
val icon = recyclerView.context.getDrawableWithTint(
R.drawable.ic_delete,
recyclerView.context.getColorFromAttr(R.attr.destructive)
)!!
val background = AppCompatResources.getDrawable(
recyclerView.context,
R.drawable.swipe_delete_background
)!!
val itemView = viewHolder.itemView
val iconLeft: Int
val iconRight: Int
val margin =
SwipeToDeleteCallback.MARGIN.dpToPx(recyclerView.context.resources.displayMetrics)
val iconWidth = icon.intrinsicWidth
val iconHeight = icon.intrinsicHeight
val cellHeight = itemView.bottom - itemView.top
val iconTop = itemView.top + (cellHeight - iconHeight) / 2
val iconBottom = iconTop + iconHeight
when {
dX > 0 -> { // Swiping to the right
iconLeft = itemView.left + margin
iconRight = itemView.left + margin + iconWidth
background.setBounds(
itemView.left, itemView.top,
(itemView.left + dX).toInt() + SwipeToDeleteCallback.BACKGROUND_CORNER_OFFSET,
itemView.bottom
)
icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
draw(background, icon, c)
}
dX < 0 -> { // Swiping to the left
iconLeft = itemView.right - margin - iconWidth
iconRight = itemView.right - margin
background.setBounds(
(itemView.right + dX).toInt() - SwipeToDeleteCallback.BACKGROUND_CORNER_OFFSET,
itemView.top, itemView.right, itemView.bottom
)
icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
draw(background, icon, c)
}
else -> { // View not swiped
background.setBounds(0, 0, 0, 0)
icon.setBounds(0, 0, 0, 0)
}
}
}
private fun draw(
background: Drawable,
icon: Drawable,
c: Canvas
) {
background.draw(c)
icon.draw(c)
}
})
private fun draw(
background: Drawable,
icon: Drawable,
c: Canvas
) {
background.draw(c)
icon.draw(c)
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<com.google.android.material.button.MaterialButton
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/NeutralButton"
android:layout_margin="8dp"
android:text="@string/save_to_collection"
app:icon="@drawable/ic_tab_collection" />

View File

@ -0,0 +1,53 @@
/* 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.tabtray
import android.widget.FrameLayout
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.Item
import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.ViewHolder
import kotlin.random.Random
@RunWith(FenixRobolectricTestRunner::class)
class SaveToCollectionsButtonAdapterTest {
private lateinit var adapter: SaveToCollectionsButtonAdapter
private lateinit var interactor: TabTrayInteractor
@Before
fun setup() {
interactor = mockk(relaxed = true)
adapter = SaveToCollectionsButtonAdapter(interactor)
}
@Test
fun `create adapter only has one item in it`() {
assertEquals(1, adapter.itemCount)
assertTrue(adapter.currentList.first() is Item)
}
@Test
fun `viewholder click invokes interactor`() {
val itemView = FrameLayout(testContext)
val viewHolder = ViewHolder(itemView, interactor)
viewHolder.onClick(itemView)
verify { interactor.onEnterMultiselect() }
}
@Test
fun `always use the same layout`() {
assertEquals(ViewHolder.LAYOUT_ID, adapter.getItemViewType(Random.nextInt()))
}
}

View File

@ -0,0 +1,55 @@
/* 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.tabtray
import android.widget.FrameLayout
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_IDLE
import androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags
import androidx.recyclerview.widget.RecyclerView
import io.mockk.mockk
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
@RunWith(FenixRobolectricTestRunner::class)
class TabsTouchHelperTest {
@Test
fun `movement flags remain unchanged if onSwipeToDelete is true`() {
val recyclerView = RecyclerView(testContext)
val layout = FrameLayout(testContext)
val interactor: TabTrayInteractor = mockk(relaxed = true)
val viewHolder = SaveToCollectionsButtonAdapter.ViewHolder(layout, interactor)
val callback = TouchCallback(mockk()) { true }
assertEquals(0, callback.getDragDirs(recyclerView, viewHolder))
assertEquals(ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, callback.getSwipeDirs(recyclerView, viewHolder))
val actual = callback.getMovementFlags(recyclerView, viewHolder)
val expected = makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT)
assertEquals(expected, actual)
}
@Test
fun `movement flags remain unchanged if onSwipeToDelete is false`() {
val recyclerView = RecyclerView(testContext)
val layout = FrameLayout(testContext)
val interactor: TabTrayInteractor = mockk(relaxed = true)
val viewHolder = SaveToCollectionsButtonAdapter.ViewHolder(layout, interactor)
val callback = TouchCallback(mockk()) { false }
assertEquals(0, callback.getDragDirs(recyclerView, viewHolder))
assertEquals(ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, callback.getSwipeDirs(recyclerView, viewHolder))
val actual = callback.getMovementFlags(recyclerView, viewHolder)
val expected = ItemTouchHelper.Callback.makeFlag(ACTION_STATE_IDLE, 0)
assertEquals(expected, actual)
}
}

View File

@ -23,7 +23,7 @@ object Versions {
const val androidx_lifecycle = "2.2.0"
const val androidx_fragment = "1.2.5"
const val androidx_navigation = "2.3.0"
const val androidx_recyclerview = "1.1.0"
const val androidx_recyclerview = "1.2.0-alpha05"
const val androidx_core = "1.2.0"
const val androidx_paging = "2.1.0"
const val androidx_transition = "1.3.0"