diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index c3fd32dd896038..230be23a2013b1 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java @@ -123,6 +123,8 @@ public class ReactEditText extends AppCompatEditText private static final KeyListener sKeyListener = QwertyKeyListener.getInstanceForFullKeyboard(); private @Nullable EventDispatcher mEventDispatcher; + private final ReactEditTextClickDetector clickDetector = new ReactEditTextClickDetector(this); + public ReactEditText(Context context) { super(context); setFocusableInTouchMode(false); @@ -207,6 +209,13 @@ public boolean onTouchEvent(MotionEvent ev) { // Disallow parent views to intercept touch events, until we can detect if we should be // capturing these touches or not. this.getParent().requestDisallowInterceptTouchEvent(true); + clickDetector.handleDown(ev); + break; + case MotionEvent.ACTION_UP: + clickDetector.handleUp(ev); + break; + case MotionEvent.ACTION_CANCEL: + clickDetector.cancelPress(); break; case MotionEvent.ACTION_MOVE: if (mDetectScrollMovement) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditTextClickDetector.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditTextClickDetector.java new file mode 100644 index 00000000000000..74fdce25d21827 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditTextClickDetector.java @@ -0,0 +1,92 @@ +package com.facebook.react.views.textinput; + +import android.os.Build; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.Nullable; + +class ReactEditTextClickDetector { + + private static final long MAX_CLICK_DURATION_MS = 250L; + private static final int MAX_CLICK_DISTANCE_DP = 12; + + private final ReactEditText reactEditText; + private final float screenDensity; + + @Nullable + private TimestampedMotionEvent currentDownEvent; + + public ReactEditTextClickDetector(final ReactEditText reactEditText) { + this.reactEditText = reactEditText; + screenDensity = reactEditText.getResources().getDisplayMetrics().density; + } + + void handleDown(final MotionEvent downEvent) { + currentDownEvent = new TimestampedMotionEvent(downEvent); + } + + void cancelPress() { + currentDownEvent = null; + } + + void handleUp(final MotionEvent upEvent) { + if (currentDownEvent == null) { + return; + } + + final TimestampedMotionEvent downEvent = currentDownEvent; + currentDownEvent = null; + + // for now, if we're not forcing showing the keyboard on clicks, we don't care if it was a + // click. we also early return if the view is not enabled. + if (!(forceShowKeyboardOnClicks() && reactEditText.isEnabled())) { + return; + } + + // make sure the press event was close enough in time + final long now = System.currentTimeMillis(); + final long timeDelta = now - downEvent.timestamp; + if (timeDelta > MAX_CLICK_DURATION_MS) { + return; + } + + // make sure the press event was close enough in distance + final float oldX = downEvent.motionEvent.getRawX(); + final float oldY = downEvent.motionEvent.getRawY(); + final float newX = upEvent.getRawX(); + final float newY = upEvent.getRawY(); + + // distance = sqrt((x2 − x1)^2 + (y2 − y1)^2) + final double distancePx = Math.sqrt( + Math.pow((newX - oldX), 2) + Math.pow((newY - oldY), 2) + ); + + double distanceDp = distancePx / screenDensity; + if (distanceDp > MAX_CLICK_DISTANCE_DP) { + return; + } + + reactEditText.showSoftKeyboard(); + } + + /** + * There is a bug on Android 7/8/9 where clicking the view while it is already + * focused does not show the keyboard. On those API levels, we force showing + * the keyboard when we detect a click. + */ + private static boolean forceShowKeyboardOnClicks() { + return Build.VERSION.SDK_INT <= Build.VERSION_CODES.P; + } + + private static class TimestampedMotionEvent { + + final long timestamp; + final MotionEvent motionEvent; + + TimestampedMotionEvent(final long timestamp, final MotionEvent motionEvent) { + this.timestamp = timestamp; + this.motionEvent = motionEvent; + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java index 8aedfecf772327..aaf7d05acd50b2 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java @@ -131,6 +131,7 @@ public void onLayoutChange( private int mLayoutDirection; private float mBackfaceOpacity = 1.f; private String mBackfaceVisibility = "visible"; + private boolean mPreventClipping = false; public ReactViewGroup(Context context) { super(context); @@ -279,6 +280,19 @@ public void setBorderStyle(@Nullable String style) { getOrCreateReactViewBackground().setBorderStyle(style); } + public void setPreventClipping(boolean preventClipping) { + mPreventClipping = preventClipping; + + // TODO(apkumar) + // + // It would be nice to trigger the LayoutChangeListener at this point. + } + + public boolean getPreventClipping() { + return mPreventClipping; + } + + @Override public void setRemoveClippedSubviews(boolean removeClippedSubviews) { if (removeClippedSubviews == mRemoveClippedSubviews) { @@ -366,16 +380,22 @@ private void updateSubviewClipStatus(Rect clippingRect, int idx, int clippedSoFa // it won't be size and located properly. Animation animation = child.getAnimation(); boolean isAnimating = animation != null && !animation.hasEnded(); - if (!intersects && child.getParent() != null && !isAnimating) { + + boolean preventClipping = false; + if (child instanceof ReactViewGroup) { + preventClipping = ((ReactViewGroup)child).getPreventClipping(); + } + + if (!intersects && child.getParent() != null && !isAnimating && !preventClipping) { // We can try saving on invalidate call here as the view that we remove is out of visible area // therefore invalidation is not necessary. super.removeViewsInLayout(idx - clippedSoFar, 1); needUpdateClippingRecursive = true; - } else if (intersects && child.getParent() == null) { + } else if ((intersects || preventClipping) && child.getParent() == null) { super.addViewInLayout(child, idx - clippedSoFar, sDefaultLayoutParam, true); invalidate(); needUpdateClippingRecursive = true; - } else if (intersects) { + } else if (intersects || preventClipping) { // If there is any intersection we need to inform the child to update its clipping rect needUpdateClippingRecursive = true; } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java index ba4a1d5cb9567a..af592385c66ed3 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java @@ -53,6 +53,11 @@ public class ReactViewManager extends ReactClippingViewManager { private static final int CMD_SET_PRESSED = 2; private static final String HOTSPOT_UPDATE_KEY = "hotspotUpdate"; + @ReactProp(name = "preventClipping") + public void setPreventClipping(ReactViewGroup view, boolean preventClipping) { + view.setPreventClipping(preventClipping); + } + @ReactProp(name = "accessible") public void setAccessible(ReactViewGroup view, boolean accessible) { view.setFocusable(accessible);