Skip to content

[General] Implement basic button interactions using native primitives#4032

Open
j-piasecki wants to merge 25 commits intomainfrom
@jpiasecki/native-button-interactions
Open

[General] Implement basic button interactions using native primitives#4032
j-piasecki wants to merge 25 commits intomainfrom
@jpiasecki/native-button-interactions

Conversation

@j-piasecki
Copy link
Member

@j-piasecki j-piasecki commented Mar 18, 2026

Description

Implements basic button interactions (scale, opacity, underlay) using the native primitives and animations instead of relying on JS:

  • ObjectAnimator and Drawable on Android
  • CoreAnimation and CALayer on iOS/macOS
  • CSS transitions on web

Test plan

Tested on this
import React from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import GestureHandlerButton from 'react-native-gesture-handler/src/components/GestureHandlerButton';
import createNativeWrapper from 'react-native-gesture-handler/src/v3/createNativeWrapper';

const RawButton = createNativeWrapper(GestureHandlerButton, {
  shouldCancelWhenOutside: false,
  shouldActivateOnStart: false,
});

function ButtonRow({
  label,
  children,
}: {
  label: string;
  children: React.ReactNode;
}) {
  return (
    <View style={styles.row}>
      <Text style={styles.label}>{label}</Text>
      {children}
    </View>
  );
}

export default function EmptyExample() {
  return (
    <GestureHandlerRootView>
      <ScrollView contentContainerStyle={styles.container}>
        {/* ── Opacity ─────────────────────────────────────────── */}
        <ButtonRow label="Opacity: defaultOpacity=1 → activeOpacity=0.2 (strong fade)">
          <RawButton
            style={[
              styles.button,
              { transform: [{ rotate: '5deg' }, { scale: 0.75 }] },
            ]}
            defaultOpacity={1}
            activeOpacity={0.2}
            animationDuration={600}
            rippleColor="transparent">
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        <ButtonRow label="Opacity: defaultOpacity=0.5 → activeOpacity=1 (fade in on press)">
          <RawButton
            style={styles.button}
            defaultOpacity={0.5}
            activeOpacity={1}
            animationDuration={200}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        <ButtonRow label="Opacity: defaultOpacity=1 → activeOpacity=0 (disappears on press)">
          <RawButton
            style={styles.button}
            defaultOpacity={1}
            activeOpacity={0}
            animationDuration={150}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        {/* ── Scale ───────────────────────────────────────────── */}
        <ButtonRow label="Scale: defaultScale=1 → activeScale=0.85 (shrink, fast)">
          <RawButton
            style={[styles.button, { opacity: 0.5 }]}
            defaultScale={1}
            activeScale={0.85}
            animationDuration={150}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        <ButtonRow label="Scale: defaultScale=0.9 → activeScale=1.05 (grow on press)">
          <RawButton
            style={styles.button}
            defaultScale={0.9}
            activeScale={1.05}
            animationDuration={200}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        <ButtonRow label="Scale: defaultScale=1 → activeScale=0.7 (heavy squish, slow)">
          <RawButton
            style={styles.button}
            defaultScale={1}
            activeScale={0.7}
            animationDuration={500}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        {/* ── Underlay ────────────────────────────────────────── */}
        <ButtonRow label="Underlay: royalblue, defaultUnderlayOpacity=0 → activeUnderlayOpacity=0.4">
          <RawButton
            style={styles.button}
            underlayColor="royalblue"
            defaultUnderlayOpacity={0}
            activeUnderlayOpacity={0.4}
            animationDuration={200}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        <ButtonRow label="Underlay: gold, defaultUnderlayOpacity=0.1 → activeUnderlayOpacity=0.9">
          <RawButton
            style={styles.button}
            underlayColor="gold"
            defaultUnderlayOpacity={0.1}
            activeUnderlayOpacity={0.9}
            animationDuration={300}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        <ButtonRow label="Underlay: limegreen, defaultUnderlayOpacity=0.3 → activeUnderlayOpacity=0.3 (static)">
          <RawButton
            style={styles.button}
            underlayColor="limegreen"
            defaultUnderlayOpacity={0.3}
            activeUnderlayOpacity={0.3}
            animationDuration={200}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        {/* ── Opacity + Scale ─────────────────────────────────── */}
        <ButtonRow label="Opacity + Scale: fade out while shrinking (slow)">
          <RawButton
            style={styles.button}
            defaultOpacity={1}
            activeOpacity={0.4}
            defaultScale={1}
            activeScale={0.9}
            animationDuration={600}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        <ButtonRow label="Opacity + Scale: already dim, grows and brightens on press">
          <RawButton
            style={styles.button}
            defaultOpacity={0.4}
            activeOpacity={1}
            defaultScale={0.95}
            activeScale={1.05}
            animationDuration={200}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        {/* ── Opacity + Underlay ──────────────────────────────── */}
        <ButtonRow label="Opacity + Underlay: fades while tomato underlay sweeps in">
          <RawButton
            style={styles.button}
            defaultOpacity={1}
            activeOpacity={0.6}
            underlayColor="tomato"
            defaultUnderlayOpacity={0}
            activeUnderlayOpacity={0.5}
            animationDuration={250}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        {/* ── Scale + Underlay ────────────────────────────────── */}
        <ButtonRow label="Scale + Underlay: shrinks and mediumpurple underlay appears">
          <RawButton
            style={styles.button}
            defaultScale={1}
            activeScale={0.9}
            underlayColor="mediumpurple"
            defaultUnderlayOpacity={0}
            activeUnderlayOpacity={0.45}
            animationDuration={200}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        {/* ── All props combined ──────────────────────────────── */}
        <ButtonRow label="All props: subtle feedback (opacity + scale + tomato underlay)">
          <RawButton
            style={styles.button}
            defaultOpacity={1}
            activeOpacity={0.6}
            defaultScale={1}
            activeScale={0.92}
            underlayColor="tomato"
            defaultUnderlayOpacity={0}
            activeUnderlayOpacity={0.35}
            animationDuration={250}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        <ButtonRow label="All props: non-default start state (dim + small, active is full)">
          <RawButton
            style={styles.button}
            defaultOpacity={0.5}
            activeOpacity={1}
            defaultScale={0.85}
            activeScale={1}
            underlayColor="deepskyblue"
            defaultUnderlayOpacity={0.2}
            activeUnderlayOpacity={0.6}
            animationDuration={300}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        <ButtonRow label="All props: bouncy grow + teal underlay + instant (duration=80)">
          <RawButton
            style={styles.button}
            defaultOpacity={1}
            activeOpacity={0.8}
            defaultScale={1}
            activeScale={1.08}
            underlayColor="teal"
            defaultUnderlayOpacity={0}
            activeUnderlayOpacity={0.5}
            animationDuration={80}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>

        <ButtonRow label="All props: start dim+large, active invisible+tiny (extreme)">
          <RawButton
            style={styles.button}
            defaultOpacity={0.5}
            activeOpacity={0}
            defaultScale={0.8}
            activeScale={0.92}
            underlayColor="tomato"
            defaultUnderlayOpacity={0}
            activeUnderlayOpacity={0.35}
            animationDuration={250}>
            <Text style={styles.text}>Press me</Text>
          </RawButton>
        </ButtonRow>
      </ScrollView>
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 24,
    gap: 24,
    paddingTop: 100,
  },
  row: {
    gap: 8,
  },
  label: {
    fontSize: 12,
    color: '#666',
    fontWeight: '500',
  },
  button: {
    backgroundColor: '#e0e0e0',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
  },
  text: {
    fontSize: 16,
    fontWeight: '600',
  },
});

Copilot AI review requested due to automatic review settings March 18, 2026 12:04
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds configurable “pressed” interactions (opacity, scale, underlay) to GestureHandlerButton implemented with platform-native animation primitives (Android ObjectAnimator, Apple CoreAnimation/layer animation, web CSS transitions), and adjusts web pointer-capture behavior for button-role views.

Changes:

  • Introduces new button interaction props (animationDuration, activeOpacity/Scale/UnderlayOpacity, startOpacity/Scale/UnderlayOpacity, underlayColor) in the JS types and native codegen spec.
  • Implements press-in / press-out animations natively on Android and Apple (including an underlay layer/drawable).
  • Implements web press visuals via pointer events + CSS transitions, and updates pointer-capture logic to treat role="button" differently.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts Skips pointer capture when the managed view is role="button".
packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts Adds codegen props for interaction animations and underlay.
packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx Adds pressed state handling + CSS transitions + underlay rendering on web.
packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx Exposes new interaction props on the public ButtonProps interface.
packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.mm Removes legacy iOS view manager implementation.
packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.h Removes legacy iOS view manager header.
packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm Wires new props into the Fabric component view and applies initial animation state.
packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm Implements Apple-side press animations and underlay layer handling.
packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h Declares new interaction/underlay properties and helpers.
packages/react-native-gesture-handler/apple/RNGHUIKit.h Adds a cross-platform RNGHColor typedef for UIColor/NSColor.
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt Implements Android-side press animations and underlay drawable layering.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@j-piasecki j-piasecki requested a review from Copilot March 19, 2026 11:14
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds native-driven (or platform-primitive) press feedback for GestureHandlerButton by introducing new animation/underlay props and implementing the corresponding behavior on Android, Apple platforms, and web, along with a small web pointer-capture adjustment to support button-like interactions.

Changes:

  • Add new press-animation props (animationDuration, active*, start*, underlayColor) to the button spec/types and propagate them into native implementations.
  • Implement press animations using native primitives (Android ObjectAnimator/Drawable, Apple CALayer/CoreAnimation, web CSS transitions).
  • Adjust web pointer-capture behavior to avoid capturing pointers for elements with role="button".

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts Skip pointer capture when the managed view has role="button".
packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts Update v3 RawButtonProps typing to omit new animation props.
packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx Type RawButton wrapper with RawButtonProps.
packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts Add new codegen props with defaults for native button interactions.
packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx Implement pressed-state visuals via CSS transitions + optional underlay.
packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx Add wrapper “resting” style derived from startOpacity/startScale.
packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.mm Remove legacy view manager implementation (file deleted).
packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.h Remove legacy view manager header (file deleted).
packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm Wire new props into the native button view and apply initial state.
packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm Implement underlay + press animations using CoreAnimation/UIView/NSAnimationContext.
packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h Declare new animation/underlay properties and applyStartAnimationState.
packages/react-native-gesture-handler/apple/RNGHUIKit.h Add cross-platform RNGHColor typedef (UIColor/NSColor).
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt Add animation/underlay props + implement press animations with animators/drawables.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@j-piasecki j-piasecki marked this pull request as ready for review March 19, 2026 11:52
shouldCancelWhenOutside: false,
shouldActivateOnStart: false,
});
export const RawButton = createNativeWrapper<unknown, RawButtonProps>(
Copy link
Contributor

Choose a reason for hiding this comment

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

Why unknown?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is the type that was previously being resolved. I don't think we have an explicit "bare" button ref, no?

Copy link
Contributor

Choose a reason for hiding this comment

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

There's this discussion in Clickable, but I guess we can change that there.

Copy link
Contributor

@m-bert m-bert left a comment

Choose a reason for hiding this comment

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

❤️

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.

3 participants