Skip to content

Make the detector frame cover transformed children - #4532

Merged
m-bert merged 2 commits into
mainfrom
@mbert/detector-frame-child-transforms
Sep 21, 2026
Merged

m-bert merged 2 commits into
mainfrom
@mbert/detector-frame-child-transforms

Conversation

@m-bert

@m-bert m-bert commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Description

On Android, native views (Switch, TextInput, native ScrollView, sliders) inside a GestureDetector stop receiving touches once a transform moves them outside the detector's frame. Gestures attached to the detector keep working, since the orchestrator does its own transform-aware traversal (#4251), but the touch never reaches the native child.

The detector's frame is the bounding box of its children's layout frames. Transforms are not layout, so a transformed child can end up outside that frame. Android's ViewGroup only dispatches a touch to a child whose bounds contain it, so the touch is rejected at the detector before the child's transform is ever considered. This is a general RN Android limitation for any parent view tighter than its transformed content, but layout-only wrappers get flattened and the detector never is, so GestureDetector is where users hit it. iOS is unaffected, since the detector forces a non-zero overflowInset there and RN's hit test then searches outside the bounds.

The fix extends the frame with each child's transformed frame and recomputes overflowInset against the resulting frame, since the default value is computed against the zero-size frame of a display: contents node. The untransformed frame stays in the union, so the orchestrator's child-space check from #4251 still covers the vacated area.

The frame follows a transform only when layout runs: at mount, on React commits, and when something else re-lays out the animated subtree, such as a measurable child like Switch or TextInput. A purely Reanimated-driven transform with no such trigger keeps a stale frame until the next layout. Following it every frame costs a Yoga pass per detector, measured at 6x worse frame times.

Fixes #4529

Performance

Performance on a Pixel 9 Pro emulator, release build, 1000 detectors animating at once, frame times from dumpsys gfxinfo over 10 s:

card content build median frame time 90th percentile frames rendered
Text main 48 ms 65 ms 372
Text this PR 48 ms 65 ms 372
Switch main 200 ms 200 ms 101
Switch this PR 250 ms 300 ms 71

Text cards never re-layout, so the cost is zero. Switch cards make RN run layout every frame on main already, and this PR adds the frame and inset mutation on top. That is the only measured cost and it only applies to many detectors animating simultaneously with measurable leaves inside.

Test plan

Tested on the following code:
import React, { useState } from 'react';
import {
  Pressable,
  ScrollView as RNScrollView,
  StyleSheet,
  Switch,
  Text,
  View,
} from 'react-native';
import {
  GestureDetector,
  InterceptingGestureDetector,
  Switch as GHSwitch,
  usePanGesture,
  useTapGesture,
  VirtualGestureDetector,
} from 'react-native-gesture-handler';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

// Issue #4529, Android only. The v3 `GestureDetector` host view's frame is the
// bounding box of its children's layout frames; transforms are not layout, so a
// child moved by `translateX` ends up outside the detector's frame. Android's
// `ViewGroup.dispatchTouchEvent` only hands a touch to a child whose bounds
// contain it, so a touch over the translated content is rejected at the
// detector and never reaches the native views inside it (`Switch`, `TextInput`,
// sliders...). Gestures attached to the detector still fire, because the
// orchestrator does its own transform-aware traversal (#4251). iOS is not
// affected: the detector forces a non-zero overflowInset, so RN's hitTest keeps
// looking at subviews outside the detector's bounds.
//
// The tap gestures need 3 taps, so a single tap never activates them and
// cannot cancel the native press - what you see is pure touch delivery.
//
// Steps (Android): single-tap the blue box in each row.
//   Row 1: Switch inside GestureDetector - stays OFF. Triple-tap the same box:
//          "detector taps" goes up, so the detector's own gesture still works.
//   Row 2: same, but the translated View sits in a plain 100x70 View with
//          collapsable={false} and no detector - dead too. Without
//          collapsable={false} RN flattens the wrapper away and it works.
//   Row 3: RN Pressable inside GestureDetector - works (RN's JS touch target
//          search passes thanks to the detector's oversized overflowInset).
//   Row 4: Switch inside VirtualGestureDetector (no host view) - toggles.
//   Row 5: RNGH Switch inside GestureDetector - toggles (touches come
//          from NativeViewGestureHandler, not from Android dispatch).
//   Row 6: Reanimated card (pan to drag, or press "slide" to move it by 160)
//          with an RN Switch inside, in a GestureDetector. Slide it right,
//          then tap the Switch: stays OFF. Slide back: toggles.
//   Row 7: card (moved only by its slide button) holding a native horizontal
//          ScrollView. Slide it and scroll the strip right away: dead.
//   Row 8: like row 6, but the card slides over a grey sibling block. Slide
//          it, then tap the Switch: stays OFF.
// Expected: every row reacts to the tap.

export default function EmptyExample() {
  const [detectorSwitch, setDetectorSwitch] = useState(false);
  const [plainSwitch, setPlainSwitch] = useState(false);
  const [virtualSwitch, setVirtualSwitch] = useState(false);
  const [ghSwitch, setGhSwitch] = useState(false);
  const [cardSwitch, setCardSwitch] = useState(false);
  const cardX = useSharedValue(0);
  const cardStartX = useSharedValue(0);
  const stripX = useSharedValue(0);
  const blockedX = useSharedValue(0);
  const [blockedSwitch, setBlockedSwitch] = useState(false);
  const [presses, setPresses] = useState(0);
  const [detectorTaps, setDetectorTaps] = useState(0);

  const switchTap = useTapGesture({
    numberOfTaps: 3,
    runOnJS: true,
    onActivate: () => setDetectorTaps((n) => n + 1),
  });

  const pressableTap = useTapGesture({
    numberOfTaps: 3,
    runOnJS: true,
    onActivate: () => setDetectorTaps((n) => n + 1),
  });

  const virtualTap = useTapGesture({
    numberOfTaps: 3,
    runOnJS: true,
    onActivate: () => setDetectorTaps((n) => n + 1),
  });

  const ghSwitchTap = useTapGesture({
    numberOfTaps: 3,
    runOnJS: true,
    onActivate: () => setDetectorTaps((n) => n + 1),
  });

  const cardPan = usePanGesture({
    onActivate: () => {
      cardStartX.value = cardX.value;
    },
    onUpdate: (e) => {
      cardX.value = cardStartX.value + e.translationX;
    },
  });

  const cardStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: cardX.value }],
  }));

  const slideCard = () => {
    cardX.value = withTiming(cardX.value > 80 ? 0 : 160);
  };

  // Tap (3 taps) instead of pan so the native ScrollView keeps its scrolling.
  const stripTap = useTapGesture({
    numberOfTaps: 3,
    runOnJS: true,
    onActivate: () => setDetectorTaps((n) => n + 1),
  });

  const stripStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: stripX.value }],
  }));

  const slideStrip = () => {
    stripX.value = withTiming(stripX.value > 80 ? 0 : 160);
  };

  const blockedTap = useTapGesture({
    numberOfTaps: 3,
    runOnJS: true,
    onActivate: () => setDetectorTaps((n) => n + 1),
  });

  const blockedStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: blockedX.value }],
  }));

  const slideBlocked = () => {
    blockedX.value = withTiming(blockedX.value > 80 ? 0 : 160);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.status} testID="status">
        detector switch: {detectorSwitch ? 'ON' : 'OFF'}, plain switch:{' '}
        {plainSwitch ? 'ON' : 'OFF'}, virtual switch:{' '}
        {virtualSwitch ? 'ON' : 'OFF'}, GH switch: {ghSwitch ? 'ON' : 'OFF'},{' '}
        card switch: {cardSwitch ? 'ON' : 'OFF'}, blocked switch:{' '}
        {blockedSwitch ? 'ON' : 'OFF'}, presses: {presses}, detector taps:{' '}
        {detectorTaps}
      </Text>

      <Text style={styles.label}>1. Switch inside GestureDetector</Text>
      <View style={styles.row}>
        <GestureDetector gesture={switchTap}>
          <View style={styles.translated} testID="detector-switch-box">
            <Switch
              value={detectorSwitch}
              onValueChange={setDetectorSwitch}
              testID="detector-switch"
            />
          </View>
        </GestureDetector>
      </View>

      <Text style={styles.label}>
        2. Switch in a plain 100x70 View (no detector)
      </Text>
      <View style={styles.row}>
        <View style={styles.wrapper} collapsable={false} testID="plain-wrapper">
          <View style={styles.translated} testID="plain-switch-box">
            <Switch
              value={plainSwitch}
              onValueChange={setPlainSwitch}
              testID="plain-switch"
            />
          </View>
        </View>
      </View>

      <Text style={styles.label}>3. RN Pressable inside GestureDetector</Text>
      <View style={styles.row}>
        <GestureDetector gesture={pressableTap}>
          <View style={styles.translated} testID="detector-pressable-box">
            <Pressable
              style={styles.pressable}
              onPress={() => setPresses((n) => n + 1)}
              testID="detector-pressable">
              <Text style={styles.pressableText}>press</Text>
            </Pressable>
          </View>
        </GestureDetector>
      </View>

      <Text style={styles.label}>4. Switch inside VirtualGestureDetector</Text>
      <InterceptingGestureDetector>
        <View style={styles.row}>
          <VirtualGestureDetector gesture={virtualTap}>
            <View style={styles.translated} testID="virtual-switch-box">
              <Switch
                value={virtualSwitch}
                onValueChange={setVirtualSwitch}
                testID="virtual-switch"
              />
            </View>
          </VirtualGestureDetector>
        </View>
      </InterceptingGestureDetector>

      <Text style={styles.label}>5. RNGH Switch inside GestureDetector</Text>
      <View style={styles.row}>
        <GestureDetector gesture={ghSwitchTap}>
          <View style={styles.translated} testID="gh-switch-box">
            <GHSwitch
              value={ghSwitch}
              onValueChange={setGhSwitch}
              testID="gh-switch"
            />
          </View>
        </GestureDetector>
      </View>

      <Text style={styles.label}>6. Reanimated card with RN Switch inside</Text>
      <View style={styles.row}>
        <GestureDetector gesture={cardPan}>
          <Animated.View
            style={[styles.card, cardStyle]}
            testID="animated-card">
            <Switch
              value={cardSwitch}
              onValueChange={setCardSwitch}
              testID="card-switch"
            />
          </Animated.View>
        </GestureDetector>
        <Pressable
          style={styles.slideButton}
          onPress={slideCard}
          testID="slide-card">
          <Text style={styles.pressableText}>slide</Text>
        </Pressable>
      </View>

      <Text style={styles.label}>
        7. Reanimated card with RN ScrollView inside
      </Text>
      <View style={styles.row}>
        <GestureDetector gesture={stripTap}>
          <Animated.View
            style={[styles.card, stripStyle]}
            testID="animated-strip">
            <RNScrollView horizontal style={styles.strip} testID="strip-scroll">
              {['#f6b914', '#21a37c', '#001a72', '#ff6259', '#8e44ad'].map(
                (color) => (
                  <View
                    key={color}
                    style={[styles.stripItem, { backgroundColor: color }]}
                  />
                )
              )}
            </RNScrollView>
          </Animated.View>
        </GestureDetector>
        <Pressable
          style={styles.slideButton}
          onPress={slideStrip}
          testID="slide-strip">
          <Text style={styles.pressableText}>slide</Text>
        </Pressable>
      </View>

      <Text style={styles.label}>
        8. Card with RN Switch sliding over a sibling
      </Text>
      <View style={styles.row}>
        <View style={styles.blocker} testID="blocker" />
        <GestureDetector gesture={blockedTap}>
          <Animated.View
            style={[styles.card, blockedStyle]}
            testID="blocked-card">
            <Switch
              value={blockedSwitch}
              onValueChange={setBlockedSwitch}
              testID="blocked-switch"
            />
          </Animated.View>
        </GestureDetector>
        <Pressable
          style={styles.slideButton}
          onPress={slideBlocked}
          testID="slide-blocked">
          <Text style={styles.pressableText}>slide</Text>
        </Pressable>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    paddingHorizontal: 16,
    paddingTop: 8,
  },
  status: {
    color: '#001a72',
    marginBottom: 6,
  },
  label: {
    color: '#001a72',
    fontWeight: '600',
    marginBottom: 2,
  },
  row: {
    height: 70,
    marginBottom: 4,
  },
  blocker: {
    position: 'absolute',
    left: 160,
    top: 0,
    width: 100,
    height: 70,
    borderRadius: 12,
    backgroundColor: '#c8c8d2',
  },
  wrapper: {
    width: 100,
    height: 70,
  },
  translated: {
    width: 100,
    height: 70,
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 12,
    backgroundColor: '#dbeafe',
    transform: [{ translateX: 160 }],
  },
  card: {
    width: 100,
    height: 70,
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 12,
    backgroundColor: '#fde68a',
  },
  strip: {
    width: 80,
    height: 40,
  },
  stripItem: {
    width: 40,
    height: 40,
    marginRight: 4,
  },
  slideButton: {
    position: 'absolute',
    right: 0,
    top: 15,
    paddingHorizontal: 16,
    paddingVertical: 10,
    borderRadius: 8,
    backgroundColor: '#001a72',
  },
  pressable: {
    paddingHorizontal: 16,
    paddingVertical: 10,
    borderRadius: 8,
    backgroundColor: '#001a72',
  },
  pressableText: {
    color: 'white',
    fontWeight: '600',
  },
});

Copilot AI lite review requested due to automatic review settings September 21, 2026 09:28
@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: e72ef550-fe0a-4400-9681-d442b34e5689

📥 Commits

Reviewing files that changed from the base of the PR and between 4c001cb and 7ab6e9a.

📒 Files selected for processing (1)
  • packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerDetectorShadowNode.cpp

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Improved gesture handler detector layout calculations on Android.
    • Updated bounding-box handling for children with transformed frames.
    • Added overflow inset calculations to better account for content extending beyond the detector’s bounds.

Walkthrough

The Android detector layout now includes transformed child frames when calculating bounds. It also recalculates overflow insets from content bounds after child positioning. Non-Android behavior remains unchanged.

Changes

Android detector bounds

Layer / File(s) Summary
Transformed bounds and overflow metrics
packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerDetectorShadowNode.cpp
Adds the transform header. Android extends detector bounds with transformed child frames. The layout recalculates overflowInset from content bounds after shifting children.

Priority: ➖ Normal

Severity of issue fixed: Medium

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: expanding the detector frame to include transformed children.
Linked Issues check ✅ Passed For [#4529], the Android layout code retains each child’s untransformed frame and also extends the detector bounds with frame * transform for non-identity transforms. It recomputes overflowInset a…
Out of Scope Changes check ✅ Passed The pull request changes only RNGestureHandlerDetectorShadowNode.cpp. The added transform handling and overflowInset calculation directly support Android native touch delivery for transformed chil…

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

One or more issues must be addressed before approval.

Review effort: Lite
Findings: None

What changed in this PR

Extends the Android Fabric GestureDetector frame to include transformed children, allowing native controls outside their layout bounds to receive touches.

Changes:

  • Includes transformed child bounds in detector geometry on Android.
  • Recomputes overflow insets after repositioning children.
  • Preserves untransformed bounds for existing gesture behavior.
File Description
packages/​react-native-gesture-handler/​shared/​shadowNodes/​react/​renderer/​components/​rngesturehandler_codegen/​RNGestureHandlerDetectorShadowNode.cpp Updated as part of this pull request.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@m-bert
m-bert merged commit 403cafa into main Sep 21, 2026
11 checks passed
@m-bert
m-bert deleted the @mbert/detector-frame-child-transforms branch September 21, 2026 10:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] Native views inside GestureDetector stop receiving touches once a transform moves them outside the detector's frame

3 participants