Skip to content

Commit 0e82a54

Browse files
authored
Enable swiping on favorites (#6581)
Task/Issue URL: https://app.asana.com/1/137249556945/project/488551667048375/task/1210986778088960?focus=true ### Description - Enables swiping on favorites on the Input Screen. ### Steps to test this PR - [x] Add some favorites - [x] Go to the Input Screen - [x] Swipe on the favorites - [x] Verify that the ViewPager swipes to Duck.ai - [x] Long press on a favorite - [x] Verify that the Edit menu is shown - [x] Drag a favorite - [x] Verify that the favorite drags as expected - [x] Go to the browser - [x] Swipe between tabs - [x] Verify that tabs swipe correctly - [x] On a new tab, drag on favorites - [x] Verify that the tabs swipe correctly _Feature flag disabled_ - [x] In feature flag inventory, disable “favoritesSwipeHandling” - [x] Verify that you can no longer swipe on favorites - [x] Verify that swiping tabs still works
1 parent 8f04621 commit 0e82a54

File tree

4 files changed

+191
-2
lines changed

4 files changed

+191
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.savedsites.impl.newtab
18+
19+
import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
20+
import com.duckduckgo.di.scopes.AppScope
21+
import com.duckduckgo.feature.toggles.api.Toggle
22+
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue
23+
24+
@ContributesRemoteFeature(
25+
scope = AppScope::class,
26+
featureName = "favoritesSwipeHandling",
27+
)
28+
interface FavoritesSwipeHandling {
29+
/**
30+
* Kill switch for intercepting touch events in Favorites section to allow swiping
31+
* If disabled remotely, the interception will not be requested
32+
*/
33+
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
34+
fun self(): Toggle
35+
}

saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionView.kt

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import android.util.AttributeSet
2424
import android.view.LayoutInflater
2525
import android.view.MotionEvent
2626
import android.view.View
27+
import android.view.ViewConfiguration
2728
import android.widget.LinearLayout
2829
import android.widget.PopupWindow
2930
import androidx.core.text.HtmlCompat
@@ -77,6 +78,7 @@ import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Co
7778
import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Command.DeleteSavedSiteConfirmation
7879
import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Command.ShowEditSavedSiteDialog
7980
import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.SavedSiteChangedViewState
81+
import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.SwipeDecision
8082
import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.ViewState
8183
import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionsAdapter.Companion.QUICK_ACCESS_GRID_MAX_COLUMNS
8284
import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionsAdapter.Companion.QUICK_ACCESS_ITEM_MAX_SIZE_DP
@@ -110,6 +112,9 @@ class FavouritesNewTabSectionView @JvmOverloads constructor(
110112
@Inject
111113
lateinit var swipingTabsFeature: SwipingTabsFeatureProvider
112114

115+
@Inject
116+
lateinit var favoritesSwipeHandling: FavoritesSwipeHandling
117+
113118
private var isExpandable = true
114119
private var showPlaceholders = false
115120
private var placement: FavoritesPlacement = FavoritesPlacement.NEW_TAB_PAGE
@@ -134,6 +139,14 @@ class FavouritesNewTabSectionView @JvmOverloads constructor(
134139
private val conflatedStateJob = ConflatedJob()
135140
private val conflatedCommandJob = ConflatedJob()
136141

142+
private val touchSlop: Int by lazy { ViewConfiguration.get(context).scaledTouchSlop }
143+
private val longPressTimeoutMs: Long by lazy { ViewConfiguration.getLongPressTimeout().toLong() }
144+
145+
private val longPressRunnable = Runnable {
146+
viewModel.onLongPressTriggered()
147+
parent?.requestDisallowInterceptTouchEvent(true)
148+
}
149+
137150
init {
138151
context.obtainStyledAttributes(
139152
attrs,
@@ -186,8 +199,37 @@ class FavouritesNewTabSectionView @JvmOverloads constructor(
186199
}
187200

188201
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
189-
if (swipingTabsFeature.isEnabled) {
190-
parent.requestDisallowInterceptTouchEvent(true)
202+
if (favoritesSwipeHandling.self().isEnabled()) {
203+
when (ev?.actionMasked) {
204+
MotionEvent.ACTION_DOWN -> {
205+
viewModel.onTouchDown(ev.x, ev.y)
206+
removeCallbacks(longPressRunnable)
207+
postDelayed(longPressRunnable, longPressTimeoutMs)
208+
parent?.requestDisallowInterceptTouchEvent(false)
209+
}
210+
211+
MotionEvent.ACTION_MOVE -> {
212+
if (viewModel.isLongPressActive()) {
213+
parent?.requestDisallowInterceptTouchEvent(true)
214+
} else {
215+
viewModel.onTouchMove(ev.x, ev.y, touchSlop)?.let { decision ->
216+
when (decision) {
217+
SwipeDecision.CANCEL_LONG_PRESS -> removeCallbacks(longPressRunnable)
218+
SwipeDecision.HORIZONTAL -> parent?.requestDisallowInterceptTouchEvent(false)
219+
SwipeDecision.VERTICAL -> parent?.requestDisallowInterceptTouchEvent(true)
220+
}
221+
}
222+
}
223+
}
224+
225+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
226+
removeCallbacks(longPressRunnable)
227+
viewModel.onTouchUp()
228+
parent?.requestDisallowInterceptTouchEvent(false)
229+
}
230+
}
231+
} else if (swipingTabsFeature.isEnabled) {
232+
parent?.requestDisallowInterceptTouchEvent(true)
191233
}
192234
return super.dispatchTouchEvent(ev)
193235
}

saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionViewModel.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Co
4141
import com.duckduckgo.sync.api.engine.SyncEngine
4242
import com.duckduckgo.sync.api.engine.SyncEngine.SyncTrigger.FEATURE_READ
4343
import javax.inject.Inject
44+
import kotlin.math.abs
4445
import kotlinx.coroutines.channels.BufferOverflow
4546
import kotlinx.coroutines.channels.Channel
4647
import kotlinx.coroutines.flow.Flow
@@ -89,6 +90,12 @@ class FavouritesNewTabSectionViewModel @Inject constructor(
8990
private val command = Channel<Command>(1, BufferOverflow.DROP_OLDEST)
9091
internal fun commands(): Flow<Command> = command.receiveAsFlow()
9192

93+
enum class SwipeDecision { HORIZONTAL, VERTICAL, CANCEL_LONG_PRESS }
94+
95+
private var initialTouchX = 0f
96+
private var initialTouchY = 0f
97+
private var longPressActivated = false
98+
9299
override fun onResume(owner: LifecycleOwner) {
93100
super.onResume(owner)
94101

@@ -287,4 +294,32 @@ class FavouritesNewTabSectionViewModel @Inject constructor(
287294
): String {
288295
return pixelName.pixelName + "_" + placement.name.lowercase()
289296
}
297+
298+
fun onTouchDown(x: Float, y: Float) {
299+
initialTouchX = x
300+
initialTouchY = y
301+
longPressActivated = false
302+
}
303+
304+
fun onTouchUp() {
305+
longPressActivated = false
306+
}
307+
308+
fun onTouchMove(x: Float, y: Float, touchSlop: Int): SwipeDecision? {
309+
val dx = abs(x - initialTouchX)
310+
val dy = abs(y - initialTouchY)
311+
312+
return when {
313+
dx > dy && dx > touchSlop -> SwipeDecision.HORIZONTAL
314+
dy > dx && dy > touchSlop -> SwipeDecision.VERTICAL
315+
dx > touchSlop || dy > touchSlop -> SwipeDecision.CANCEL_LONG_PRESS
316+
else -> null
317+
}
318+
}
319+
320+
fun onLongPressTriggered() {
321+
longPressActivated = true
322+
}
323+
324+
fun isLongPressActive(): Boolean = longPressActivated
290325
}

saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/newtab/FavouritesNewTabSectionViewModelTests.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@ import com.duckduckgo.savedsites.api.models.SavedSite.Favorite
2626
import com.duckduckgo.savedsites.impl.SavedSitesPixelName
2727
import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Command.DeleteFavoriteConfirmation
2828
import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.Command.ShowEditSavedSiteDialog
29+
import com.duckduckgo.savedsites.impl.newtab.FavouritesNewTabSectionViewModel.SwipeDecision
2930
import com.duckduckgo.sync.api.engine.SyncEngine
3031
import com.duckduckgo.sync.api.engine.SyncEngine.SyncTrigger.FEATURE_READ
3132
import kotlinx.coroutines.flow.flowOf
3233
import kotlinx.coroutines.test.runTest
34+
import org.junit.Assert.assertEquals
3335
import org.junit.Assert.assertFalse
36+
import org.junit.Assert.assertNull
3437
import org.junit.Assert.assertTrue
3538
import org.junit.Before
3639
import org.junit.Rule
@@ -201,4 +204,78 @@ class FavouritesNewTabSectionViewModelTests {
201204

202205
verify(pixel).fire(SavedSitesPixelName.FAVOURITES_LIST_COLLAPSED)
203206
}
207+
208+
@Test
209+
fun `when onLongPressTriggered then isLongPressActive returns true`() {
210+
assertFalse(testee.isLongPressActive())
211+
212+
testee.onLongPressTriggered()
213+
214+
assertTrue(testee.isLongPressActive())
215+
}
216+
217+
@Test
218+
fun `when onTouchDown then isLongPressActive returns false`() {
219+
testee.onLongPressTriggered()
220+
assertTrue(testee.isLongPressActive())
221+
222+
testee.onTouchDown(0f, 0f)
223+
224+
assertFalse(testee.isLongPressActive())
225+
}
226+
227+
@Test
228+
fun `when onTouchUp then isLongPressActive returns false`() {
229+
testee.onLongPressTriggered()
230+
assertTrue(testee.isLongPressActive())
231+
232+
testee.onTouchUp()
233+
234+
assertFalse(testee.isLongPressActive())
235+
}
236+
237+
@Test
238+
fun `when onTouchMove and dx greater than dy and touchSlop then return HORIZONTAL swipe`() {
239+
testee.onTouchDown(0f, 0f)
240+
241+
val decision = testee.onTouchMove(x = 3f, y = 1f, touchSlop = 2)
242+
243+
assertEquals(SwipeDecision.HORIZONTAL, decision)
244+
}
245+
246+
@Test
247+
fun `when onTouchMove and dy greater than dx and touchSlop then return VERTICAL swipe`() {
248+
testee.onTouchDown(0f, 0f)
249+
250+
val decision = testee.onTouchMove(x = 1f, y = 3f, touchSlop = 2)
251+
252+
assertEquals(SwipeDecision.VERTICAL, decision)
253+
}
254+
255+
@Test
256+
fun `when onTouchMove and dx and dy are equal and greater than touchSlop then return CANCEL_LONG_PRESS`() {
257+
testee.onTouchDown(0f, 0f)
258+
259+
val decision = testee.onTouchMove(x = 2f, y = 2f, touchSlop = 1)
260+
261+
assertEquals(SwipeDecision.CANCEL_LONG_PRESS, decision)
262+
}
263+
264+
@Test
265+
fun `when onTouchMove and dx and dy are less than touchSlop then return null`() {
266+
testee.onTouchDown(0f, 0f)
267+
268+
val decision = testee.onTouchMove(x = 1f, y = 1f, touchSlop = 2)
269+
270+
assertNull(decision)
271+
}
272+
273+
@Test
274+
fun `when onTouchMove and dx and dy and touchSlop are equal then return null`() {
275+
testee.onTouchDown(0f, 0f)
276+
277+
val decision = testee.onTouchMove(x = 1f, y = 1f, touchSlop = 1)
278+
279+
assertNull(decision)
280+
}
204281
}

0 commit comments

Comments
 (0)