Skip to content
Open
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
Expand Up @@ -66,6 +66,7 @@
import com.facebook.react.uimanager.ReactStage;
import com.facebook.react.uimanager.RootView;
import com.facebook.react.uimanager.RootViewUtil;
import com.facebook.react.uimanager.TapFocusTracker;
import com.facebook.react.uimanager.UIManagerHelper;
import com.facebook.react.uimanager.common.UIManagerType;
import com.facebook.react.uimanager.events.EventDispatcher;
Expand Down Expand Up @@ -105,6 +106,7 @@ public interface ReactRootViewEventListener {
private @Nullable JSTouchDispatcher mJSTouchDispatcher;
private @Nullable JSPointerDispatcher mJSPointerDispatcher;
private @Nullable JSKeyDispatcher mJSKeyDispatcher;
private @Nullable TapFocusTracker mTapFocusTracker;
private final ReactAndroidHWInputDeviceHelper mAndroidHWInputDeviceHelper =
new ReactAndroidHWInputDeviceHelper();
private boolean mWasMeasured = false;
Expand Down Expand Up @@ -259,6 +261,9 @@ public boolean shouldDispatchJSTouchEvent(MotionEvent ev) {

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (mTapFocusTracker != null) {
mTapFocusTracker.handleMotionEvent(ev);
}
if (shouldDispatchJSTouchEvent(ev)) {
dispatchJSTouchEvent(ev);
}
Expand All @@ -268,6 +273,9 @@ public boolean onInterceptTouchEvent(MotionEvent ev) {

@Override
public boolean onTouchEvent(MotionEvent ev) {
if (mTapFocusTracker != null) {
mTapFocusTracker.handleMotionEvent(ev);
}
if (shouldDispatchJSTouchEvent(ev)) {
dispatchJSTouchEvent(ev);
}
Expand Down Expand Up @@ -450,6 +458,30 @@ protected void dispatchJSKeyEvent(KeyEvent ev) {
}
}

private void startTrackingKeyEventTargets() {
TapFocusTracker tapFocusTracker = mTapFocusTracker;
if (tapFocusTracker == null) {
if (!ReactNativeFeatureFlags.enableKeyEvents()) {
return;
}
tapFocusTracker = new TapFocusTracker(this);
mTapFocusTracker = tapFocusTracker;
ViewCompat.addOnUnhandledKeyEventListener(this, this::dispatchUnfocusedJSKeyEvent);
}
if (isAttachedToWindow()) {
tapFocusTracker.observeWindow();
}
}

private boolean dispatchUnfocusedJSKeyEvent(View view, KeyEvent keyEvent) {
// A root that is its window's top view gets every key through dispatchKeyEvent, and any root
// gets here a key that a focused view left unhandled, after dispatchKeyEvent sent it.
if (getRootView() != this && getRootView().findFocus() == null) {
dispatchJSKeyEvent(keyEvent);
}
return false;
}

@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
// Override in order to still receive events to onInterceptTouchEvent even when some other
Expand Down Expand Up @@ -479,6 +511,7 @@ protected void onAttachedToWindow() {
if (isViewAttachedToReactInstance()) {
removeOnGlobalLayoutListener();
getViewTreeObserver().addOnGlobalLayoutListener(getCustomGlobalLayoutListener());
startTrackingKeyEventTargets();
}
}

Expand All @@ -488,6 +521,9 @@ protected void onDetachedFromWindow() {
if (isViewAttachedToReactInstance()) {
removeOnGlobalLayoutListener();
}
if (mTapFocusTracker != null) {
mTapFocusTracker.stopObservingWindow();
}
}

private void removeOnGlobalLayoutListener() {
Expand Down Expand Up @@ -706,7 +742,7 @@ public void onAttachedToReactInstance() {
}

if (ReactNativeFeatureFlags.enableKeyEvents()) {
mJSKeyDispatcher = new JSKeyDispatcher();
mJSKeyDispatcher = new JSKeyDispatcher(this);
}

if (mRootViewEventListener != null) {
Expand Down Expand Up @@ -820,6 +856,7 @@ private void attachToReactInstanceManager() {
mIsAttachedToInstance = true;
Assertions.assertNotNull(mReactInstanceManager).attachRootView(this);
getViewTreeObserver().addOnGlobalLayoutListener(getCustomGlobalLayoutListener());
startTrackingKeyEventTargets();
} finally {
ReactMarker.logMarker(ReactMarkerConstants.ROOT_VIEW_ATTACH_TO_REACT_INSTANCE_MANAGER_END);
Systrace.endSection(TRACE_TAG_REACT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public class ReactSurfaceView(context: Context?, internal val surface: ReactSurf
jsPointerDispatcher = JSPointerDispatcher(this)
}
if (ReactNativeFeatureFlags.enableKeyEvents()) {
jsKeyDispatcher = JSKeyDispatcher()
jsKeyDispatcher = JSKeyDispatcher(this)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package com.facebook.react.uimanager

import android.view.KeyEvent as AndroidKeyEvent
import android.view.View
import android.view.ViewGroup
import com.facebook.react.uimanager.events.EventDispatcher
import com.facebook.react.uimanager.events.KeyDownEvent
import com.facebook.react.uimanager.events.KeyUpEvent
Expand All @@ -19,17 +20,24 @@ import com.facebook.react.uimanager.events.KeyUpEvent
* phases.
*
* The keydown and keyup events provide a code indicating which key is pressed. The event target is
* derived from the currently focused Android view.
* derived from the currently focused Android view. While no view in the window holds focus, as
* after a touch in touch mode, the target is the view [TapFocusTracker] holds focused for it.
*/
internal class JSKeyDispatcher {
internal class JSKeyDispatcher(private val rootViewGroup: ViewGroup) {
private var focusedViewTag: Int = View.NO_ID

fun handleKeyEvent(
keyEvent: AndroidKeyEvent,
eventDispatcher: EventDispatcher,
surfaceId: Int,
) {
if (focusedViewTag == View.NO_ID) {
val targetTag =
if (rootViewGroup.rootView.findFocus() != null) {
focusedViewTag
} else {
TapFocusTracker.findFocusedView(rootViewGroup)?.id ?: View.NO_ID
}
if (targetTag == View.NO_ID) {
return
}

Expand All @@ -38,7 +46,7 @@ internal class JSKeyDispatcher {
eventDispatcher.dispatchEvent(
KeyDownEvent(
surfaceId,
focusedViewTag,
targetTag,
keyEvent,
),
)
Expand All @@ -47,7 +55,7 @@ internal class JSKeyDispatcher {
eventDispatcher.dispatchEvent(
KeyUpEvent(
surfaceId,
focusedViewTag,
targetTag,
keyEvent,
),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.uimanager

import android.graphics.PointF
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.ViewTreeObserver
import com.facebook.react.R
import java.lang.ref.WeakReference

/**
* Keeps, for the window of a React root, the view a browser would hold focused where Android holds
* none, since Android's touch mode leaves no view focused unless it is focusable in touch mode: the
* nearest focusable React view of the last tap on a React root in the window, or of the view whose
* focus a touch that was no tap took away. A view that takes Android's focus, or held it when
* tapped, holds that focus itself, so the tap's focus ends.
*
* A tap is a touch with one pointer that never moves past the touch slop, so a scroll keeps the
* focus, as it keeps a browser's.
*/
internal class TapFocusTracker(private val rootViewGroup: ViewGroup) :
ViewTreeObserver.OnGlobalFocusChangeListener, ViewTreeObserver.OnTouchModeChangeListener {
private val touchSlop = ViewConfiguration.get(rootViewGroup.context).scaledTouchSlop
private var tapStart: PointF? = null

fun handleMotionEvent(motionEvent: MotionEvent) {
when (motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN -> tapStart = PointF(motionEvent.x, motionEvent.y)
MotionEvent.ACTION_MOVE ->
tapStart = tapStart?.takeIf { start -> isWithinTouchSlop(start, motionEvent) }
MotionEvent.ACTION_UP -> {
if (tapStart != null) {
setFocusedView(findTappedView(motionEvent)?.takeUnless { it.isFocused })
}
tapStart = null
}
else -> tapStart = null
}
}

/** Follows the focus and the touch mode of the root's window, until [stopObservingWindow]. */
fun observeWindow() {
stopObservingWindow()
rootViewGroup.viewTreeObserver.addOnGlobalFocusChangeListener(this)
rootViewGroup.viewTreeObserver.addOnTouchModeChangeListener(this)
}

fun stopObservingWindow() {
rootViewGroup.viewTreeObserver.removeOnGlobalFocusChangeListener(this)
rootViewGroup.viewTreeObserver.removeOnTouchModeChangeListener(this)
}

override fun onGlobalFocusChanged(oldFocus: View?, newFocus: View?) {
if (newFocus != null) {
setFocusedView(null)
}
}

/**
* Android calls this before entering touch mode clears the focus of a view not focusable in it.
*/
override fun onTouchModeChanged(isInTouchMode: Boolean) {
val focusedView = rootViewGroup.findFocus()
if (isInTouchMode && focusedView != null && !focusedView.isFocusableInTouchMode) {
setFocusedView(focusedView.findNearestFocusableReactView())
}
}

private fun isWithinTouchSlop(start: PointF, motionEvent: MotionEvent): Boolean {
val deltaX = motionEvent.x - start.x
val deltaY = motionEvent.y - start.y
return deltaX * deltaX + deltaY * deltaY <= touchSlop * touchSlop
}

private fun findTappedView(motionEvent: MotionEvent): View? =
TouchTargetHelper.findTargetPathAndCoordinatesForTouch(
motionEvent.x,
motionEvent.y,
rootViewGroup,
FloatArray(2),
)
.firstNotNullOfOrNull { target -> target.getView() }
?.findNearestFocusableReactView()

private fun setFocusedView(view: View?) {
rootViewGroup.rootView.setTag(R.id.tap_focused_view, view?.let(::TappedView))
}

private class TappedView(view: View) {
private val viewReference = WeakReference(view)
private val viewTag = view.id

/**
* The tapped view while it could still hold focus and [rootViewGroup] is the closest React root
* above it. A view recycled under another tag since the tap stands for another component.
*/
fun findIn(rootViewGroup: ViewGroup): View? {
val view = viewReference.get() ?: return null
if (view.id != viewTag || !view.isShown || !view.isFocusableReactView()) {
return null
}
var parent = view.parent
while (parent != null && parent !is RootView) {
parent = parent.parent
}
return if (parent === rootViewGroup) view else null
}
}

companion object {
/** The view the window of [rootViewGroup] holds focused for a browser, if it is inside it. */
fun findFocusedView(rootViewGroup: ViewGroup): View? =
(rootViewGroup.rootView.getTag(R.id.tap_focused_view) as? TappedView)?.findIn(rootViewGroup)
}
}

/** A view without a positive id is no React view, so no JS event can name it. */
private fun View.isFocusableReactView(): Boolean = id > 0 && isFocusable && isEnabled

private fun View.findNearestFocusableReactView(): View? =
generateSequence(this) { it.parent as? View }.firstOrNull { it.isFocusableReactView() }
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,7 @@

<!-- tag is used to store the safe area insets observer attached to the view -->
<item type="id" name="safe_area_insets_observer"/>

<!-- tag is used to store the view the last tap in the window focused -->
<item type="id" name="tap_focused_view"/>
</resources>
Loading
Loading