From 239e3de5e9ed7f04593aed89fc24b4f30b80a8ff Mon Sep 17 00:00:00 2001 From: Jonathan Almeida Date: Mon, 27 Jul 2020 21:55:09 -0400 Subject: [PATCH] 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. --- .../fenix/migration/MigrationStatusAdapter.kt | 2 +- .../tabtray/SaveToCollectionsButtonAdapter.kt | 67 +++++++ .../org/mozilla/fenix/tabtray/TabTrayView.kt | 17 +- .../mozilla/fenix/tabtray/TabsTouchHelper.kt | 168 +++++++++++------- .../tabs_tray_save_to_collections_item.xml | 11 ++ .../SaveToCollectionsButtonAdapterTest.kt | 53 ++++++ .../fenix/tabtray/TabsTouchHelperTest.kt | 55 ++++++ buildSrc/src/main/java/Dependencies.kt | 2 +- 8 files changed, 305 insertions(+), 70 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/tabtray/SaveToCollectionsButtonAdapter.kt create mode 100644 app/src/main/res/layout/tabs_tray_save_to_collections_item.xml create mode 100644 app/src/test/java/org/mozilla/fenix/tabtray/SaveToCollectionsButtonAdapterTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/tabtray/TabsTouchHelperTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt b/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt index 39ddc3405..34f55e59c 100644 --- a/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/migration/MigrationStatusAdapter.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/SaveToCollectionsButtonAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabtray/SaveToCollectionsButtonAdapter.kt new file mode 100644 index 000000000..d9bce5f3b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabtray/SaveToCollectionsButtonAdapter.kt @@ -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(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() { + 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 + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt index ee5d13bef..a3ccf9ed9 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt @@ -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) } } diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabsTouchHelper.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabsTouchHelper.kt index ceecc6578..03fe38d43 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabsTouchHelper.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabsTouchHelper.kt @@ -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) : - 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, + 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, + 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) + } +} diff --git a/app/src/main/res/layout/tabs_tray_save_to_collections_item.xml b/app/src/main/res/layout/tabs_tray_save_to_collections_item.xml new file mode 100644 index 000000000..25ecc2057 --- /dev/null +++ b/app/src/main/res/layout/tabs_tray_save_to_collections_item.xml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/app/src/test/java/org/mozilla/fenix/tabtray/SaveToCollectionsButtonAdapterTest.kt b/app/src/test/java/org/mozilla/fenix/tabtray/SaveToCollectionsButtonAdapterTest.kt new file mode 100644 index 000000000..ba1f71d76 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabtray/SaveToCollectionsButtonAdapterTest.kt @@ -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())) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabtray/TabsTouchHelperTest.kt b/app/src/test/java/org/mozilla/fenix/tabtray/TabsTouchHelperTest.kt new file mode 100644 index 000000000..317e3085d --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabtray/TabsTouchHelperTest.kt @@ -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) + } +} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index c9c8c6261..043375927 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -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"