diff --git a/android/src/androidTest/java/com/reactnativenavigation/views/overlay/OverlayContainerTest.kt b/android/src/androidTest/java/com/reactnativenavigation/views/overlay/OverlayContainerTest.kt new file mode 100644 index 0000000000..57c358724e --- /dev/null +++ b/android/src/androidTest/java/com/reactnativenavigation/views/overlay/OverlayContainerTest.kt @@ -0,0 +1,104 @@ +package com.reactnativenavigation.views.overlay + +import android.content.Context +import android.view.MotionEvent +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.reactnativenavigation.views.component.ComponentLayout +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OverlayContainerTest { + private lateinit var context: Context + private lateinit var uut: OverlayContainer + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + uut = OverlayContainer(context) + } + + @Test + fun onInterceptTouchEvent_noChildren_returnsFalse() { + val event = createDownEvent(0f, 0f) + val result = uut.onInterceptTouchEvent(event) + assertFalse("Should not intercept when no children", result) + event.recycle() + } + + @Test + fun onInterceptTouchEvent_nonComponentLayoutChild_returnsFalse() { + // Add a regular View (not a ComponentLayout) + val regularView = View(context) + uut.addView(regularView, 100, 100) + + val event = createDownEvent(0f, 0f) + val result = uut.onInterceptTouchEvent(event) + + assertFalse("Should not intercept when child is not ComponentLayout", result) + event.recycle() + } + + @Test + fun onInterceptTouchEvent_nonActionDown_usesDefaultBehavior() { + val regularView = View(context) + uut.addView(regularView, 100, 100) + + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 0f, 0f, 0) + // Should use super.onInterceptTouchEvent for non-ACTION_DOWN + val result = uut.onInterceptTouchEvent(event) + + // Result depends on CoordinatorLayout's default behavior + event.recycle() + } + + @Test + fun dispatchTouchEvent_noComponentLayoutChildren_returnsFalse() { + // Add a regular View (not a ComponentLayout) + val regularView = View(context) + uut.addView(regularView, 100, 100) + + val event = createDownEvent(0f, 0f) + val result = uut.dispatchTouchEvent(event) + + assertFalse("Should return false to let touch pass through when no ComponentLayout wants it", result) + event.recycle() + } + + @Test + fun dispatchTouchEvent_nonActionDown_callsSuper() { + val regularView = View(context) + uut.addView(regularView, 100, 100) + + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 50f, 50f, 0) + // Should call super.dispatchTouchEvent for non-ACTION_DOWN + uut.dispatchTouchEvent(event) + + event.recycle() + } + + @Test + fun onInterceptTouchEvent_checksOnlyComponentLayoutChildren() { + // Add a mix of regular Views and potentially ComponentLayouts + val view1 = View(context) + val view2 = View(context) + uut.addView(view1, 100, 100) + uut.addView(view2, 100, 100) + + val event = createDownEvent(50f, 50f) + val result = uut.onInterceptTouchEvent(event) + + // Should iterate through children but find no ComponentLayouts + assertFalse("Should not intercept when no ComponentLayout children", result) + event.recycle() + } + + // Helper methods + private fun createDownEvent(x: Float, y: Float): MotionEvent { + return MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, x, y, 0) + } +} diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java index 45877b1702..a5735fb946 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java @@ -28,6 +28,7 @@ import com.reactnativenavigation.viewcontrollers.viewcontroller.RootPresenter; import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController; import com.reactnativenavigation.viewcontrollers.viewcontroller.overlay.RootOverlay; +import com.reactnativenavigation.views.overlay.OverlayContainer; import java.util.Collection; import java.util.Collections; @@ -42,7 +43,7 @@ public class Navigator extends ParentController { private ViewController previousRoot; private final CoordinatorLayout rootLayout; private final CoordinatorLayout modalsLayout; - private final CoordinatorLayout overlaysLayout; + private final OverlayContainer overlaysLayout; private ViewGroup contentLayout; private Options defaultOptions = new Options(); @@ -81,7 +82,7 @@ public Navigator(final Activity activity, ChildControllersRegistry childRegistry this.rootPresenter = rootPresenter; rootLayout = new CoordinatorLayout(getActivity()); modalsLayout = new CoordinatorLayout(getActivity()); - overlaysLayout = new CoordinatorLayout(getActivity()); + overlaysLayout = new OverlayContainer(getActivity()); } @@ -266,7 +267,7 @@ CoordinatorLayout getModalsLayout() { } @RestrictTo(RestrictTo.Scope.TESTS) - CoordinatorLayout getOverlaysLayout() { + OverlayContainer getOverlaysLayout() { return overlaysLayout; } diff --git a/android/src/main/java/com/reactnativenavigation/views/overlay/OverlayContainer.kt b/android/src/main/java/com/reactnativenavigation/views/overlay/OverlayContainer.kt new file mode 100644 index 0000000000..1485998e0e --- /dev/null +++ b/android/src/main/java/com/reactnativenavigation/views/overlay/OverlayContainer.kt @@ -0,0 +1,54 @@ +package com.reactnativenavigation.views.overlay + +import android.content.Context +import android.view.MotionEvent +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.reactnativenavigation.views.component.ComponentLayout + +/** + * Custom CoordinatorLayout that checks child overlays' interceptTouchOutside + * settings and passes touches through when appropriate. + * + * Fixes issues #4953 and #7674 where overlays with interceptTouchOutside: false + * were still blocking touches to underlying views. + */ +class OverlayContainer(context: Context) : CoordinatorLayout(context) { + + private var shouldPassThroughTouch = false + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + // Only check on ACTION_DOWN to avoid interfering with gesture sequences + if (ev.actionMasked == MotionEvent.ACTION_DOWN) { + shouldPassThroughTouch = true + + // Check if any child overlay wants to intercept this touch + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child is ComponentLayout) { + // Delegate to ComponentLayout's onInterceptTouchEvent + // which uses OverlayTouchDelegate to check interceptTouchOutside flag + if (child.onInterceptTouchEvent(ev)) { + // At least one overlay wants to handle this touch + shouldPassThroughTouch = false + return true + } + } + } + // No overlay wants to intercept - don't block the touch + return false + } + // For non-ACTION_DOWN events, use default behavior + return super.onInterceptTouchEvent(ev) + } + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + // For ACTION_DOWN, check if we should pass through + if (ev.actionMasked == MotionEvent.ACTION_DOWN && shouldPassThroughTouch) { + // No overlay wants this touch - let it fall through to views below + // Return false so the touch system doesn't consider it consumed + return false + } + // Either it's not ACTION_DOWN or an overlay wants to handle it + return super.dispatchTouchEvent(ev) + } +} diff --git a/website/docs/api/options-overlay.mdx b/website/docs/api/options-overlay.mdx index ee4bf5e875..647aace3de 100644 --- a/website/docs/api/options-overlay.mdx +++ b/website/docs/api/options-overlay.mdx @@ -21,6 +21,8 @@ Controls whether touch events outside the bounds of the overlay are to be handle | ----- | -------- | -------- | | boolean | No | Both | +**Note for Android**: This option now correctly makes the overlay container transparent to touches when set to `false`. The overlay container itself will not consume touch events, allowing them to reach underlying views. This fixes long-standing issues where overlays were blocking touches even with `interceptTouchOutside: false`. + ### `handleKeyboardEvents` Overlays on iOS don't handle keyboard events by default. If your Overlay contains a TextInput component, you'll want to enable this option so that TextInput responds to keyboard events.