Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi-selection: Selection mode #5623

Open
wants to merge 23 commits into
base: feature/ondrej/tab-multi-selection-menu
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ package com.duckduckgo.app.browser.tabs.adapter
import android.os.Bundle
import androidx.recyclerview.widget.DiffUtil
import com.duckduckgo.app.tabs.ui.TabSwitcherItem
import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.SelectionViewState.Mode

class TabSwitcherItemDiffCallback(old: List<TabSwitcherItem>, new: List<TabSwitcherItem>) : DiffUtil.Callback() {
class TabSwitcherItemDiffCallback(
old: List<TabSwitcherItem>,
new: List<TabSwitcherItem>,
private val oldMode: Mode = Mode.Normal,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡Something doesn't sit right with us passing in some external state when we're using the TabSwitcherItemDiffCallback to compare items and their changes.

Did you explore anything where the Tab knows what selection mode it's in so then it can reflect that in it's UI?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, yeah, that's a really good point. I'll refactor it so they this isn't necessary.

private val newMode: Mode = Mode.Normal,
) : DiffUtil.Callback() {

// keep a local copy of the lists to avoid any changes to the lists during the diffing process
private val oldList = old.toList()
Expand All @@ -42,7 +48,9 @@ class TabSwitcherItemDiffCallback(old: List<TabSwitcherItem>, new: List<TabSwitc
oldItem.tabEntity.tabPreviewFile == newItem.tabEntity.tabPreviewFile &&
oldItem.tabEntity.viewed == newItem.tabEntity.viewed &&
oldItem.tabEntity.title == newItem.tabEntity.title &&
oldItem.tabEntity.url == newItem.tabEntity.url
oldItem.tabEntity.url == newItem.tabEntity.url &&
oldItem.isSelected == newItem.isSelected &&
oldMode == newMode
}
else -> false
}
Expand Down Expand Up @@ -71,6 +79,10 @@ class TabSwitcherItemDiffCallback(old: List<TabSwitcherItem>, new: List<TabSwitc
if (oldItem.tabEntity.tabPreviewFile != newItem.tabEntity.tabPreviewFile) {
diffBundle.putString(DIFF_KEY_PREVIEW, newItem.tabEntity.tabPreviewFile)
}

if (oldItem.isSelected != newItem.isSelected || oldMode != newMode) {
diffBundle.putBoolean(DIFF_KEY_SELECTION, newItem.isSelected)
}
}
}

Expand Down Expand Up @@ -114,5 +126,6 @@ class TabSwitcherItemDiffCallback(old: List<TabSwitcherItem>, new: List<TabSwitc
const val DIFF_KEY_URL = "url"
const val DIFF_KEY_PREVIEW = "previewImage"
const val DIFF_KEY_VIEWED = "viewed"
const val DIFF_KEY_SELECTION = "selection"
}
}
47 changes: 31 additions & 16 deletions app/src/main/java/com/duckduckgo/app/tabs/ui/TabItemDecorator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,52 +23,66 @@ import android.graphics.RectF
import android.util.TypedValue
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.SelectionViewState.Mode
import com.duckduckgo.common.ui.view.toPx
import com.duckduckgo.mobile.android.R as CommonR

class TabItemDecorator(
context: Context,
var tabSwitcherItemId: String?,
tabSwitcherItemId: String?,
mode: Mode,
) : RecyclerView.ItemDecoration() {

private val borderStroke: Paint = Paint().apply {
var highlightedTabId: String? = tabSwitcherItemId
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Why do we need to hold this state? Can it not be held outside of this class? I would have imagined we would know the state of this externally to the decorator class

var selectionMode: Mode = mode

private val activeTabBorderStroke: Paint = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = BORDER_WIDTH
strokeWidth = ACTIVE_TAB_BORDER_WIDTH

val typedValue = TypedValue()
context.theme.resolveAttribute(CommonR.attr.daxColorBackgroundInverted, typedValue, true)
color = ContextCompat.getColor(context, typedValue.resourceId)
}

private val selectionBorderStroke: Paint = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = SELECTION_BORDER_WIDTH

val typedValue = TypedValue()
context.theme.resolveAttribute(CommonR.attr.daxColorAccentBlue, typedValue, true)
color = ContextCompat.getColor(context, typedValue.resourceId)
}

override fun onDrawOver(
canvas: Canvas,
recyclerView: RecyclerView,
state: RecyclerView.State,
) {
val adapter = recyclerView.adapter as TabSwitcherAdapter? ?: return

for (i in 0 until recyclerView.childCount) {
val child = recyclerView.getChildAt(i)

recyclerView.children.forEach { child ->
val positionInAdapter = recyclerView.getChildAdapterPosition(child)
adapter.getTabSwitcherItem(positionInAdapter)?.let { tabSwitcherItem ->
if (tabSwitcherItem.id == tabSwitcherItemId) {
drawSelectedTabDecoration(child, canvas)
if (selectionMode is Mode.Selection) {
if (tabSwitcherItem is TabSwitcherItem.Tab && tabSwitcherItem.isSelected) {
drawTabDecoration(child, canvas, selectionBorderStroke)
}
} else if (tabSwitcherItem.id == highlightedTabId) {
drawTabDecoration(child, canvas, activeTabBorderStroke)
}
}
}

super.onDrawOver(canvas, recyclerView, state)
}

private fun drawSelectedTabDecoration(
child: View,
c: Canvas,
) {
borderStroke.alpha = (child.alpha * 255).toInt()
c.drawRoundRect(child.getBounds(), BORDER_RADIUS, BORDER_RADIUS, borderStroke)
private fun drawTabDecoration(child: View, c: Canvas, paint: Paint) {
selectionBorderStroke.alpha = (child.alpha * 255).toInt()
c.drawRoundRect(child.getBounds(), BORDER_RADIUS, BORDER_RADIUS, paint)
}

private fun View.getBounds(): RectF {
Expand All @@ -83,7 +97,8 @@ class TabItemDecorator(

companion object {
private val BORDER_RADIUS = 12.toPx().toFloat()
private val BORDER_WIDTH = 2.toPx().toFloat()
private val ACTIVE_TAB_BORDER_WIDTH = 2.toPx().toFloat()
private val SELECTION_BORDER_WIDTH = 4.toPx().toFloat()
private val BORDER_PADDING = 3.toPx().toFloat()
}
}
135 changes: 99 additions & 36 deletions app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
Expand Down Expand Up @@ -56,7 +57,8 @@ import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType
import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.Command
import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.Command.Close
import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.Command.CloseAllTabsRequest
import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.ViewState.Mode.Selection
import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.SelectionViewState.Mode
import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.SelectionViewState.Mode.Selection
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.common.ui.DuckDuckGoActivity
import com.duckduckgo.common.ui.menu.PopupMenu
Expand Down Expand Up @@ -217,41 +219,110 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine
val swipeListener = ItemTouchHelper(tabTouchHelper)
swipeListener.attachToRecyclerView(tabsRecycler)

tabItemDecorator = TabItemDecorator(this, selectedTabId)
tabItemDecorator = TabItemDecorator(this, selectedTabId, viewModel.selectionViewState.value.mode)
tabsRecycler.addItemDecoration(tabItemDecorator)

tabsRecycler.setHasFixedSize(true)

if (tabManagerFeatureFlags.multiSelection().isEnabled()) {
tabsRecycler.addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy > 0) {
tabsFab.shrink()
} else if (dy < 0) {
tabsFab.extend()
handleFabStateUpdates()
handleSelectionModeCancellation()
}
}

private fun handleSelectionModeCancellation() {
tabsRecycler.addOnItemTouchListener(
object : RecyclerView.OnItemTouchListener {
private var lastEventAction: Int? = null
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
if (e.action == MotionEvent.ACTION_DOWN && tabsRecycler.findChildViewUnder(e.x, e.y) == null ||
e.action == MotionEvent.ACTION_MOVE
) {
lastEventAction = e.action
} else if (e.action == MotionEvent.ACTION_UP) {
if (lastEventAction == MotionEvent.ACTION_DOWN) {
viewModel.onEmptyAreaClicked()
}
lastEventAction = null
}
},
)
return false
}

override fun onTouchEvent(
rv: RecyclerView,
e: MotionEvent,
) {
// no-op
}

override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
// no-op
}
},
)
}

private fun handleFabStateUpdates() {
tabsRecycler.addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrolled(
recyclerView: RecyclerView,
dx: Int,
dy: Int,
) {
super.onScrolled(recyclerView, dx, dy)
if (dy > 0) {
tabsFab.shrink()
} else if (dy < 0) {
tabsFab.extend()
}
}
},
)
}

private fun updateToolbarTitle(mode: Mode) {
toolbar.title = if (mode is Mode.Selection) {
if (mode.selectedTabs.isEmpty()) {
getString(R.string.selectTabsMenuItem)
} else {
getString(R.string.tabSelectionTitle, mode.selectedTabs.size)
}
} else {
getString(R.string.tabActivityTitle)
}
}

private fun configureObservers() {
viewModel.tabSwitcherItems.observe(this) { tabSwitcherItems ->
if (tabManagerFeatureFlags.multiSelection().isEnabled()) {
lifecycleScope.launch {
viewModel.selectionViewState.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collectLatest {
tabsRecycler.invalidateItemDecorations()
tabsAdapter.updateSelection(it.mode)

render(tabSwitcherItems)
updateToolbarTitle(it.mode)
updateTabGridItemDecorator(it.selectedTab?.tabId)

val noTabSelected = tabSwitcherItems.none { it.id == tabItemDecorator.tabSwitcherItemId }
if (noTabSelected && tabSwitcherItems.isNotEmpty()) {
updateTabGridItemDecorator(tabSwitcherItems.last().id)
invalidateOptionsMenu()
}
}
} else {
viewModel.activeTab.observe(this) { tab ->
if (tab != null && tab.tabId != tabItemDecorator.highlightedTabId && !tab.deletable) {
updateTabGridItemDecorator(tab.tabId)
}
}
}
viewModel.activeTab.observe(this) { tab ->
if (tab != null && tab.tabId != tabItemDecorator.tabSwitcherItemId && !tab.deletable) {
updateTabGridItemDecorator(tab.tabId)

viewModel.tabSwitcherItems.observe(this) { tabSwitcherItems ->
tabsAdapter.updateData(tabSwitcherItems)

val noTabSelected = tabSwitcherItems.none { it.id == tabItemDecorator.highlightedTabId }
if (noTabSelected && tabSwitcherItems.isNotEmpty()) {
updateTabGridItemDecorator(tabSwitcherItems.last().id)
}
}

viewModel.deletableTabs.observe(this) {
if (it.isNotEmpty()) {
onDeletableTab(it.last())
Expand All @@ -264,12 +335,6 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine
}
}

lifecycleScope.launch {
viewModel.viewState.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collectLatest {
invalidateOptionsMenu()
}
}

viewModel.command.observe(this) {
processCommand(it)
}
Expand Down Expand Up @@ -342,10 +407,6 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine
}
}

private fun render(tabs: List<TabSwitcherItem>) {
tabsAdapter.updateData(tabs)
}

private fun scrollToShowCurrentTab() {
val index = tabsAdapter.getAdapterPositionForTab(selectedTabId)
if (index != -1) {
Expand Down Expand Up @@ -374,8 +435,8 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine
popupMenuItem = menu.findItem(R.id.popupMenuItem)

val popupBinding = PopupTabsMenuBinding.bind(popupMenu.contentView)
val viewState = viewModel.viewState.value
val numSelectedTabs = (viewModel.viewState.value.mode as? Selection)?.selectedTabs?.size ?: 0
val viewState = viewModel.selectionViewState.value
val numSelectedTabs = (viewModel.selectionViewState.value.mode as? Selection)?.selectedTabs?.size ?: 0

menu.createDynamicInterface(numSelectedTabs, popupBinding, binding.tabsFab, viewState.dynamicInterface)
} else {
Expand All @@ -395,6 +456,7 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine
private fun initMenuClickListeners() {
popupMenu.onMenuItemClicked(popupMenu.contentView.findViewById(R.id.newTabMenuItem)) { onNewTabRequested(fromOverflowMenu = true) }
popupMenu.onMenuItemClicked(popupMenu.contentView.findViewById(R.id.selectAllMenuItem)) { viewModel.onSelectAllTabs() }
popupMenu.onMenuItemClicked(popupMenu.contentView.findViewById(R.id.deselectAllMenuItem)) { viewModel.onDeselectAllTabs() }
popupMenu.onMenuItemClicked(popupMenu.contentView.findViewById(R.id.shareSelectedLinksMenuItem)) { viewModel.onShareSelectedTabs() }
popupMenu.onMenuItemClicked(popupMenu.contentView.findViewById(R.id.bookmarkSelectedTabsMenuItem)) { viewModel.onBookmarkSelectedTabs() }
popupMenu.onMenuItemClicked(popupMenu.contentView.findViewById(R.id.selectTabsMenuItem)) { viewModel.onSelectionModeRequested() }
Expand Down Expand Up @@ -435,7 +497,7 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine
duckChatMenuItem?.isVisible = duckChat.showInBrowserMenu()

return if (tabManagerFeatureFlags.multiSelection().isEnabled()) {
viewModel.viewState.value.dynamicInterface.isMoreMenuItemEnabled
viewModel.selectionViewState.value.dynamicInterface.isMoreMenuItemEnabled
} else {
super.onPrepareOptionsMenu(menu)
}
Expand Down Expand Up @@ -475,12 +537,13 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine

override fun onTabSelected(tab: TabEntity) {
selectedTabId = tab.tabId
updateTabGridItemDecorator(tab.tabId)
updateTabGridItemDecorator(selectedTabId)
launch { viewModel.onTabSelected(tab) }
}

private fun updateTabGridItemDecorator(tabSwitcherItemId: String) {
tabItemDecorator.tabSwitcherItemId = tabSwitcherItemId
private fun updateTabGridItemDecorator(tabId: String?) {
tabItemDecorator.highlightedTabId = tabId
tabItemDecorator.selectionMode = viewModel.selectionViewState.value.mode
tabsRecycler.invalidateItemDecorations()
}

Expand Down
Loading
Loading