From 616e72923f301ae9f457aa3efd9be9b12c0b7aba Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 23 Oct 2026 23:34:43 +0100 Subject: [PATCH] ADFA-6251: Ignore output-panel drags that start above its visible edge The editor bottom sheet is padded at the top by the status-bar height so its tabs clear the status bar when it expands to the top of the window. While collapsed, that transparent padding sits above the visible border, over the editor, and BottomSheetBehavior treats any drag inside the view's bounds as a drag on the sheet. A swipe that started up to a status bar's height above the border opened the panel. EditorBottomSheetBehavior drops a gesture whose ACTION_DOWN lands in the top padding, so the view underneath keeps it. --- .../ui/EditorBottomSheetBehavior.kt | 37 ++++++ .../main/res/layout-land/content_editor.xml | 2 +- app/src/main/res/layout/content_editor.xml | 2 +- .../ui/EditorBottomSheetBehaviorTest.kt | 116 ++++++++++++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheetBehavior.kt create mode 100644 app/src/test/java/com/itsaky/androidide/ui/EditorBottomSheetBehaviorTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheetBehavior.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheetBehavior.kt new file mode 100644 index 0000000000..a8f8e6e684 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheetBehavior.kt @@ -0,0 +1,37 @@ +package com.itsaky.androidide.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior + +class EditorBottomSheetBehavior( + context: Context, + attrs: AttributeSet?, +) : BottomSheetBehavior(context, attrs) { + private var gestureStartedInTopPadding = false + + override fun onInterceptTouchEvent( + parent: CoordinatorLayout, + child: V, + event: MotionEvent, + ): Boolean = !isGestureInTopPadding(child, event) && super.onInterceptTouchEvent(parent, child, event) + + override fun onTouchEvent( + parent: CoordinatorLayout, + child: V, + event: MotionEvent, + ): Boolean = !isGestureInTopPadding(child, event) && super.onTouchEvent(parent, child, event) + + private fun isGestureInTopPadding( + child: V, + event: MotionEvent, + ): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + gestureStartedInTopPadding = event.y < child.top + child.paddingTop + } + return gestureStartedInTopPadding + } +} diff --git a/app/src/main/res/layout-land/content_editor.xml b/app/src/main/res/layout-land/content_editor.xml index b2f0a7e74f..c5636fdb66 100644 --- a/app/src/main/res/layout-land/content_editor.xml +++ b/app/src/main/res/layout-land/content_editor.xml @@ -165,7 +165,7 @@ android:layout_height="match_parent" app:behavior_hideable="false" app:behavior_peekHeight="@dimen/editor_sheet_collapsed_height" - app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" /> + app:layout_behavior="com.itsaky.androidide.ui.EditorBottomSheetBehavior" /> + app:layout_behavior="com.itsaky.androidide.ui.EditorBottomSheetBehavior" /> () + + private val editor = + View(activity).apply { + isClickable = true + setOnTouchListener { _, event -> + editorActions += event.actionMasked + false + } + } + + private val behavior = + EditorBottomSheetBehavior(activity, null).apply { + peekHeight = PEEK_HEIGHT + isHideable = false + } + + private val sheet = FrameLayout(activity).apply { setPadding(0, TOP_PADDING, 0, 0) } + + private val parent = + CoordinatorLayout(activity).apply { + addView(editor, CoordinatorLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)) + addView( + sheet, + CoordinatorLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT).also { it.behavior = behavior }, + ) + } + + private fun laidOut() { + activity.setContentView(parent) + shadowOf(Looper.getMainLooper()).idle() + assertThat(behavior.state).isEqualTo(BottomSheetBehavior.STATE_COLLAPSED) + } + + private fun dragUpFrom(startY: Float) { + val downTime = SystemClock.uptimeMillis() + val x = parent.width / 2f + val events = + listOf(MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, startY, 0)) + + (1..DRAG_STEPS).map { step -> + MotionEvent.obtain( + downTime, + downTime + step * STEP_MILLIS, + MotionEvent.ACTION_MOVE, + x, + startY - step * STEP_PX, + 0, + ) + } + events.forEach { event -> + parent.dispatchTouchEvent(event) + event.recycle() + } + } + + @Test + fun `a drag that starts in the sheet's top padding stays with the view underneath`() { + laidOut() + val collapsedTop = sheet.top + + dragUpFrom(collapsedTop + TOP_PADDING / 2f) + + assertThat(behavior.state).isEqualTo(BottomSheetBehavior.STATE_COLLAPSED) + assertThat(sheet.top).isEqualTo(collapsedTop) + assertThat(editorActions).doesNotContain(MotionEvent.ACTION_CANCEL) + assertThat(editorActions.count { it == MotionEvent.ACTION_MOVE }).isEqualTo(DRAG_STEPS) + } + + @Test + fun `a drag that starts on the sheet's visible top still drags the sheet`() { + laidOut() + val collapsedTop = sheet.top + + dragUpFrom(collapsedTop + TOP_PADDING + (PEEK_HEIGHT - TOP_PADDING) / 2f) + + assertThat(behavior.state).isEqualTo(BottomSheetBehavior.STATE_DRAGGING) + assertThat(sheet.top).isLessThan(collapsedTop) + } + + private companion object { + const val PEEK_HEIGHT = 200 + const val TOP_PADDING = 60 + const val DRAG_STEPS = 6 + const val STEP_PX = 20f + const val STEP_MILLIS = 16L + } +}