Browse Source

Overlay nested touchables (#6234)

* initial commit

* Fix nested touchables in Overlay

When nesting Touchable components in an Overlay, under certain conditions, the outer most Touchable component consumed all touch events.

Fixes #5184

Co-authored-by: Yogev Ben David <yogev132@gmail.com>
Guy Carmeli 4 years ago
parent
commit
851703c0ca
No account linked to committer's email address

+ 11
- 0
e2e/Overlay.test.js View File

35
     await expect(elementById(TestIDs.OVERLAY_ALERT_HEADER)).toBeVisible();
35
     await expect(elementById(TestIDs.OVERLAY_ALERT_HEADER)).toBeVisible();
36
   });
36
   });
37
 
37
 
38
+  fit('nested touchables work as expected', async () => {
39
+    await elementById(TestIDs.TOAST_BTN).tap();
40
+    await elementById(TestIDs.TOAST_OK_BTN_INNER).tap();
41
+    await expect(elementByLabel('Inner button clicked')).toBeVisible();
42
+    await elementById(TestIDs.OK_BUTTON).tap();
43
+
44
+    await elementById(TestIDs.TOAST_BTN).tap();
45
+    await elementById(TestIDs.TOAST_OK_BTN_OUTER).tap();
46
+    await expect(elementByLabel('Outer button clicked')).toBeVisible();
47
+  });
48
+
38
   xtest('overlay pass touches - false', async () => {
49
   xtest('overlay pass touches - false', async () => {
39
     await elementById(TestIDs.SHOW_OVERLAY_BUTTON).tap();
50
     await elementById(TestIDs.SHOW_OVERLAY_BUTTON).tap();
40
     await expect(elementById(TestIDs.SHOW_OVERLAY_BUTTON)).toBeVisible();
51
     await expect(elementById(TestIDs.SHOW_OVERLAY_BUTTON)).toBeVisible();

+ 2
- 2
lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/IReactView.java View File

1
 package com.reactnativenavigation.viewcontrollers;
1
 package com.reactnativenavigation.viewcontrollers;
2
 
2
 
3
 import android.view.MotionEvent;
3
 import android.view.MotionEvent;
4
-import android.view.View;
4
+import android.view.ViewGroup;
5
 
5
 
6
 import com.reactnativenavigation.interfaces.ScrollEventListener;
6
 import com.reactnativenavigation.interfaces.ScrollEventListener;
7
 
7
 
9
 
9
 
10
     boolean isReady();
10
     boolean isReady();
11
 
11
 
12
-    View asView();
12
+    ViewGroup asView();
13
 
13
 
14
     void sendOnNavigationButtonPressed(String buttonId);
14
     void sendOnNavigationButtonPressed(String buttonId);
15
 
15
 

+ 7
- 3
lib/android/app/src/main/java/com/reactnativenavigation/views/ComponentLayout.java View File

3
 import android.annotation.SuppressLint;
3
 import android.annotation.SuppressLint;
4
 import android.content.Context;
4
 import android.content.Context;
5
 import android.view.MotionEvent;
5
 import android.view.MotionEvent;
6
-import android.view.View;
6
+import android.view.ViewGroup;
7
 
7
 
8
 import com.reactnativenavigation.interfaces.ScrollEventListener;
8
 import com.reactnativenavigation.interfaces.ScrollEventListener;
9
 import com.reactnativenavigation.parse.Options;
9
 import com.reactnativenavigation.parse.Options;
27
 		super(context);
27
 		super(context);
28
 		this.reactView = reactView;
28
 		this.reactView = reactView;
29
         addView(reactView.asView(), matchParentLP());
29
         addView(reactView.asView(), matchParentLP());
30
-        touchDelegate = new OverlayTouchDelegate(reactView);
30
+        touchDelegate = new OverlayTouchDelegate(this, reactView);
31
     }
31
     }
32
 
32
 
33
     @Override
33
     @Override
36
     }
36
     }
37
 
37
 
38
     @Override
38
     @Override
39
-    public View asView() {
39
+    public ViewGroup asView() {
40
         return this;
40
         return this;
41
     }
41
     }
42
 
42
 
94
     public boolean onInterceptTouchEvent(MotionEvent ev) {
94
     public boolean onInterceptTouchEvent(MotionEvent ev) {
95
         return touchDelegate.onInterceptTouchEvent(ev);
95
         return touchDelegate.onInterceptTouchEvent(ev);
96
     }
96
     }
97
+
98
+    public boolean superOnInterceptTouchEvent(MotionEvent event) {
99
+        return super.onInterceptTouchEvent(event);
100
+    }
97
 }
101
 }

+ 17
- 27
lib/android/app/src/main/java/com/reactnativenavigation/views/touch/OverlayTouchDelegate.java View File

1
 package com.reactnativenavigation.views.touch;
1
 package com.reactnativenavigation.views.touch;
2
 
2
 
3
 import android.graphics.Rect;
3
 import android.graphics.Rect;
4
-import androidx.annotation.VisibleForTesting;
5
 import android.view.MotionEvent;
4
 import android.view.MotionEvent;
6
 import android.view.View;
5
 import android.view.View;
7
-import android.view.ViewGroup;
8
 
6
 
9
 import com.reactnativenavigation.parse.params.Bool;
7
 import com.reactnativenavigation.parse.params.Bool;
10
 import com.reactnativenavigation.parse.params.NullBool;
8
 import com.reactnativenavigation.parse.params.NullBool;
11
 import com.reactnativenavigation.viewcontrollers.IReactView;
9
 import com.reactnativenavigation.viewcontrollers.IReactView;
10
+import com.reactnativenavigation.views.ComponentLayout;
11
+
12
+import androidx.annotation.VisibleForTesting;
12
 
13
 
13
 public class OverlayTouchDelegate {
14
 public class OverlayTouchDelegate {
14
-    private enum TouchLocation {Outside, Inside}
15
     private final Rect hitRect = new Rect();
15
     private final Rect hitRect = new Rect();
16
-    private IReactView reactView;
17
     private Bool interceptTouchOutside = new NullBool();
16
     private Bool interceptTouchOutside = new NullBool();
17
+    private ComponentLayout component;
18
+    private IReactView reactView;
18
 
19
 
19
     public void setInterceptTouchOutside(Bool interceptTouchOutside) {
20
     public void setInterceptTouchOutside(Bool interceptTouchOutside) {
20
         this.interceptTouchOutside = interceptTouchOutside;
21
         this.interceptTouchOutside = interceptTouchOutside;
21
     }
22
     }
22
 
23
 
23
-    public OverlayTouchDelegate(IReactView reactView) {
24
+    public OverlayTouchDelegate(ComponentLayout component, IReactView reactView) {
25
+        this.component = component;
24
         this.reactView = reactView;
26
         this.reactView = reactView;
25
     }
27
     }
26
 
28
 
27
     public boolean onInterceptTouchEvent(MotionEvent event) {
29
     public boolean onInterceptTouchEvent(MotionEvent event) {
28
-        if (interceptTouchOutside instanceof NullBool) return false;
29
-        switch (event.getActionMasked()) {
30
-            case MotionEvent.ACTION_DOWN:
31
-                return handleDown(event);
32
-            default:
33
-                reactView.dispatchTouchEventToJs(event);
34
-                return false;
35
-
36
-        }
30
+        return interceptTouchOutside.hasValue() && event.getActionMasked() == MotionEvent.ACTION_DOWN ?
31
+                handleDown(event) :
32
+                component.superOnInterceptTouchEvent(event);
37
     }
33
     }
38
 
34
 
39
     @VisibleForTesting
35
     @VisibleForTesting
40
     public boolean handleDown(MotionEvent event) {
36
     public boolean handleDown(MotionEvent event) {
41
-        TouchLocation location = getTouchLocation(event);
42
-        if (location == TouchLocation.Inside) {
43
-            reactView.dispatchTouchEventToJs(event);
44
-            return false;
45
-        }
46
-        return interceptTouchOutside.isFalseOrUndefined();
37
+        if (isTouchInsideOverlay(event)) return component.superOnInterceptTouchEvent(event);
38
+        return interceptTouchOutside.isFalse();
47
     }
39
     }
48
 
40
 
49
-    private TouchLocation getTouchLocation(MotionEvent ev) {
50
-        getView((ViewGroup) reactView.asView()).getHitRect(hitRect);
51
-        return hitRect.contains((int) ev.getRawX(), (int) ev.getRawY()) ?
52
-                TouchLocation.Inside :
53
-                TouchLocation.Outside;
41
+    private boolean isTouchInsideOverlay(MotionEvent ev) {
42
+        getOverlayView().getHitRect(hitRect);
43
+        return hitRect.contains((int) ev.getRawX(), (int) ev.getRawY());
54
     }
44
     }
55
 
45
 
56
-    private View getView(ViewGroup view) {
57
-        return view.getChildCount() > 0 ? view.getChildAt(0) : view;
46
+    private View getOverlayView() {
47
+        return reactView.asView().getChildCount() > 0 ? reactView.asView().getChildAt(0) : reactView.asView();
58
     }
48
     }
59
 }
49
 }

+ 2
- 2
lib/android/app/src/test/java/com/reactnativenavigation/mocks/SimpleOverlay.java View File

2
 
2
 
3
 import android.content.Context;
3
 import android.content.Context;
4
 import android.view.MotionEvent;
4
 import android.view.MotionEvent;
5
-import android.view.View;
5
+import android.view.ViewGroup;
6
 import android.widget.FrameLayout;
6
 import android.widget.FrameLayout;
7
 import android.widget.RelativeLayout;
7
 import android.widget.RelativeLayout;
8
 
8
 
20
     }
20
     }
21
 
21
 
22
     @Override
22
     @Override
23
-    public View asView() {
23
+    public ViewGroup asView() {
24
         FrameLayout root = new FrameLayout(getContext());
24
         FrameLayout root = new FrameLayout(getContext());
25
         FrameLayout overlay = new FrameLayout(getContext());
25
         FrameLayout overlay = new FrameLayout(getContext());
26
         root.addView(overlay);
26
         root.addView(overlay);

+ 2
- 2
lib/android/app/src/test/java/com/reactnativenavigation/mocks/TestComponentLayout.java View File

2
 
2
 
3
 import android.content.Context;
3
 import android.content.Context;
4
 import android.view.MotionEvent;
4
 import android.view.MotionEvent;
5
-import android.view.View;
5
+import android.view.ViewGroup;
6
 
6
 
7
 import com.reactnativenavigation.interfaces.ScrollEventListener;
7
 import com.reactnativenavigation.interfaces.ScrollEventListener;
8
 import com.reactnativenavigation.parse.Options;
8
 import com.reactnativenavigation.parse.Options;
26
     }
26
     }
27
 
27
 
28
     @Override
28
     @Override
29
-    public View asView() {
29
+    public ViewGroup asView() {
30
         return this;
30
         return this;
31
     }
31
     }
32
 
32
 

+ 6
- 3
lib/android/app/src/test/java/com/reactnativenavigation/views/TouchDelegateTest.java View File

8
 import com.reactnativenavigation.views.touch.OverlayTouchDelegate;
8
 import com.reactnativenavigation.views.touch.OverlayTouchDelegate;
9
 
9
 
10
 import org.junit.Test;
10
 import org.junit.Test;
11
+import org.mockito.Mockito;
11
 
12
 
12
 import static org.assertj.core.api.Java6Assertions.assertThat;
13
 import static org.assertj.core.api.Java6Assertions.assertThat;
13
 import static org.mockito.Mockito.spy;
14
 import static org.mockito.Mockito.spy;
21
     private final MotionEvent downEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, x, y, 0);
22
     private final MotionEvent downEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, x, y, 0);
22
     private final MotionEvent upEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, x, y, 0);
23
     private final MotionEvent upEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, x, y, 0);
23
     private SimpleOverlay reactView;
24
     private SimpleOverlay reactView;
25
+    private ComponentLayout component;
24
 
26
 
25
     @Override
27
     @Override
26
     public void beforeEach() {
28
     public void beforeEach() {
27
         super.beforeEach();
29
         super.beforeEach();
28
         reactView = spy(new SimpleOverlay(newActivity()));
30
         reactView = spy(new SimpleOverlay(newActivity()));
29
-        uut = spy(new OverlayTouchDelegate(reactView));
31
+        component = Mockito.mock(ComponentLayout.class);
32
+        uut = spy(new OverlayTouchDelegate(component, reactView));
30
     }
33
     }
31
 
34
 
32
     @Test
35
     @Test
50
     }
53
     }
51
 
54
 
52
     @Test
55
     @Test
53
-    public void nonDownEventsDispatchTouchEventsToJs() {
56
+    public void nonDownEventsInvokeSuperImplementation() {
54
         uut.setInterceptTouchOutside(new Bool(true));
57
         uut.setInterceptTouchOutside(new Bool(true));
55
         uut.onInterceptTouchEvent(upEvent);
58
         uut.onInterceptTouchEvent(upEvent);
56
-        verify(reactView, times(1)).dispatchTouchEventToJs(upEvent);
59
+        verify(component).superOnInterceptTouchEvent(upEvent);
57
     }
60
     }
58
 }
61
 }

+ 0
- 1
playground/src/screens/OptionsScreen.js View File

1
 const React = require('react');
1
 const React = require('react');
2
 const {Component} = require('react');
2
 const {Component} = require('react');
3
-import {Platform} from 'react-native';
4
 const Root = require('../components/Root');
3
 const Root = require('../components/Root');
5
 const Button = require('../components/Button')
4
 const Button = require('../components/Button')
6
 const Navigation = require('../services/Navigation');
5
 const Navigation = require('../services/Navigation');

+ 5
- 1
playground/src/screens/OverlayScreen.js View File

7
   SHOW_OVERLAY_BTN,
7
   SHOW_OVERLAY_BTN,
8
   SHOW_TOUCH_THROUGH_OVERLAY_BTN,
8
   SHOW_TOUCH_THROUGH_OVERLAY_BTN,
9
   ALERT_BUTTON,
9
   ALERT_BUTTON,
10
-  SET_ROOT_BTN
10
+  SET_ROOT_BTN,
11
+  TOAST_BTN
11
 } = require('../testIDs');
12
 } = require('../testIDs');
12
 const Screens = require('./Screens');
13
 const Screens = require('./Screens');
13
 
14
 
25
   render() {
26
   render() {
26
     return (
27
     return (
27
       <Root componentId={this.props.componentId}>
28
       <Root componentId={this.props.componentId}>
29
+        <Button label='Toast' testID={TOAST_BTN} onPress={this.toast} />
28
         <Button label='Alert' testID={ALERT_BUTTON} onPress={() => alert('Alert displayed')} />
30
         <Button label='Alert' testID={ALERT_BUTTON} onPress={() => alert('Alert displayed')} />
29
         <Button label='Show overlay' testID={SHOW_OVERLAY_BTN} onPress={() => this.showOverlay(true)} />
31
         <Button label='Show overlay' testID={SHOW_OVERLAY_BTN} onPress={() => this.showOverlay(true)} />
30
         <Button label='Show touch through overlay' testID={SHOW_TOUCH_THROUGH_OVERLAY_BTN} onPress={() => this.showOverlay(false)} />
32
         <Button label='Show touch through overlay' testID={SHOW_TOUCH_THROUGH_OVERLAY_BTN} onPress={() => this.showOverlay(false)} />
34
     );
36
     );
35
   }
37
   }
36
 
38
 
39
+  toast = () => Navigation.showOverlay(Screens.Toast);
40
+
37
   showOverlay = (interceptTouchOutside) => Navigation.showOverlay(Screens.OverlayAlert, {
41
   showOverlay = (interceptTouchOutside) => Navigation.showOverlay(Screens.OverlayAlert, {
38
     layout: { componentBackgroundColor: 'transparent' },
42
     layout: { componentBackgroundColor: 'transparent' },
39
     overlay: { interceptTouchOutside }
43
     overlay: { interceptTouchOutside }

+ 9
- 2
playground/src/screens/ScrollViewOverlay.js View File

10
   '#D9644A',
10
   '#D9644A',
11
   '#CF262F',
11
   '#CF262F',
12
   '#8B1079',
12
   '#8B1079',
13
-  '#48217B'
13
+  '#48217B',
14
+  '#8B1079',
15
+  '#CF262F',
16
+  '#D9644A',
17
+  '#E2902B',
18
+  '#00A65F',
19
+  '#00AAAF',
20
+  '#3182C8',
14
 ];
21
 ];
15
 
22
 
16
 class ScrollViewOverlay extends PureComponent {
23
 class ScrollViewOverlay extends PureComponent {
27
     );
34
     );
28
   }
35
   }
29
 
36
 
30
-  renderRow = (color) => <Text key={color} style={[styles.row, { backgroundColor: color }]}>{color}</Text>
37
+  renderRow = (color) => <Text key={color + Math.random()} style={[styles.row, { backgroundColor: color }]}>{color}</Text>
31
 }
38
 }
32
 
39
 
33
 const styles = StyleSheet.create({
40
 const styles = StyleSheet.create({

+ 17
- 7
playground/src/screens/Toast.js View File

2
 const { View, Text, StyleSheet, TouchableOpacity } = require('react-native');
2
 const { View, Text, StyleSheet, TouchableOpacity } = require('react-native');
3
 const Colors = require('../commons/Colors');
3
 const Colors = require('../commons/Colors');
4
 const Navigation = require('../services/Navigation');
4
 const Navigation = require('../services/Navigation');
5
+const {
6
+  TOAST_OK_BTN_INNER,
7
+  TOAST_OK_BTN_OUTER
8
+} = require('../testIDs');
5
 
9
 
6
 const Toast = function ({componentId}) {
10
 const Toast = function ({componentId}) {
11
+  const dismiss = (txt) => {
12
+    alert(txt)
13
+    Navigation.dismissOverlay(componentId);
14
+  }
15
+
7
   return (
16
   return (
8
     <View style={styles.root}>
17
     <View style={styles.root}>
9
-      <View style={styles.toast}>
10
-        <Text style={styles.text}>This a very important message!</Text>
11
-        <TouchableOpacity style={styles.button} onPress={() => Navigation.dismissOverlay(componentId)}>
12
-          <Text style={styles.buttonText}>OK</Text>
13
-        </TouchableOpacity>
14
-      </View>
18
+      <TouchableOpacity testID={TOAST_OK_BTN_OUTER} onPress={() => dismiss('Outer button clicked')}>
19
+        <View style={styles.toast}>
20
+          <Text style={styles.text}>This a very important message!</Text>
21
+          <TouchableOpacity testID={TOAST_OK_BTN_INNER} style={styles.button} onPress={() => dismiss('Inner button clicked')}>
22
+            <Text style={styles.buttonText}>OK</Text>
23
+          </TouchableOpacity>
24
+        </View>
25
+      </TouchableOpacity>
15
     </View>
26
     </View>
16
   )
27
   )
17
 }
28
 }
21
     flex: 1,
32
     flex: 1,
22
     flexDirection: 'column-reverse',
33
     flexDirection: 'column-reverse',
23
     backgroundColor: 0x3e434aa1
34
     backgroundColor: 0x3e434aa1
24
-    // backgroundColor: 'red'
25
   },
35
   },
26
   toast: {
36
   toast: {
27
     elevation: 2,
37
     elevation: 2,

+ 3
- 0
playground/src/testIDs.js View File

98
   ADD_BUTTON: 'ADD_BUTTON',
98
   ADD_BUTTON: 'ADD_BUTTON',
99
   TAB_BASED_APP_BUTTON: `TAB_BASED_APP_BUTTON`,
99
   TAB_BASED_APP_BUTTON: `TAB_BASED_APP_BUTTON`,
100
   TAB_BASED_APP_SIDE_BUTTON: `TAB_BASED_APP_SIDE_BUTTON`,
100
   TAB_BASED_APP_SIDE_BUTTON: `TAB_BASED_APP_SIDE_BUTTON`,
101
+  TOAST_BTN: 'TOAST_BTN',
102
+  TOAST_OK_BTN_OUTER: 'TOAST_OK_BTN_OUTER',
103
+  TOAST_OK_BTN_INNER: 'TOAST_OK_BTN_INNER',
101
   PUSH_STATIC_LIFECYCLE_BUTTON: `PUSH_STATIC_LIFECYCLE_BUTTON`,
104
   PUSH_STATIC_LIFECYCLE_BUTTON: `PUSH_STATIC_LIFECYCLE_BUTTON`,
102
   PUSH_CONTEXT_SCREEN_BUTTON: `PUSH_CONTEXT_SCREEN_BUTTON`,
105
   PUSH_CONTEXT_SCREEN_BUTTON: `PUSH_CONTEXT_SCREEN_BUTTON`,
103
   PUSH_OPTIONS_BUTTON: `PUSH_OPTIONS_BUTTON`,
106
   PUSH_OPTIONS_BUTTON: `PUSH_OPTIONS_BUTTON`,