Skip to content

[Android] Dispatch onPressIn when the native press starts - #4575

Merged
m-bert merged 6 commits into
@mbert/docs-press-out-after-animationfrom
@mbert/android-press-in-on-pressed
Oct 12, 2026
Merged

m-bert merged 6 commits into
@mbert/docs-press-out-after-animationfrom
@mbert/android-press-in-on-pressed

Conversation

@m-bert

@m-bert m-bert commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Description

On Android onPressIn was sent when the Native gesture began, i.e. on the DOWN. The platform defers the pressed state inside scrolling containers and drops it when a parent intercepts the touch, so a tap that stops a fling fired onPressIn and onPressOut with no onPress, and feedback driven from onPressIn ran ahead of the native ripple (#4515).

onPressIn and the long press timer now follow setPressed(true), the moment the native press feedback starts. The long press timer is still measured from the DOWN. onPressOut, onPress and the pressOutAfterAnimation hold are unchanged.

Fixes #4515

Test plan

Tested on the following code:
import React, { useRef, useState } from 'react';
import {
  Animated,
  FlatList as RNFlatList,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import { FlatList, Touchable } from 'react-native-gesture-handler';

// onPressIn follows the native press (#4515, #4547). Every row scales down from onPressIn and
// back from onPressOut, next to the native ripple, so the two should start together.
//  - Fling the list, then stop it with a finger on a row and lift: no callback should fire.
//  - Start a fast swipe on a row: no onPressIn. Press, hold, then drag: onPressIn with the
//    ripple, onPressOut when the scroll takes over.
//  - Hold a row: onLongPress at ~500 ms, in both lists.
const ROWS = Array.from({ length: 200 }, (_, i) => i);
const LONG_PRESS_MS = 500;
const COLORS = ['#6750a4', '#0b6e4f', '#b3261e', '#1d4ed8', '#b45309'];

type Counts = {
  pressIn: number;
  pressOut: number;
  press: number;
  longPress: number;
};

const EMPTY_COUNTS: Counts = {
  pressIn: 0,
  pressOut: 0,
  press: 0,
  longPress: 0,
};

function Row({
  index,
  count,
}: {
  index: number;
  count: (key: keyof Counts) => void;
}) {
  const scale = useRef(new Animated.Value(1)).current;
  const animateTo = (value: number) =>
    Animated.timing(scale, {
      toValue: value,
      duration: 100,
      useNativeDriver: true,
    }).start();
  const color = COLORS[index % COLORS.length];

  return (
    <Touchable
      androidRipple={{ color: 'rgba(0, 0, 0, 0.12)' }}
      delayLongPress={LONG_PRESS_MS}
      onPressIn={() => {
        count('pressIn');
        animateTo(0.95);
      }}
      onPressOut={() => {
        count('pressOut');
        animateTo(1);
      }}
      onPress={() => count('press')}
      onLongPress={() => count('longPress')}>
      <Animated.View style={[styles.row, { transform: [{ scale }] }]}>
        <View style={[styles.avatar, { backgroundColor: color }]}>
          <Text style={styles.avatarText}>{index}</Text>
        </View>
        <View>
          <Text style={styles.rowTitle}>Row {index}</Text>
          <Text style={styles.rowSubtitle}>
            scales on onPressIn, ripple on native press
          </Text>
        </View>
      </Animated.View>
    </Touchable>
  );
}

function Stat({ label, value }: { label: string; value: number }) {
  return (
    <View style={styles.stat}>
      <Text style={styles.statValue}>{value}</Text>
      <Text style={styles.statLabel}>{label}</Text>
    </View>
  );
}

export default function PressInNativeTimingExample() {
  const [useRNGHList, setUseRNGHList] = useState(true);
  const [counts, setCounts] = useState<Counts>(EMPTY_COUNTS);
  const count = (key: keyof Counts) =>
    setCounts((c) => ({ ...c, [key]: c[key] + 1 }));

  const List = useRNGHList ? FlatList : RNFlatList;

  return (
    <View style={styles.root}>
      <View style={styles.panel}>
        <View style={styles.stats}>
          <Stat label="pressIn" value={counts.pressIn} />
          <Stat label="pressOut" value={counts.pressOut} />
          <Stat label="press" value={counts.press} />
          <Stat label="longPress" value={counts.longPress} />
        </View>
        <View style={styles.actions}>
          <Touchable
            style={styles.button}
            onPress={() => setUseRNGHList((v) => !v)}>
            <Text style={styles.buttonText}>
              {useRNGHList ? 'RNGH FlatList' : 'RN FlatList'}
            </Text>
          </Touchable>
          <Touchable
            style={[styles.button, styles.buttonSecondary]}
            onPress={() => setCounts(EMPTY_COUNTS)}>
            <Text style={[styles.buttonText, styles.buttonSecondaryText]}>
              Reset
            </Text>
          </Touchable>
        </View>
        <Text style={styles.hint}>
          Fling, then stop the list with a finger on a row: nothing should
          count. Hold a row for onLongPress.
        </Text>
      </View>
      <List
        key={useRNGHList ? 'rngh' : 'rn'}
        data={ROWS}
        keyExtractor={(i) => String(i)}
        contentContainerStyle={styles.content}
        renderItem={({ item }) => <Row index={item} count={count} />}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: '#f4f3f8' },
  panel: {
    margin: 16,
    marginBottom: 8,
    padding: 16,
    borderRadius: 16,
    backgroundColor: '#fff',
    gap: 12,
    shadowColor: '#000',
    shadowOpacity: 0.08,
    shadowRadius: 12,
    shadowOffset: { width: 0, height: 4 },
    elevation: 3,
  },
  stats: { flexDirection: 'row', justifyContent: 'space-between' },
  stat: { alignItems: 'flex-start' },
  statValue: { fontSize: 24, fontWeight: '700', color: '#1c1b1f' },
  statLabel: { fontSize: 12, color: '#79747e' },
  actions: { flexDirection: 'row', gap: 8 },
  button: {
    flex: 1,
    alignItems: 'center',
    paddingVertical: 10,
    borderRadius: 10,
    backgroundColor: '#6750a4',
  },
  buttonText: { color: '#fff', fontWeight: '600' },
  buttonSecondary: {
    flex: 0,
    paddingHorizontal: 16,
    backgroundColor: '#e8e8ee',
  },
  buttonSecondaryText: { color: '#1c1b1f' },
  hint: { color: '#5f5c66', fontSize: 13, lineHeight: 18 },
  content: { paddingHorizontal: 16, paddingBottom: 16, gap: 8 },
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
    padding: 12,
    borderRadius: 14,
    backgroundColor: '#fff',
  },
  avatar: {
    width: 40,
    height: 40,
    borderRadius: 20,
    alignItems: 'center',
    justifyContent: 'center',
  },
  avatarText: { color: '#fff', fontWeight: '700' },
  rowTitle: { fontSize: 16, fontWeight: '600', color: '#1c1b1f' },
  rowSubtitle: { fontSize: 12, color: '#79747e', marginTop: 2 },
});

@m-bert
m-bert added this pull request to stack #4571 October 6, 2026 17:14
Copilot AI balanced review requested due to automatic review settings October 6, 2026 17:14
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

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: 9c169f4d-ad5c-4a26-b0a0-6cba429d58f5

📥 Commits

Reviewing files that changed from the base of the PR and between cfd5ba9 and 932bed2.


📒 Files selected for processing (1)
  • packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 5 remain after this review.



📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Press-in and long-press events now follow the platform’s pressed-state timing, including when a press is deferred. Synthetic activations without tracked pointers also receive immediate press-in feedback.
    • Long-press timing accounts for time elapsed since the initial touch, keeping delays consistent. Re-entering the pressed state can trigger press-in feedback again without restarting the long-press timer.
📝 Summary

Walkthrough

On Android, touch-driven PressIn dispatch and long-press scheduling now follow the button’s pressed state. The manager tracks touch-down time to calculate the remaining long-press delay.

Changes

Android pressed-state handling

Layer / File(s) Summary
Pressed-state event and timer flow
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt
The manager records touch-down time and resets press tracking when the handler enters BEGAN. For touch-driven presses, it dispatches PressIn when the view enters its pressed state and schedules the long-press timer once per press sequence, using the remaining time from touch-down. Gestures without tracked pointers dispatch PressIn immediately.

Suggested reviewers: j-piasecki


Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 932be

No actionable issue was established in the changed Android press timing; the PR is mergeable after normal checks.

Pre-merge checks | Passed 3
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check Passed For #4515, Android ButtonViewGroup now dispatches touch-driven PressIn from onPressedStateStarted, when native pressed feedback starts. The implementation records the original DOWN time and sc…
Out of Scope Changes check Passed The available whole-PR summary identifies changes only in RNGestureHandlerButtonViewManager.kt. Press-state tracking, deferred PressIn dispatch, and long-press scheduling directly support #4515 an…
Title check Passed The title clearly and concisely describes the main Android change: dispatching onPressIn when native press feedback starts.

  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

🟢 Approval recommended

The lifecycle changes correctly preserve timing and cancellation behavior while addressing deferred native presses.

Review effort: Balanced
Findings: None

What changed in this PR

Aligns Android v3 Touchable callbacks with native pressed-state feedback.

Changes:

  • Dispatches onPressIn when setPressed(true) occurs.
  • Measures long-press delay from the original down time.
  • Avoids press callbacks when scrolling intercepts the touch.
File Description
RNGestureHandlerButtonViewManager.kt Synchronizes press callbacks and long-press timing with native feedback.

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

@m-bert m-bert changed the title [Android] Dispatch onPressIn when the native press starts [Android] Dispatch onPressIn when the native press starts Oct 8, 2026
@m-bert
m-bert requested a review from j-piasecki October 12, 2026 08:51
@m-bert
m-bert merged commit 4727219 into main Oct 12, 2026
5 checks passed
@m-bert
m-bert deleted the @mbert/android-press-in-on-pressed branch October 12, 2026 11:24
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.

Expose native visual pressed state for custom animations inside scroll views

3 participants