From 105b0e171f7a77824d0923e8fc1d14682bd0f290 Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Sun, 22 Mar 2026 23:34:54 -0500 Subject: [PATCH 01/10] feat: close app list by swiping down at scroll boundary --- .../launcher/ui/list/apps/ListFragmentApps.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt index b029da0c..39282a00 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt @@ -5,10 +5,14 @@ import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration import android.os.Bundle +import android.view.GestureDetector import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import android.view.ViewGroup import android.widget.Toast +import androidx.core.view.GestureDetectorCompat import androidx.fragment.app.Fragment import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -121,6 +125,39 @@ class ListFragmentApps : Fragment(), UIObject { } } + // Dismiss the app list by swiping down when already at the scroll boundary. + // Standard layout: boundary is the top (canScrollVertically(-1) == false). + // Reversed layout: boundary is the visual bottom (canScrollVertically(1) == false). + val minFlingVelocity = ViewConfiguration.get(requireContext()).scaledMinimumFlingVelocity + val dismissDetector = GestureDetectorCompat( + requireContext(), + object : GestureDetector.SimpleOnGestureListener() { + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + val atBoundary = if (LauncherPreferences.list().reverseLayout()) + !binding.listAppsRview.canScrollVertically(1) + else + !binding.listAppsRview.canScrollVertically(-1) + + if (atBoundary && velocityY > minFlingVelocity) { + requireActivity().finish() + return true + } + return false + } + } + ) + binding.listAppsRview.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() { + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + dismissDetector.onTouchEvent(e) + return false // never consume — RecyclerView handles scrolling normally + } + }) + binding.listAppsSearchview.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { From ce868854909bf0521bb0ffe4220dcfe2f98e2a69 Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Sun, 22 Mar 2026 23:38:08 -0500 Subject: [PATCH 02/10] fix: clear item touch listeners before re-adding dismiss listener --- .../android/launcher/ui/list/apps/ListFragmentApps.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt index 39282a00..252a0ea4 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt @@ -37,6 +37,7 @@ import kotlin.math.absoluteValue class ListFragmentApps : Fragment(), UIObject { private lateinit var binding: ListAppsBinding private lateinit var appsRecyclerAdapter: AppsRecyclerAdapter + private var dismissTouchListener: RecyclerView.OnItemTouchListener? = null private var sharedPreferencesListener = @@ -151,12 +152,14 @@ class ListFragmentApps : Fragment(), UIObject { } } ) - binding.listAppsRview.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() { + dismissTouchListener?.let { binding.listAppsRview.removeOnItemTouchListener(it) } + dismissTouchListener = object : RecyclerView.SimpleOnItemTouchListener() { override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { dismissDetector.onTouchEvent(e) return false // never consume — RecyclerView handles scrolling normally } - }) + } + binding.listAppsRview.addOnItemTouchListener(dismissTouchListener!!) binding.listAppsSearchview.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { From e8833dec419bc2a938e68435418d23792d880772 Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Sun, 22 Mar 2026 23:39:24 -0500 Subject: [PATCH 03/10] feat: close app list by swiping down on header title or close button --- .../launcher/ui/list/AppListActivity.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt index 42088e0c..b167a8a9 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt @@ -2,8 +2,12 @@ package de.jrpie.android.launcher.ui.list import android.os.Build import android.os.Bundle +import android.view.GestureDetector +import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import android.window.OnBackInvokedDispatcher +import androidx.core.view.GestureDetectorCompat import androidx.appcompat.content.res.AppCompatResources import de.jrpie.android.launcher.Application import de.jrpie.android.launcher.R @@ -117,6 +121,33 @@ class AppListActivity : AbstractListActivity() { finish() } } + + // Dismiss by swiping down on the title or ✕ button. + // The settings gear (list_settings) is intentionally excluded. + val minFlingVelocity = ViewConfiguration.get(this).scaledMinimumFlingVelocity + val dismissDetector = GestureDetectorCompat( + this, + object : GestureDetector.SimpleOnGestureListener() { + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (velocityY > minFlingVelocity) { + finish() + return true + } + return false + } + } + ) + val dismissTouchListener = View.OnTouchListener { _, event -> + dismissDetector.onTouchEvent(event) + false // don't consume — click listener on list_close still fires normally + } + binding.listHeading.setOnTouchListener(dismissTouchListener) + binding.listClose.setOnTouchListener(dismissTouchListener) } override fun adjustLayout() { From d4a96e9940b52c645cbb3fca1215287d4a6b9aee Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Sun, 22 Mar 2026 23:44:03 -0500 Subject: [PATCH 04/10] fix: replace deprecated GestureDetectorCompat with GestureDetector --- .../java/de/jrpie/android/launcher/ui/list/AppListActivity.kt | 3 +-- .../de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt index b167a8a9..23199846 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt @@ -7,7 +7,6 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.window.OnBackInvokedDispatcher -import androidx.core.view.GestureDetectorCompat import androidx.appcompat.content.res.AppCompatResources import de.jrpie.android.launcher.Application import de.jrpie.android.launcher.R @@ -125,7 +124,7 @@ class AppListActivity : AbstractListActivity() { // Dismiss by swiping down on the title or ✕ button. // The settings gear (list_settings) is intentionally excluded. val minFlingVelocity = ViewConfiguration.get(this).scaledMinimumFlingVelocity - val dismissDetector = GestureDetectorCompat( + val dismissDetector = GestureDetector( this, object : GestureDetector.SimpleOnGestureListener() { override fun onFling( diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt index 252a0ea4..3e0240b9 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt @@ -12,7 +12,6 @@ import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import android.widget.Toast -import androidx.core.view.GestureDetectorCompat import androidx.fragment.app.Fragment import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -130,7 +129,7 @@ class ListFragmentApps : Fragment(), UIObject { // Standard layout: boundary is the top (canScrollVertically(-1) == false). // Reversed layout: boundary is the visual bottom (canScrollVertically(1) == false). val minFlingVelocity = ViewConfiguration.get(requireContext()).scaledMinimumFlingVelocity - val dismissDetector = GestureDetectorCompat( + val dismissDetector = GestureDetector( requireContext(), object : GestureDetector.SimpleOnGestureListener() { override fun onFling( From eeaef83d2a77762f2ba56adbbdd2f75317723d32 Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Mon, 23 Mar 2026 00:04:45 -0500 Subject: [PATCH 05/10] fix: detect dismiss gesture via scroll accumulation at boundary --- .../launcher/ui/list/apps/ListFragmentApps.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt index 3e0240b9..a36a15f8 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt @@ -129,9 +129,40 @@ class ListFragmentApps : Fragment(), UIObject { // Standard layout: boundary is the top (canScrollVertically(-1) == false). // Reversed layout: boundary is the visual bottom (canScrollVertically(1) == false). val minFlingVelocity = ViewConfiguration.get(requireContext()).scaledMinimumFlingVelocity + val dismissThresholdPx = (40 * resources.displayMetrics.density).toInt() + var overscrollDistance = 0f val dismissDetector = GestureDetector( requireContext(), object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent): Boolean { + overscrollDistance = 0f + return false + } + + override fun onScroll( + e1: MotionEvent?, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + val atBoundary = if (LauncherPreferences.list().reverseLayout()) + !binding.listAppsRview.canScrollVertically(1) + else + !binding.listAppsRview.canScrollVertically(-1) + + if (atBoundary && distanceY < 0) { + // distanceY < 0 means finger is moving down + overscrollDistance -= distanceY + if (overscrollDistance > dismissThresholdPx) { + requireActivity().finish() + return true + } + } else { + overscrollDistance = 0f + } + return false + } + override fun onFling( e1: MotionEvent?, e2: MotionEvent, From 28823e2e33031571732d64743b911c819e955dd3 Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Mon, 23 Mar 2026 00:05:05 -0500 Subject: [PATCH 06/10] fix: consume ACTION_DOWN on list heading to keep gesture sequence alive --- .../android/launcher/ui/list/AppListActivity.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt index 23199846..391c0b39 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt @@ -141,12 +141,18 @@ class AppListActivity : AbstractListActivity() { } } ) - val dismissTouchListener = View.OnTouchListener { _, event -> + // listHeading is a non-clickable TextView — must return true for ACTION_DOWN + // so Android keeps delivering ACTION_MOVE and ACTION_UP to this view. + binding.listHeading.setOnTouchListener { _, event -> dismissDetector.onTouchEvent(event) - false // don't consume — click listener on list_close still fires normally + event.actionMasked == MotionEvent.ACTION_DOWN + } + // listClose is clickable — its own onTouchEvent returns true for ACTION_DOWN, + // so the gesture sequence is already kept alive without consuming here. + binding.listClose.setOnTouchListener { _, event -> + dismissDetector.onTouchEvent(event) + false } - binding.listHeading.setOnTouchListener(dismissTouchListener) - binding.listClose.setOnTouchListener(dismissTouchListener) } override fun adjustLayout() { From 4073d89bddfbb1a783052d05402df023a84210a7 Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Mon, 23 Mar 2026 00:11:06 -0500 Subject: [PATCH 07/10] fix: use top boundary for dismiss in both standard and reversed layout --- .../launcher/ui/list/apps/ListFragmentApps.kt | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt index a36a15f8..d5032589 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt @@ -125,9 +125,10 @@ class ListFragmentApps : Fragment(), UIObject { } } - // Dismiss the app list by swiping down when already at the scroll boundary. - // Standard layout: boundary is the top (canScrollVertically(-1) == false). - // Reversed layout: boundary is the visual bottom (canScrollVertically(1) == false). + // Dismiss the app list by swiping down when already at the top scroll boundary. + // canScrollVertically(-1) == false means the list cannot scroll further upward, + // i.e. the top of the content is visible. This is the dismiss boundary for both + // standard and reversed layouts. val minFlingVelocity = ViewConfiguration.get(requireContext()).scaledMinimumFlingVelocity val dismissThresholdPx = (40 * resources.displayMetrics.density).toInt() var overscrollDistance = 0f @@ -145,10 +146,7 @@ class ListFragmentApps : Fragment(), UIObject { distanceX: Float, distanceY: Float ): Boolean { - val atBoundary = if (LauncherPreferences.list().reverseLayout()) - !binding.listAppsRview.canScrollVertically(1) - else - !binding.listAppsRview.canScrollVertically(-1) + val atBoundary = !binding.listAppsRview.canScrollVertically(-1) if (atBoundary && distanceY < 0) { // distanceY < 0 means finger is moving down @@ -169,10 +167,7 @@ class ListFragmentApps : Fragment(), UIObject { velocityX: Float, velocityY: Float ): Boolean { - val atBoundary = if (LauncherPreferences.list().reverseLayout()) - !binding.listAppsRview.canScrollVertically(1) - else - !binding.listAppsRview.canScrollVertically(-1) + val atBoundary = !binding.listAppsRview.canScrollVertically(-1) if (atBoundary && velocityY > minFlingVelocity) { requireActivity().finish() From 235210770d86ce7d228628b26dcaefc3d65da6b8 Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Mon, 23 Mar 2026 00:17:02 -0500 Subject: [PATCH 08/10] fix: prevent dismiss if gesture left scroll boundary before swiping down --- .../launcher/ui/list/apps/ListFragmentApps.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt index d5032589..2aa6b846 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt @@ -132,11 +132,15 @@ class ListFragmentApps : Fragment(), UIObject { val minFlingVelocity = ViewConfiguration.get(requireContext()).scaledMinimumFlingVelocity val dismissThresholdPx = (40 * resources.displayMetrics.density).toInt() var overscrollDistance = 0f + // Latches true if the gesture left the boundary at any point — prevents + // a swipe-up-then-back-down from accidentally dismissing. + var gestureLeftBoundary = false val dismissDetector = GestureDetector( requireContext(), object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean { overscrollDistance = 0f + gestureLeftBoundary = false return false } @@ -148,15 +152,20 @@ class ListFragmentApps : Fragment(), UIObject { ): Boolean { val atBoundary = !binding.listAppsRview.canScrollVertically(-1) - if (atBoundary && distanceY < 0) { + if (!atBoundary) { + gestureLeftBoundary = true + overscrollDistance = 0f + return false + } + if (gestureLeftBoundary) return false + + if (distanceY < 0) { // distanceY < 0 means finger is moving down overscrollDistance -= distanceY if (overscrollDistance > dismissThresholdPx) { requireActivity().finish() return true } - } else { - overscrollDistance = 0f } return false } @@ -167,6 +176,7 @@ class ListFragmentApps : Fragment(), UIObject { velocityX: Float, velocityY: Float ): Boolean { + if (gestureLeftBoundary) return false val atBoundary = !binding.listAppsRview.canScrollVertically(-1) if (atBoundary && velocityY > minFlingVelocity) { From 4c545f94966a7daeb46b14bfd67c1351b9e7b6bd Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Mon, 23 Mar 2026 00:20:33 -0500 Subject: [PATCH 09/10] refactor: remove WHAT comments and clean up dismiss listener in onDestroyView --- .../android/launcher/ui/list/apps/ListFragmentApps.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt index 2aa6b846..2f541ca6 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt @@ -71,6 +71,12 @@ class ListFragmentApps : Fragment(), UIObject { } + override fun onDestroyView() { + super.onDestroyView() + dismissTouchListener?.let { binding.listAppsRview.removeOnItemTouchListener(it) } + dismissTouchListener = null + } + override fun onStop() { super.onStop() LauncherPreferences.getSharedPreferences() @@ -126,9 +132,6 @@ class ListFragmentApps : Fragment(), UIObject { } // Dismiss the app list by swiping down when already at the top scroll boundary. - // canScrollVertically(-1) == false means the list cannot scroll further upward, - // i.e. the top of the content is visible. This is the dismiss boundary for both - // standard and reversed layouts. val minFlingVelocity = ViewConfiguration.get(requireContext()).scaledMinimumFlingVelocity val dismissThresholdPx = (40 * resources.displayMetrics.density).toInt() var overscrollDistance = 0f @@ -160,7 +163,6 @@ class ListFragmentApps : Fragment(), UIObject { if (gestureLeftBoundary) return false if (distanceY < 0) { - // distanceY < 0 means finger is moving down overscrollDistance -= distanceY if (overscrollDistance > dismissThresholdPx) { requireActivity().finish() From a220d6e6a0e16ae7940d8a9b64a234bae6da610b Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Mon, 23 Mar 2026 00:27:10 -0500 Subject: [PATCH 10/10] cleanup comments --- .../de/jrpie/android/launcher/ui/list/AppListActivity.kt | 4 ---- .../jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt index 391c0b39..4943dfe5 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/AppListActivity.kt @@ -141,14 +141,10 @@ class AppListActivity : AbstractListActivity() { } } ) - // listHeading is a non-clickable TextView — must return true for ACTION_DOWN - // so Android keeps delivering ACTION_MOVE and ACTION_UP to this view. binding.listHeading.setOnTouchListener { _, event -> dismissDetector.onTouchEvent(event) event.actionMasked == MotionEvent.ACTION_DOWN } - // listClose is clickable — its own onTouchEvent returns true for ACTION_DOWN, - // so the gesture sequence is already kept alive without consuming here. binding.listClose.setOnTouchListener { _, event -> dismissDetector.onTouchEvent(event) false diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt index 2f541ca6..72315b4f 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt @@ -135,8 +135,8 @@ class ListFragmentApps : Fragment(), UIObject { val minFlingVelocity = ViewConfiguration.get(requireContext()).scaledMinimumFlingVelocity val dismissThresholdPx = (40 * resources.displayMetrics.density).toInt() var overscrollDistance = 0f - // Latches true if the gesture left the boundary at any point — prevents - // a swipe-up-then-back-down from accidentally dismissing. + // Latches true if the gesture left the boundary at any point. + // Prevents a swipe-up-then-back-down from accidentally dismissing. var gestureLeftBoundary = false val dismissDetector = GestureDetector( requireContext(), @@ -193,7 +193,7 @@ class ListFragmentApps : Fragment(), UIObject { dismissTouchListener = object : RecyclerView.SimpleOnItemTouchListener() { override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { dismissDetector.onTouchEvent(e) - return false // never consume — RecyclerView handles scrolling normally + return false } } binding.listAppsRview.addOnItemTouchListener(dismissTouchListener!!)