Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,7 +43,7 @@ public class Navigator extends ParentController<ViewGroup> {
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();

Expand Down Expand Up @@ -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());
}


Expand Down Expand Up @@ -266,7 +267,7 @@ CoordinatorLayout getModalsLayout() {
}

@RestrictTo(RestrictTo.Scope.TESTS)
CoordinatorLayout getOverlaysLayout() {
OverlayContainer getOverlaysLayout() {
return overlaysLayout;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions website/docs/api/options-overlay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down