diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavoritesSwipeHandling.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavoritesSwipeHandling.kt new file mode 100644 index 000000000000..15e963ec1625 --- /dev/null +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavoritesSwipeHandling.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.savedsites.impl.newtab + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "favoritesSwipeHandling", +) +interface FavoritesSwipeHandling { + /** + * Kill switch for intercepting touch events in Favorites section to allow swiping + * If disabled remotely, the interception will not be requested + */ + @Toggle.DefaultValue(DefaultFeatureValue.TRUE) + fun self(): Toggle +} diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionView.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionView.kt index dfef3c7888bf..322e3066a575 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionView.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionView.kt @@ -24,6 +24,7 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import android.widget.LinearLayout import android.widget.PopupWindow import androidx.core.text.HtmlCompat @@ -77,6 +78,7 @@ import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Co import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Command.DeleteSavedSiteConfirmation import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Command.ShowEditSavedSiteDialog import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.SavedSiteChangedViewState +import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.SwipeDecision import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.ViewState import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionsAdapter.Companion.QUICK_ACCESS_GRID_MAX_COLUMNS import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionsAdapter.Companion.QUICK_ACCESS_ITEM_MAX_SIZE_DP @@ -110,6 +112,9 @@ class FavouritesNewTabSectionView @JvmOverloads constructor( @Inject lateinit var swipingTabsFeature: SwipingTabsFeatureProvider + @Inject + lateinit var favoritesSwipeHandling: FavoritesSwipeHandling + private var isExpandable = true private var showPlaceholders = false private var placement: FavoritesPlacement = FavoritesPlacement.NEW_TAB_PAGE @@ -134,6 +139,14 @@ class FavouritesNewTabSectionView @JvmOverloads constructor( private val conflatedStateJob = ConflatedJob() private val conflatedCommandJob = ConflatedJob() + private val touchSlop: Int by lazy { ViewConfiguration.get(context).scaledTouchSlop } + private val longPressTimeoutMs: Long by lazy { ViewConfiguration.getLongPressTimeout().toLong() } + + private val longPressRunnable = Runnable { + viewModel.onLongPressTriggered() + parent?.requestDisallowInterceptTouchEvent(true) + } + init { context.obtainStyledAttributes( attrs, @@ -186,8 +199,37 @@ class FavouritesNewTabSectionView @JvmOverloads constructor( } override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { - if (swipingTabsFeature.isEnabled) { - parent.requestDisallowInterceptTouchEvent(true) + if (favoritesSwipeHandling.self().isEnabled()) { + when (ev?.actionMasked) { + MotionEvent.ACTION_DOWN -> { + viewModel.onTouchDown(ev.x, ev.y) + removeCallbacks(longPressRunnable) + postDelayed(longPressRunnable, longPressTimeoutMs) + parent?.requestDisallowInterceptTouchEvent(false) + } + + MotionEvent.ACTION_MOVE -> { + if (viewModel.isLongPressActive()) { + parent?.requestDisallowInterceptTouchEvent(true) + } else { + viewModel.onTouchMove(ev.x, ev.y, touchSlop)?.let { decision -> + when (decision) { + SwipeDecision.CANCEL_LONG_PRESS -> removeCallbacks(longPressRunnable) + SwipeDecision.HORIZONTAL -> parent?.requestDisallowInterceptTouchEvent(false) + SwipeDecision.VERTICAL -> parent?.requestDisallowInterceptTouchEvent(true) + } + } + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + removeCallbacks(longPressRunnable) + viewModel.onTouchUp() + parent?.requestDisallowInterceptTouchEvent(false) + } + } + } else if (swipingTabsFeature.isEnabled) { + parent?.requestDisallowInterceptTouchEvent(true) } return super.dispatchTouchEvent(ev) } diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionViewModel.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionViewModel.kt index f78f9d56fd96..5dd1473f21cb 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionViewModel.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionViewModel.kt @@ -41,6 +41,7 @@ import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Co import com.duckduckgo.sync.api.engine.SyncEngine import com.duckduckgo.sync.api.engine.SyncEngine.SyncTrigger.FEATURE_READ import javax.inject.Inject +import kotlin.math.abs import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -89,6 +90,12 @@ class FavouritesNewTabSectionViewModel @Inject constructor( private val command = Channel(1, BufferOverflow.DROP_OLDEST) internal fun commands(): Flow = command.receiveAsFlow() + enum class SwipeDecision { HORIZONTAL, VERTICAL, CANCEL_LONG_PRESS } + + private var initialTouchX = 0f + private var initialTouchY = 0f + private var longPressActivated = false + override fun onResume(owner: LifecycleOwner) { super.onResume(owner) @@ -287,4 +294,32 @@ class FavouritesNewTabSectionViewModel @Inject constructor( ): String { return pixelName.pixelName + "_" + placement.name.lowercase() } + + fun onTouchDown(x: Float, y: Float) { + initialTouchX = x + initialTouchY = y + longPressActivated = false + } + + fun onTouchUp() { + longPressActivated = false + } + + fun onTouchMove(x: Float, y: Float, touchSlop: Int): SwipeDecision? { + val dx = abs(x - initialTouchX) + val dy = abs(y - initialTouchY) + + return when { + dx > dy && dx > touchSlop -> SwipeDecision.HORIZONTAL + dy > dx && dy > touchSlop -> SwipeDecision.VERTICAL + dx > touchSlop || dy > touchSlop -> SwipeDecision.CANCEL_LONG_PRESS + else -> null + } + } + + fun onLongPressTriggered() { + longPressActivated = true + } + + fun isLongPressActive(): Boolean = longPressActivated } diff --git a/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionViewModelTests.kt b/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionViewModelTests.kt index 5c59a775f832..b0755bab20b2 100644 --- a/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionViewModelTests.kt +++ b/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionViewModelTests.kt @@ -26,11 +26,14 @@ import com.duckduckgo.savedsites.api.models.SavedSite.Favorite import com.duckduckgo.savedsites.impl.SavedSitesPixelName import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Command.DeleteFavoriteConfirmation import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Command.ShowEditSavedSiteDialog +import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.SwipeDecision import com.duckduckgo.sync.api.engine.SyncEngine import com.duckduckgo.sync.api.engine.SyncEngine.SyncTrigger.FEATURE_READ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -201,4 +204,78 @@ class FavouritesNewTabSectionViewModelTests { verify(pixel).fire(SavedSitesPixelName.FAVOURITES_LIST_COLLAPSED) } + + @Test + fun `when onLongPressTriggered then isLongPressActive returns true`() { + assertFalse(testee.isLongPressActive()) + + testee.onLongPressTriggered() + + assertTrue(testee.isLongPressActive()) + } + + @Test + fun `when onTouchDown then isLongPressActive returns false`() { + testee.onLongPressTriggered() + assertTrue(testee.isLongPressActive()) + + testee.onTouchDown(0f, 0f) + + assertFalse(testee.isLongPressActive()) + } + + @Test + fun `when onTouchUp then isLongPressActive returns false`() { + testee.onLongPressTriggered() + assertTrue(testee.isLongPressActive()) + + testee.onTouchUp() + + assertFalse(testee.isLongPressActive()) + } + + @Test + fun `when onTouchMove and dx greater than dy and touchSlop then return HORIZONTAL swipe`() { + testee.onTouchDown(0f, 0f) + + val decision = testee.onTouchMove(x = 3f, y = 1f, touchSlop = 2) + + assertEquals(SwipeDecision.HORIZONTAL, decision) + } + + @Test + fun `when onTouchMove and dy greater than dx and touchSlop then return VERTICAL swipe`() { + testee.onTouchDown(0f, 0f) + + val decision = testee.onTouchMove(x = 1f, y = 3f, touchSlop = 2) + + assertEquals(SwipeDecision.VERTICAL, decision) + } + + @Test + fun `when onTouchMove and dx and dy are equal and greater than touchSlop then return CANCEL_LONG_PRESS`() { + testee.onTouchDown(0f, 0f) + + val decision = testee.onTouchMove(x = 2f, y = 2f, touchSlop = 1) + + assertEquals(SwipeDecision.CANCEL_LONG_PRESS, decision) + } + + @Test + fun `when onTouchMove and dx and dy are less than touchSlop then return null`() { + testee.onTouchDown(0f, 0f) + + val decision = testee.onTouchMove(x = 1f, y = 1f, touchSlop = 2) + + assertNull(decision) + } + + @Test + fun `when onTouchMove and dx and dy and touchSlop are equal then return null`() { + testee.onTouchDown(0f, 0f) + + val decision = testee.onTouchMove(x = 1f, y = 1f, touchSlop = 1) + + assertNull(decision) + } }