Skip to content

Commit 4a7639d

Browse files
authored
[General] Implement basic button interactions using native primitives (#4032)
## 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 <details> <summary>Tested on this</summary> ```jsx 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', }, }); ``` </details>
1 parent 1a7aea1 commit 4a7639d

15 files changed

+628
-123
lines changed

packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.swmansion.gesturehandler.react
22

3+
import android.animation.Animator
4+
import android.animation.AnimatorSet
5+
import android.animation.ObjectAnimator
36
import android.annotation.SuppressLint
47
import android.annotation.TargetApi
58
import android.content.Context
@@ -19,10 +22,10 @@ import android.util.TypedValue
1922
import android.view.KeyEvent
2023
import android.view.MotionEvent
2124
import android.view.View
22-
import android.view.View.OnClickListener
2325
import android.view.ViewGroup
2426
import android.view.accessibility.AccessibilityNodeInfo
2527
import androidx.core.view.children
28+
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
2629
import com.facebook.react.R
2730
import com.facebook.react.module.annotations.ReactModule
2831
import com.facebook.react.uimanager.PixelUtil
@@ -133,6 +136,46 @@ class RNGestureHandlerButtonViewManager :
133136
view.isSoundEffectsEnabled = !touchSoundDisabled
134137
}
135138

139+
@ReactProp(name = "animationDuration")
140+
override fun setAnimationDuration(view: ButtonViewGroup, animationDuration: Int) {
141+
view.animationDuration = animationDuration
142+
}
143+
144+
@ReactProp(name = "defaultOpacity")
145+
override fun setDefaultOpacity(view: ButtonViewGroup, defaultOpacity: Float) {
146+
view.defaultOpacity = defaultOpacity
147+
}
148+
149+
@ReactProp(name = "activeOpacity")
150+
override fun setActiveOpacity(view: ButtonViewGroup, targetOpacity: Float) {
151+
view.activeOpacity = targetOpacity
152+
}
153+
154+
@ReactProp(name = "defaultScale")
155+
override fun setDefaultScale(view: ButtonViewGroup, defaultScale: Float) {
156+
view.defaultScale = defaultScale
157+
}
158+
159+
@ReactProp(name = "activeScale")
160+
override fun setActiveScale(view: ButtonViewGroup, activeScale: Float) {
161+
view.activeScale = activeScale
162+
}
163+
164+
@ReactProp(name = "underlayColor")
165+
override fun setUnderlayColor(view: ButtonViewGroup, underlayColor: Int?) {
166+
view.underlayColor = underlayColor
167+
}
168+
169+
@ReactProp(name = "defaultUnderlayOpacity")
170+
override fun setDefaultUnderlayOpacity(view: ButtonViewGroup, defaultUnderlayOpacity: Float) {
171+
view.defaultUnderlayOpacity = defaultUnderlayOpacity
172+
}
173+
174+
@ReactProp(name = "activeUnderlayOpacity")
175+
override fun setActiveUnderlayOpacity(view: ButtonViewGroup, activeUnderlayOpacity: Float) {
176+
view.activeUnderlayOpacity = activeUnderlayOpacity
177+
}
178+
136179
@ReactProp(name = ViewProps.POINTER_EVENTS)
137180
override fun setPointerEvents(view: ButtonViewGroup, pointerEvents: String?) {
138181
view.pointerEvents = when (pointerEvents) {
@@ -212,6 +255,20 @@ class RNGestureHandlerButtonViewManager :
212255
borderBottomRightRadius != 0f
213256

214257
var exclusive = true
258+
var animationDuration: Int = 100
259+
var activeOpacity: Float = 1.0f
260+
var defaultOpacity: Float = 1.0f
261+
var activeScale: Float = 1.0f
262+
var defaultScale: Float = 1.0f
263+
var underlayColor: Int? = null
264+
set(color) = withBackgroundUpdate {
265+
field = color
266+
}
267+
var activeUnderlayOpacity: Float = 0f
268+
var defaultUnderlayOpacity: Float = 0f
269+
set(value) = withBackgroundUpdate {
270+
field = value
271+
}
215272

216273
override var pointerEvents: PointerEvents = PointerEvents.AUTO
217274

@@ -220,6 +277,8 @@ class RNGestureHandlerButtonViewManager :
220277
private var lastEventTime = -1L
221278
private var lastAction = -1
222279
private var receivedKeyEvent = false
280+
private var currentAnimator: AnimatorSet? = null
281+
private var underlayDrawable: PaintDrawable? = null
223282

224283
var isTouched = false
225284

@@ -331,7 +390,73 @@ class RNGestureHandlerButtonViewManager :
331390
return false
332391
}
333392

334-
private fun updateBackgroundColor(backgroundColor: Int, borderDrawable: Drawable, selectable: Drawable?) {
393+
private fun applyStartAnimationState() {
394+
(parent as? ViewGroup)?.let {
395+
if (activeOpacity != 1.0f || defaultOpacity != 1.0f) {
396+
it.alpha = defaultOpacity
397+
}
398+
if (activeScale != 1.0f || defaultScale != 1.0f) {
399+
it.scaleX = defaultScale
400+
it.scaleY = defaultScale
401+
}
402+
}
403+
underlayDrawable?.alpha = (defaultUnderlayOpacity * 255).toInt()
404+
}
405+
406+
private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float) {
407+
val hasOpacity = activeOpacity != 1.0f || defaultOpacity != 1.0f
408+
val hasScale = activeScale != 1.0f || defaultScale != 1.0f
409+
val hasUnderlay = activeUnderlayOpacity != defaultUnderlayOpacity && underlayDrawable != null
410+
if (!hasOpacity && !hasScale && !hasUnderlay) {
411+
return
412+
}
413+
414+
currentAnimator?.cancel()
415+
val animators = ArrayList<Animator>()
416+
if (hasOpacity || hasScale) {
417+
val parent = this.parent as? ViewGroup ?: return
418+
if (hasOpacity) {
419+
animators.add(ObjectAnimator.ofFloat(parent, "alpha", opacity))
420+
}
421+
if (hasScale) {
422+
animators.add(ObjectAnimator.ofFloat(parent, "scaleX", scale))
423+
animators.add(ObjectAnimator.ofFloat(parent, "scaleY", scale))
424+
}
425+
}
426+
if (hasUnderlay) {
427+
animators.add(ObjectAnimator.ofInt(underlayDrawable!!, "alpha", (underlayOpacity * 255).toInt()))
428+
}
429+
currentAnimator = AnimatorSet().apply {
430+
playTogether(animators)
431+
duration = animationDuration.toLong()
432+
interpolator = LinearOutSlowInInterpolator()
433+
start()
434+
}
435+
}
436+
437+
private fun animatePressIn() {
438+
animateTo(activeOpacity, activeScale, activeUnderlayOpacity)
439+
}
440+
441+
private fun animatePressOut() {
442+
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity)
443+
}
444+
445+
private fun createUnderlayDrawable(): PaintDrawable {
446+
val drawable = PaintDrawable(underlayColor ?: Color.BLACK)
447+
if (hasBorderRadii) {
448+
drawable.setCornerRadii(buildBorderRadii())
449+
}
450+
drawable.alpha = (defaultUnderlayOpacity * 255).toInt()
451+
return drawable
452+
}
453+
454+
private fun updateBackgroundColor(
455+
backgroundColor: Int,
456+
underlay: Drawable,
457+
borderDrawable: Drawable,
458+
selectable: Drawable?,
459+
) {
335460
val colorDrawable = PaintDrawable(backgroundColor)
336461

337462
if (hasBorderRadii) {
@@ -340,9 +465,9 @@ class RNGestureHandlerButtonViewManager :
340465

341466
val layerDrawable = LayerDrawable(
342467
if (selectable != null) {
343-
arrayOf(colorDrawable, selectable, borderDrawable)
468+
arrayOf(colorDrawable, underlay, selectable, borderDrawable)
344469
} else {
345-
arrayOf(colorDrawable, borderDrawable)
470+
arrayOf(colorDrawable, underlay, borderDrawable)
346471
},
347472
)
348473
background = layerDrawable
@@ -365,6 +490,8 @@ class RNGestureHandlerButtonViewManager :
365490

366491
val selectable = createSelectableDrawable()
367492
val borderDrawable = createBorderDrawable()
493+
val underlay = createUnderlayDrawable()
494+
underlayDrawable = underlay
368495

369496
if (hasBorderRadii && selectable is RippleDrawable) {
370497
val mask = PaintDrawable(Color.WHITE)
@@ -375,13 +502,15 @@ class RNGestureHandlerButtonViewManager :
375502
if (useDrawableOnForeground && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
376503
foreground = selectable
377504
if (buttonBackgroundColor != Color.TRANSPARENT) {
378-
updateBackgroundColor(buttonBackgroundColor, borderDrawable, null)
505+
updateBackgroundColor(buttonBackgroundColor, underlay, borderDrawable, null)
379506
}
380507
} else if (buttonBackgroundColor == Color.TRANSPARENT && rippleColor == null) {
381-
background = LayerDrawable(arrayOf(selectable, borderDrawable))
508+
background = LayerDrawable(arrayOf(underlay, selectable, borderDrawable))
382509
} else {
383-
updateBackgroundColor(buttonBackgroundColor, borderDrawable, selectable)
510+
updateBackgroundColor(buttonBackgroundColor, underlay, borderDrawable, selectable)
384511
}
512+
513+
applyStartAnimationState()
385514
}
386515

387516
private fun createBorderDrawable(): Drawable {
@@ -540,6 +669,12 @@ class RNGestureHandlerButtonViewManager :
540669
// is null or non-exclusive, assuming it doesn't have pressed children
541670
isTouched = pressed
542671
super.setPressed(pressed)
672+
673+
if (pressed) {
674+
animatePressIn()
675+
} else {
676+
animatePressOut()
677+
}
543678
}
544679

545680
if (!pressed && touchResponder === this) {

packages/react-native-gesture-handler/apple/RNGHUIKit.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ typedef UIWindow RNGHWindow;
77
typedef UIScrollView RNGHScrollView;
88
typedef UITouch RNGHUITouch;
99
typedef UIScrollView RNGHUIScrollView;
10+
typedef UIColor RNGHColor;
1011

1112
#define RNGHGestureRecognizerStateFailed UIGestureRecognizerStateFailed;
1213
#define RNGHGestureRecognizerStatePossible UIGestureRecognizerStatePossible;
@@ -23,6 +24,7 @@ typedef NSWindow RNGHWindow;
2324
typedef NSScrollView RNGHScrollView;
2425
typedef RCTUITouch RNGHUITouch;
2526
typedef NSScrollView RNGHUIScrollView;
27+
typedef NSColor RNGHColor;
2628

2729
#define RNGHGestureRecognizerStateFailed NSGestureRecognizerStateFailed;
2830
#define RNGHGestureRecognizerStatePossible NSGestureRecognizerStatePossible;

packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,27 @@
2828
@property (nonatomic) BOOL userEnabled;
2929
@property (nonatomic, assign) RNGestureHandlerPointerEvents pointerEvents;
3030

31+
@property (nonatomic, assign) NSInteger animationDuration;
32+
@property (nonatomic, assign) CGFloat activeOpacity;
33+
@property (nonatomic, assign) CGFloat defaultOpacity;
34+
@property (nonatomic, assign) CGFloat activeScale;
35+
@property (nonatomic, assign) CGFloat defaultScale;
36+
@property (nonatomic, assign) CGFloat defaultUnderlayOpacity;
37+
@property (nonatomic, assign) CGFloat activeUnderlayOpacity;
38+
@property (nonatomic, strong, nullable) RNGHColor *underlayColor;
39+
40+
/**
41+
* The view that press animations are applied to. Defaults to self; set by the
42+
* Fabric component view to its own instance so animations affect the full wrapper.
43+
*/
44+
@property (nonatomic, weak, nullable) RNGHUIView *animationTarget;
45+
46+
/**
47+
* Immediately applies the start* values to the animation target and underlay layer.
48+
* Call after props are updated to ensure the idle visual state is correct.
49+
*/
50+
- (void)applyStartAnimationState;
51+
3152
#if TARGET_OS_OSX
3253
- (void)mountChildComponentView:(RNGHUIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index;
3354
- (void)unmountChildComponentView:(RNGHUIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index;

0 commit comments

Comments
 (0)