Skip to content

[General] Update button's animation duration props#4046

Draft
j-piasecki wants to merge 10 commits into@jpiasecki/refactor-buttonfrom
@jpiasecki/add-minimum-animation-duration
Draft

[General] Update button's animation duration props#4046
j-piasecki wants to merge 10 commits into@jpiasecki/refactor-buttonfrom
@jpiasecki/add-minimum-animation-duration

Conversation

@j-piasecki
Copy link
Copy Markdown
Member

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

Description

Native platforms delay touches when the button is inside a ScrollView. This may cause situations where press-out is triggered immediately after press-in, effectively running with no animation.

This PR:

  • changes the easing on web to better align with native platforms
  • changes animationDuration to pressAndHoldAnimationDuration
  • adds tapAnimationDuration

pressAndHoldAnimationDuration defaults to tapAnimationDuration when unspecified; tapAnimationDuration defaults to 100ms.

Press out handler now has three branches:

  1. pressDuration >= pressAndHoldAnimationDuration -> press out animation is started immediately with the duration of pressAndHoldAnimationDuration
  2. pressDuration * 2 in [tapAnimationDuration, pressAndHoldAnimationDuration] (*2 so there's at least half of tapAnimationDuration for the remaining animation) -> press out animation is started immediately with the duration of the press so far
  3. pressDuration < tapAnimationDuration / 2 -> pressIn animation is played fully in the remaining time in tapAnimationDuration, and pressOut is scheduled after with the duration of tapAnimationDuration

Test plan

Updated the button underlay example

Copy link
Copy Markdown
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

Adds a new minimumAnimationDuration prop to GestureHandlerButton to ensure the “pressed in” visual state remains visible for at least a minimum time (mitigating native touch delays inside scrollables that can cause immediate press-out).

Changes:

  • Introduces minimumAnimationDuration to the button’s codegen spec and TS props.
  • Implements minimum-duration press-out deferral on Web, iOS, and Android.
  • Updates the common-app underlay example to exercise the new prop.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts Adds minimumAnimationDuration to native component codegen props with a default.
packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx Implements min-duration press-out deferral for web button visuals.
packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx Documents the new prop on the public ButtonProps interface.
packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm Wires the new prop from Fabric props into the native button view.
packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm Adds iOS minimum-duration handling between press-in and press-out animations.
packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h Exposes minimumAnimationDuration on the native button class.
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt Adds Android prop setter + min-duration delayed press-out implementation.
apps/common-app/src/new_api/components/button_underlay/index.tsx Updates example props to demonstrate minimumAnimationDuration.

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

@j-piasecki j-piasecki requested a review from Copilot April 1, 2026 09:37
@j-piasecki j-piasecki changed the title [General] Add minimumAnimationDuration prop to button component [General] Update button's animation duration props Apr 1, 2026
Copy link
Copy Markdown
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

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


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

@j-piasecki j-piasecki requested a review from Copilot April 1, 2026 09:55
Copy link
Copy Markdown
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

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.


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

Comment on lines +540 to +552

if (elapsed >= pressAndHoldAnimationDuration) {
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, pressAndHoldAnimationDuration.toLong())
// elapsed * 2 to ensure there is at least half of the tapAnimationDuration left for the animation to play
} else if (elapsed * 2 >= tapAnimationDuration) {
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed)
} else {
val remaining = tapAnimationDuration - elapsed
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining)

val runnable = Runnable {
pendingPressOut = null
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapAnimationDuration.toLong())
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

elapsed is a Long, while pressAndHoldAnimationDuration/tapAnimationDuration are Int. Kotlin doesn’t allow ordering comparisons or arithmetic between Long and Int, so these comparisons will fail to compile. Convert the durations to Long once (e.g., val pressAndHoldMs = pressAndHoldAnimationDuration.toLong(), val tapMs = tapAnimationDuration.toLong()) and use those consistently in comparisons and animator durations.

Suggested change
if (elapsed >= pressAndHoldAnimationDuration) {
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, pressAndHoldAnimationDuration.toLong())
// elapsed * 2 to ensure there is at least half of the tapAnimationDuration left for the animation to play
} else if (elapsed * 2 >= tapAnimationDuration) {
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed)
} else {
val remaining = tapAnimationDuration - elapsed
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining)
val runnable = Runnable {
pendingPressOut = null
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapAnimationDuration.toLong())
val pressAndHoldMs = pressAndHoldAnimationDuration.toLong()
val tapMs = tapAnimationDuration.toLong()
if (elapsed >= pressAndHoldMs) {
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, pressAndHoldMs)
// elapsed * 2 to ensure there is at least half of the tapAnimationDuration left for the animation to play
} else if (elapsed * 2 >= tapMs) {
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed)
} else {
val remaining = tapMs - elapsed
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining)
val runnable = Runnable {
pendingPressOut = null
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapMs)

Copilot uses AI. Check for mistakes.
Comment on lines +546 to +555
} else {
val remaining = tapAnimationDuration - elapsed
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining)

val runnable = Runnable {
pendingPressOut = null
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapAnimationDuration.toLong())
}
pendingPressOut = runnable
handler.postDelayed(runnable, remaining)
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

val remaining = tapAnimationDuration - elapsed mixes Int and Long (won’t compile), and handler.postDelayed(runnable, remaining) expects a non-negative Long delay. After converting tapAnimationDuration to Long, ensure remaining is computed as Long and clamped to >= 0 before calling postDelayed.

Copilot uses AI. Check for mistakes.
Comment on lines 378 to 382
private var currentAnimator: AnimatorSet? = null
private var underlayDrawable: PaintDrawable? = null
private var pressInTimestamp = 0L
private var pendingPressOut: Runnable? = null

Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

A pending Runnable is now stored in pendingPressOut, but there’s no lifecycle cleanup (e.g., onDetachedFromWindow) to remove it and cancel currentAnimator. If the view is unmounted while a delayed press-out is scheduled (or if tapAnimationDuration is set high), the runnable can keep the view referenced and run animations after detach. Consider overriding onDetachedFromWindow to removeCallbacks for pendingPressOut, null it out, and cancel currentAnimator.

Copilot uses AI. Check for mistakes.
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.

2 participants