From 0d273cab4741efe17a2a994a8b2f136dac5cef51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 24 Jun 2026 10:27:54 +0200 Subject: [PATCH 1/9] Android hover --- .../RNGestureHandlerButtonViewManager.kt | 129 ++++++++++++++++-- .../src/components/GestureHandlerButton.tsx | 10 +- .../RNGestureHandlerButtonNativeComponent.ts | 8 ++ 3 files changed, 133 insertions(+), 14 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 7be7ae4ef7..9c930bf709 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -18,6 +18,7 @@ import android.graphics.drawable.shapes.RectShape import android.os.Build import android.os.SystemClock import android.util.TypedValue +import android.view.Choreographer import android.view.KeyEvent import android.view.MotionEvent import android.view.View @@ -333,6 +334,31 @@ class RNGestureHandlerButtonViewManager : view.activeUnderlayOpacity = activeUnderlayOpacity } + @ReactProp(name = "hoverOpacity") + override fun setHoverOpacity(view: ButtonViewGroup, hoverOpacity: Float) { + view.hoverOpacity = hoverOpacity + } + + @ReactProp(name = "hoverScale") + override fun setHoverScale(view: ButtonViewGroup, hoverScale: Float) { + view.hoverScale = hoverScale + } + + @ReactProp(name = "hoverUnderlayOpacity") + override fun setHoverUnderlayOpacity(view: ButtonViewGroup, hoverUnderlayOpacity: Float) { + view.hoverUnderlayOpacity = hoverUnderlayOpacity + } + + @ReactProp(name = "hoverAnimationInDuration") + override fun setHoverAnimationInDuration(view: ButtonViewGroup, value: Int) { + view.hoverAnimationInDuration = if (value > 0) value else 0 + } + + @ReactProp(name = "hoverAnimationOutDuration") + override fun setHoverAnimationOutDuration(view: ButtonViewGroup, value: Int) { + view.hoverAnimationOutDuration = if (value > 0) value else 0 + } + @ReactProp(name = ViewProps.POINTER_EVENTS) override fun setPointerEvents(view: ButtonViewGroup, pointerEvents: String?) { view.pointerEvents = when (pointerEvents) { @@ -383,6 +409,14 @@ class RNGestureHandlerButtonViewManager : var defaultOpacity: Float = 1.0f var activeScale: Float = 1.0f var defaultScale: Float = 1.0f + var hoverAnimationInDuration: Int = 50 + var hoverAnimationOutDuration: Int = 100 + var hoverOpacity: Float = -1f + get() = if (field < 0f) defaultOpacity else field + var hoverScale: Float = -1f + get() = if (field < 0f) defaultScale else field + var hoverUnderlayOpacity: Float = -1f + get() = if (field < 0f) defaultUnderlayOpacity else field var underlayColor: Int? = null set(color) = withBackgroundUpdate { field = color @@ -404,7 +438,17 @@ class RNGestureHandlerButtonViewManager : private var underlayDrawable: PaintDrawable? = null private var pressInTimestamp = 0L private var pendingPressOut: Runnable? = null + private var pendingHoverOut: Choreographer.FrameCallback? = null private var isPointerInsideBounds = false + private var isHovered = false + + // Resting (non-pressed) animation targets. While the pointer hovers the + // button, the press-out animation settles on the hover values instead of + // the defaults, mirroring the web priority order (pressed > hovered > + // default). + private val restingOpacity get() = if (isHovered) hoverOpacity else defaultOpacity + private val restingScale get() = if (isHovered) hoverScale else defaultScale + private val restingUnderlayOpacity get() = if (isHovered) hoverUnderlayOpacity else defaultUnderlayOpacity // When non-null the ripple is drawn in dispatchDraw (above background, below children). // When null the ripple lives on the foreground drawable instead. @@ -503,6 +547,12 @@ class RNGestureHandlerButtonViewManager : val eventTime = event.eventTime val action = event.action + if (event.actionMasked == MotionEvent.ACTION_DOWN || + event.actionMasked == MotionEvent.ACTION_POINTER_DOWN + ) { + cancelPendingHoverOut() + } + if (touchResponder != null && touchResponder !== this && touchResponder!!.exclusive) { if (isPressed) { setPressed(false) @@ -550,6 +600,17 @@ class RNGestureHandlerButtonViewManager : return false } + override fun onHoverEvent(event: MotionEvent): Boolean { + if (isEnabled) { + when (event.actionMasked) { + MotionEvent.ACTION_HOVER_ENTER -> animateHoverIn() + MotionEvent.ACTION_HOVER_EXIT -> animateHoverOut() + } + } + + return super.onHoverEvent(event) + } + private fun getAnimatorDurationScale(): Float = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ValueAnimator.getDurationScale() } else { @@ -564,10 +625,10 @@ class RNGestureHandlerButtonViewManager : } private fun applyStartAnimationState() { - if (activeOpacity != 1.0f || defaultOpacity != 1.0f) { + if (activeOpacity != 1.0f || defaultOpacity != 1.0f || hoverOpacity != 1.0f) { alpha = defaultOpacity } - if (activeScale != 1.0f || defaultScale != 1.0f) { + if (activeScale != 1.0f || defaultScale != 1.0f || hoverScale != 1.0f) { scaleX = defaultScale scaleY = defaultScale } @@ -575,9 +636,10 @@ class RNGestureHandlerButtonViewManager : } private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float, durationMs: Long) { - val hasOpacity = activeOpacity != 1.0f || defaultOpacity != 1.0f - val hasScale = activeScale != 1.0f || defaultScale != 1.0f - val hasUnderlay = activeUnderlayOpacity != defaultUnderlayOpacity && underlayDrawable != null + val hasOpacity = activeOpacity != 1.0f || defaultOpacity != 1.0f || hoverOpacity != 1.0f + val hasScale = activeScale != 1.0f || defaultScale != 1.0f || hoverScale != 1.0f + val hasUnderlay = underlayDrawable != null && + (activeUnderlayOpacity != defaultUnderlayOpacity || hoverUnderlayOpacity != defaultUnderlayOpacity) if (!hasOpacity && !hasScale && !hasUnderlay) { return } @@ -637,6 +699,53 @@ class RNGestureHandlerButtonViewManager : animateTo(activeOpacity, activeScale, activeUnderlayOpacity, tapAnimationInDuration.toLong()) } + private fun animateHoverIn() { + cancelPendingHoverOut() + isHovered = true + + // While pressed the press visual owns the view; only record the hover + // state so the eventual press-out settles on the hover values. + if (isPressed) { + return + } + + animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong()) + } + + private fun animateHoverOut() { + if (isPressed) { + isHovered = false + return + } + + // Android brackets a pointer press with HOVER_EXIT (just before + // ACTION_DOWN) and HOVER_ENTER (just after ACTION_UP). Defer the + // hover-out to the next frame: Choreographer runs the input phase before + // the animation phase, so the bracketing ACTION_DOWN (delivered in the + // same input cycle that emitted this exit) cancels this first via + // onTouchEvent — keeping the hover state for a flicker-free + // hover → press → hover transition. A real pointer leave has no press + // following, so the callback runs and settles to the default state. + // `isHovered` is cleared inside the callback so a cancel keeps it set. + cancelPendingHoverOut() + + // We have to defer hover-out due to MotionEvent orders. + // In case of a press, the hover-out is sent before the press-in, so we need to wait for the next frame to animate back to the default state. + val callback = Choreographer.FrameCallback { + pendingHoverOut = null + isHovered = false + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, hoverAnimationOutDuration.toLong()) + } + + pendingHoverOut = callback + Choreographer.getInstance().postFrameCallback(callback) + } + + private fun cancelPendingHoverOut() { + pendingHoverOut?.let { Choreographer.getInstance().removeFrameCallback(it) } + pendingHoverOut = null + } + private fun animatePressOut() { pendingPressOut?.let { handler.removeCallbacks(it) } val tapInMs = tapAnimationInDuration.toLong() @@ -647,20 +756,20 @@ class RNGestureHandlerButtonViewManager : if (longPressMs >= 0 && elapsed >= longPressMs) { // Long-press release - use the configured long-press out duration. - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, longPressOutMs) + animateTo(restingOpacity, restingScale, restingUnderlayOpacity, longPressOutMs) } else if (elapsed >= tapInMs) { // Press-in animation fully finished — release with the configured out duration. - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapOutMs) + animateTo(restingOpacity, restingScale, restingUnderlayOpacity, tapOutMs) // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play } else if (elapsed * 2 >= tapOutMs) { - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed) + animateTo(restingOpacity, restingScale, restingUnderlayOpacity, elapsed) } else { val remaining = tapInMs - elapsed animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining) val runnable = Runnable { pendingPressOut = null - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapOutMs) + animateTo(restingOpacity, restingScale, restingUnderlayOpacity, tapOutMs) } pendingPressOut = runnable // The animator scales `remaining` by ANIMATOR_DURATION_SCALE internally, @@ -788,8 +897,10 @@ class RNGestureHandlerButtonViewManager : super.onDetachedFromWindow() pendingPressOut?.let { handler.removeCallbacks(it) } pendingPressOut = null + cancelPendingHoverOut() currentAnimator?.cancel() currentAnimator = null + isHovered = false applyStartAnimationState() if (touchResponder === this) { diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 8e09e6ed97..6e49839131 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -102,7 +102,7 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { activeUnderlayOpacity?: number | undefined; /** - * Web only. + * Web and Android only. * * Opacity applied to the button when it is hovered. Defaults to * `defaultOpacity` when not set. @@ -110,7 +110,7 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { hoverOpacity?: number | undefined; /** - * Web only. + * Web and Android only. * * Scale applied to the button when it is hovered. Defaults to * `defaultScale` when not set. @@ -118,7 +118,7 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { hoverScale?: number | undefined; /** - * Web only. + * Web and Android only. * * Opacity applied to the underlay when the button is hovered. Defaults * to `defaultUnderlayOpacity` when not set. @@ -126,14 +126,14 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { hoverUnderlayOpacity?: number | undefined; /** - * Web only. + * Web and Android only. * * Duration of the hover-in animation, in milliseconds. Defaults to 50ms. */ hoverAnimationInDuration?: number | undefined; /** - * Web only. + * Web and Android only. * * Duration of the hover-out animation, in milliseconds. Defaults to 100ms. */ diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index b166134873..1842518015 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -28,6 +28,14 @@ interface NativeProps extends ViewProps { activeOpacity?: WithDefault; activeScale?: WithDefault; activeUnderlayOpacity?: WithDefault; + // Hover values default to -1 as an "unset" sentinel; the native side + // resolves them to the corresponding default* value (matching web, where + // an omitted hover value falls back to its default counterpart). + hoverOpacity?: WithDefault; + hoverScale?: WithDefault; + hoverUnderlayOpacity?: WithDefault; + hoverAnimationInDuration?: WithDefault; + hoverAnimationOutDuration?: WithDefault; defaultOpacity?: WithDefault; defaultScale?: WithDefault; defaultUnderlayOpacity?: WithDefault; From 1e69e68d8f3df597d501d450717fe4caca01f530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 24 Jun 2026 12:09:31 +0200 Subject: [PATCH 2/9] Pointer up outside --- .../RNGestureHandlerButtonViewManager.kt | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 9c930bf709..5e4cbf9dd5 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -570,6 +570,26 @@ class RNGestureHandlerButtonViewManager : if (lastEventTime != eventTime || lastAction != action || action == MotionEvent.ACTION_CANCEL) { lastEventTime = eventTime lastAction = action + + // Android delivers no hover events while a button is held, so derive + // the hover state from the touch stream for hovering pointers: + // hovered iff the pointer is within bounds. Done before + // super.onTouchEvent so a press-out it triggers (release, or + // cancel-on-leave) settles on the correct resting visual — hover while + // still over the view, default once the pointer leaves. + when (event.actionMasked) { + MotionEvent.ACTION_MOVE, + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP, + -> + if (isHoveringPointer(event)) { + isHovered = isWithinBounds(event) + } + // The synthesized cancel event carries no real tool type or coordinates, + // so it must NOT be gated on isHoveringPointer or a bounds check + MotionEvent.ACTION_CANCEL -> isHovered = false + } + val handled = super.onTouchEvent(event) // Replay press-in / press-out animations across drag transitions. @@ -577,7 +597,7 @@ class RNGestureHandlerButtonViewManager : when (event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> isPointerInsideBounds = true MotionEvent.ACTION_MOVE -> { - val inside = event.x >= 0 && event.y >= 0 && event.x < width && event.y < height + val inside = isWithinBounds(event) if (inside != isPointerInsideBounds) { isPointerInsideBounds = inside if (inside) { @@ -701,6 +721,17 @@ class RNGestureHandlerButtonViewManager : private fun animateHoverIn() { cancelPendingHoverOut() + + // A HOVER_ENTER while already hovered is the trailing bracket Android + // emits right after a press release (just after ACTION_UP). `isHovered` + // is kept true through the press and press-out already settles the view + // on the hover resting state, so animating here would just fight it. A + // genuine fresh hover always arrives with `isHovered == false` (hover-out + // clears it), so this only skips the redundant bracket. + if (isHovered) { + return + } + isHovered = true // While pressed the press visual owns the view; only record the hover @@ -718,19 +749,11 @@ class RNGestureHandlerButtonViewManager : return } - // Android brackets a pointer press with HOVER_EXIT (just before - // ACTION_DOWN) and HOVER_ENTER (just after ACTION_UP). Defer the - // hover-out to the next frame: Choreographer runs the input phase before - // the animation phase, so the bracketing ACTION_DOWN (delivered in the - // same input cycle that emitted this exit) cancels this first via - // onTouchEvent — keeping the hover state for a flicker-free - // hover → press → hover transition. A real pointer leave has no press - // following, so the callback runs and settles to the default state. - // `isHovered` is cleared inside the callback so a cancel keeps it set. cancelPendingHoverOut() // We have to defer hover-out due to MotionEvent orders. - // In case of a press, the hover-out is sent before the press-in, so we need to wait for the next frame to animate back to the default state. + // In case of a press, the hover-out is sent before the press-in, + // so we need to wait for the next frame to animate back to the default state. val callback = Choreographer.FrameCallback { pendingHoverOut = null isHovered = false @@ -746,6 +769,12 @@ class RNGestureHandlerButtonViewManager : pendingHoverOut = null } + private fun isHoveringPointer(event: MotionEvent): Boolean = event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE || + event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS + + private fun isWithinBounds(event: MotionEvent): Boolean = + event.x >= 0 && event.y >= 0 && event.x < width && event.y < height + private fun animatePressOut() { pendingPressOut?.let { handler.removeCallbacks(it) } val tapInMs = tapAnimationInDuration.toLong() From 6be95a3954ed4e749efbeacf3a83801909c0dc32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 24 Jun 2026 12:36:01 +0200 Subject: [PATCH 3/9] Handle enabled --- .../RNGestureHandlerButtonViewManager.kt | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 5e4cbf9dd5..7491493464 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -621,11 +621,9 @@ class RNGestureHandlerButtonViewManager : } override fun onHoverEvent(event: MotionEvent): Boolean { - if (isEnabled) { - when (event.actionMasked) { - MotionEvent.ACTION_HOVER_ENTER -> animateHoverIn() - MotionEvent.ACTION_HOVER_EXIT -> animateHoverOut() - } + when (event.actionMasked) { + MotionEvent.ACTION_HOVER_ENTER -> animateHoverIn() + MotionEvent.ACTION_HOVER_EXIT -> animateHoverOut() } return super.onHoverEvent(event) @@ -719,28 +717,27 @@ class RNGestureHandlerButtonViewManager : animateTo(activeOpacity, activeScale, activeUnderlayOpacity, tapAnimationInDuration.toLong()) } + private fun applyHoverState() { + if (isPressed) { + return + } + + if (isEnabled && isHovered) { + animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong()) + } else { + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, hoverAnimationOutDuration.toLong()) + } + } + private fun animateHoverIn() { cancelPendingHoverOut() - // A HOVER_ENTER while already hovered is the trailing bracket Android - // emits right after a press release (just after ACTION_UP). `isHovered` - // is kept true through the press and press-out already settles the view - // on the hover resting state, so animating here would just fight it. A - // genuine fresh hover always arrives with `isHovered == false` (hover-out - // clears it), so this only skips the redundant bracket. if (isHovered) { return } isHovered = true - - // While pressed the press visual owns the view; only record the hover - // state so the eventual press-out settles on the hover values. - if (isPressed) { - return - } - - animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong()) + applyHoverState() } private fun animateHoverOut() { @@ -757,7 +754,7 @@ class RNGestureHandlerButtonViewManager : val callback = Choreographer.FrameCallback { pendingHoverOut = null isHovered = false - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, hoverAnimationOutDuration.toLong()) + applyHoverState() } pendingHoverOut = callback @@ -1058,6 +1055,18 @@ class RNGestureHandlerButtonViewManager : } } + override fun setEnabled(enabled: Boolean) { + val changed = enabled != isEnabled + super.setEnabled(enabled) + + // Enabled is an input to the effective hover visual. + // `isHovered` keeps tracking across the disabled period, + // so re-evaluate the visual when it changes. + if (changed && isHovered) { + applyHoverState() + } + } + override fun setPressed(pressed: Boolean) { // button can be pressed alongside other button if both are non-exclusive and it doesn't have // any pressed children (to prevent pressing the parent when children is pressed). From e4817acd0f3cf4e98a8bb563048aef89b6e898a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 24 Jun 2026 12:50:50 +0200 Subject: [PATCH 4/9] Update docs --- .../docs-gesture-handler/docs/components/touchable.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/docs-gesture-handler/docs/components/touchable.mdx b/packages/docs-gesture-handler/docs/components/touchable.mdx index 787d680512..4761e1f3d1 100644 --- a/packages/docs-gesture-handler/docs/components/touchable.mdx +++ b/packages/docs-gesture-handler/docs/components/touchable.mdx @@ -237,7 +237,7 @@ defaultUnderlayOpacity?: number; Defines the initial opacity of underlay when the button is inactive. By default set to `0`. - + ### hoverOpacity @@ -247,7 +247,7 @@ hoverOpacity?: number; Defines the opacity of the whole component when the button is hovered. By default falls back to [`defaultOpacity`](#defaultopacity). - + ### hoverScale @@ -257,7 +257,7 @@ hoverScale?: number; Defines the scale of the whole component when the button is hovered. By default falls back to [`defaultScale`](#defaultscale). - + ### hoverUnderlayOpacity @@ -296,7 +296,7 @@ Press and hover animation timing, in milliseconds. Defaults to 50ms for the in p Each animation has two phases — `in` (running while the pointer engages the component) and `out` (running after the pointer releases) — across two categories: - `tap` — applies to presses. -- `hover` — pointer hover (web only). +- `hover` — pointer hover (web and Android). `longPress` is an optional override for the press-out timing once the press has been held past [`delayLongPress`](#delaylongpress). It only has an `out` field (the press-in is always the `tap` in duration). If omitted, the long-press release uses the resolved `tap` out duration. From 20050b223e60a2b9a0f78143e35e1394e80f92a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 29 Jun 2026 09:37:16 +0200 Subject: [PATCH 5/9] Add effective hover --- .../RNGestureHandlerButtonViewManager.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 7491493464..ef51f241fa 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -442,13 +442,16 @@ class RNGestureHandlerButtonViewManager : private var isPointerInsideBounds = false private var isHovered = false - // Resting (non-pressed) animation targets. While the pointer hovers the - // button, the press-out animation settles on the hover values instead of - // the defaults, mirroring the web priority order (pressed > hovered > - // default). - private val restingOpacity get() = if (isHovered) hoverOpacity else defaultOpacity - private val restingScale get() = if (isHovered) hoverScale else defaultScale - private val restingUnderlayOpacity get() = if (isHovered) hoverUnderlayOpacity else defaultUnderlayOpacity + // The hover visual is masked while disabled + private val effectivelyHovered get() = isHovered && isEnabled + + // Resting (non-pressed) animation targets. While the pointer effectively + // hovers the button, the press-out animation settles on the hover values + // instead of the defaults, mirroring the web priority order (pressed > + // hovered > default). + private val restingOpacity get() = if (effectivelyHovered) hoverOpacity else defaultOpacity + private val restingScale get() = if (effectivelyHovered) hoverScale else defaultScale + private val restingUnderlayOpacity get() = if (effectivelyHovered) hoverUnderlayOpacity else defaultUnderlayOpacity // When non-null the ripple is drawn in dispatchDraw (above background, below children). // When null the ripple lives on the foreground drawable instead. @@ -722,7 +725,7 @@ class RNGestureHandlerButtonViewManager : return } - if (isEnabled && isHovered) { + if (effectivelyHovered) { animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong()) } else { animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, hoverAnimationOutDuration.toLong()) From f1221006706a47e6eabcf6247aa6542b8d59f449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 29 Jun 2026 09:48:22 +0200 Subject: [PATCH 6/9] Stylus w/o hover --- .../RNGestureHandlerButtonViewManager.kt | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index ef51f241fa..d5c1ac45e0 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -442,6 +442,14 @@ class RNGestureHandlerButtonViewManager : private var isPointerInsideBounds = false private var isHovered = false + // Whether a real hover was active when the current press began. A + // hover-capable pointer (mouse, or a stylus that supports hover) fires + // ACTION_HOVER_ENTER before the press, so `isHovered` is already true at + // ACTION_DOWN; a stylus without hover support never does. The touch-stream + // derivation below uses this to only *maintain* an existing hover, never + // fabricate one for a non-hovering tool. + private var hoverActiveAtPressStart = false + // The hover visual is masked while disabled private val effectivelyHovered get() = isHovered && isEnabled @@ -575,21 +583,23 @@ class RNGestureHandlerButtonViewManager : lastAction = action // Android delivers no hover events while a button is held, so derive - // the hover state from the touch stream for hovering pointers: - // hovered iff the pointer is within bounds. Done before - // super.onTouchEvent so a press-out it triggers (release, or - // cancel-on-leave) settles on the correct resting visual — hover while - // still over the view, default once the pointer leaves. + // the hover state from the touch stream: hovered iff the pointer is + // within bounds. Gated on `hoverActiveAtPressStart` so it only + // maintains a hover that was already active (a real hovering pointer) — + // a stylus without hover support fires no hover events and must not + // enter the hover visual on a plain tap. Done before super.onTouchEvent + // so a press-out it triggers (release, or cancel-on-leave) settles on + // the correct resting visual — hover while still over the view, default + // once the pointer leaves. when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> hoverActiveAtPressStart = isHovered MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, -> - if (isHoveringPointer(event)) { + if (hoverActiveAtPressStart) { isHovered = isWithinBounds(event) } - // The synthesized cancel event carries no real tool type or coordinates, - // so it must NOT be gated on isHoveringPointer or a bounds check MotionEvent.ACTION_CANCEL -> isHovered = false } @@ -769,9 +779,6 @@ class RNGestureHandlerButtonViewManager : pendingHoverOut = null } - private fun isHoveringPointer(event: MotionEvent): Boolean = event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE || - event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS - private fun isWithinBounds(event: MotionEvent): Boolean = event.x >= 0 && event.y >= 0 && event.x < width && event.y < height From f1d0fc4f3c8d2e976e8eff456887ba4a651078d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 1 Jul 2026 11:21:08 +0200 Subject: [PATCH 7/9] Renames --- .../RNGestureHandlerButtonViewManager.kt | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 4867434c7d..17fe9b53a6 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -448,16 +448,11 @@ class RNGestureHandlerButtonViewManager : // fabricate one for a non-hovering tool. private var hoverActiveAtPressStart = false - // The hover visual is masked while disabled - private val effectivelyHovered get() = isHovered && isEnabled + private val shouldAnimateHover get() = isHovered && isEnabled - // Resting (non-pressed) animation targets. While the pointer effectively - // hovers the button, the press-out animation settles on the hover values - // instead of the defaults, mirroring the web priority order (pressed > - // hovered > default). - private val restingOpacity get() = if (effectivelyHovered) hoverOpacity else defaultOpacity - private val restingScale get() = if (effectivelyHovered) hoverScale else defaultScale - private val restingUnderlayOpacity get() = if (effectivelyHovered) hoverUnderlayOpacity else defaultUnderlayOpacity + private val restingOpacity get() = if (shouldAnimateHover) hoverOpacity else defaultOpacity + private val restingScale get() = if (shouldAnimateHover) hoverScale else defaultScale + private val restingUnderlayOpacity get() = if (shouldAnimateHover) hoverUnderlayOpacity else defaultUnderlayOpacity // When non-null the ripple is drawn in dispatchDraw (above background, below children). // When null the ripple lives on the foreground drawable instead. @@ -633,8 +628,8 @@ class RNGestureHandlerButtonViewManager : override fun onHoverEvent(event: MotionEvent): Boolean { when (event.actionMasked) { - MotionEvent.ACTION_HOVER_ENTER -> animateHoverIn() - MotionEvent.ACTION_HOVER_EXIT -> animateHoverOut() + MotionEvent.ACTION_HOVER_ENTER -> onHoverIn() + MotionEvent.ACTION_HOVER_EXIT -> onHoverOut() } return super.onHoverEvent(event) @@ -728,19 +723,19 @@ class RNGestureHandlerButtonViewManager : animateTo(activeOpacity, activeScale, activeUnderlayOpacity, tapAnimationInDuration.toLong()) } - private fun applyHoverState() { + private fun animateHoverState() { if (isPressed) { return } - if (effectivelyHovered) { + if (shouldAnimateHover) { animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong()) } else { animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, hoverAnimationOutDuration.toLong()) } } - private fun animateHoverIn() { + private fun onHoverIn() { cancelPendingHoverOut() if (isHovered) { @@ -748,10 +743,10 @@ class RNGestureHandlerButtonViewManager : } isHovered = true - applyHoverState() + animateHoverState() } - private fun animateHoverOut() { + private fun onHoverOut() { if (isPressed) { isHovered = false return @@ -765,7 +760,7 @@ class RNGestureHandlerButtonViewManager : val callback = Choreographer.FrameCallback { pendingHoverOut = null isHovered = false - applyHoverState() + animateHoverState() } pendingHoverOut = callback @@ -1068,7 +1063,7 @@ class RNGestureHandlerButtonViewManager : // `isHovered` keeps tracking across the disabled period, // so re-evaluate the visual when it changes. if (changed && isHovered) { - applyHoverState() + animateHoverState() } } From 70fbd36d702ebd2db763639b979678ae4f59e126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 1 Jul 2026 11:31:41 +0200 Subject: [PATCH 8/9] Convert to getters --- .../RNGestureHandlerButtonViewManager.kt | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 17fe9b53a6..9227381781 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -454,6 +454,14 @@ class RNGestureHandlerButtonViewManager : private val restingScale get() = if (shouldAnimateHover) hoverScale else defaultScale private val restingUnderlayOpacity get() = if (shouldAnimateHover) hoverUnderlayOpacity else defaultUnderlayOpacity + // Whether any configured value differs from the idle default — used to skip + // animating a property that never changes (mirrors iOS hasOpacityAnimation + // / hasScaleAnimation / hasUnderlayAnimation). + private val hasOpacityAnimation get() = activeOpacity != 1.0f || defaultOpacity != 1.0f || hoverOpacity != 1.0f + private val hasScaleAnimation get() = activeScale != 1.0f || defaultScale != 1.0f || hoverScale != 1.0f + private val hasUnderlayAnimation get() = underlayDrawable != null && + (activeUnderlayOpacity != defaultUnderlayOpacity || hoverUnderlayOpacity != defaultUnderlayOpacity) + // When non-null the ripple is drawn in dispatchDraw (above background, below children). // When null the ripple lives on the foreground drawable instead. private var selectableDrawable: Drawable? = null @@ -649,10 +657,10 @@ class RNGestureHandlerButtonViewManager : } private fun applyStartAnimationState() { - if (activeOpacity != 1.0f || defaultOpacity != 1.0f || hoverOpacity != 1.0f) { + if (hasOpacityAnimation) { alpha = defaultOpacity } - if (activeScale != 1.0f || defaultScale != 1.0f || hoverScale != 1.0f) { + if (hasScaleAnimation) { scaleX = defaultScale scaleY = defaultScale } @@ -660,11 +668,7 @@ class RNGestureHandlerButtonViewManager : } private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float, durationMs: Long) { - val hasOpacity = activeOpacity != 1.0f || defaultOpacity != 1.0f || hoverOpacity != 1.0f - val hasScale = activeScale != 1.0f || defaultScale != 1.0f || hoverScale != 1.0f - val hasUnderlay = underlayDrawable != null && - (activeUnderlayOpacity != defaultUnderlayOpacity || hoverUnderlayOpacity != defaultUnderlayOpacity) - if (!hasOpacity && !hasScale && !hasUnderlay) { + if (!hasOpacityAnimation && !hasScaleAnimation && !hasUnderlayAnimation) { return } @@ -682,28 +686,28 @@ class RNGestureHandlerButtonViewManager : val durationScale = getAnimatorDurationScale() val effectiveDurationMs = (durationMs * durationScale).toLong() if (effectiveDurationMs < (display?.minimumFrameTime ?: 16f)) { - if (hasOpacity) { + if (hasOpacityAnimation) { alpha = opacity } - if (hasScale) { + if (hasScaleAnimation) { scaleX = scale scaleY = scale } - if (hasUnderlay) { + if (hasUnderlayAnimation) { underlayDrawable!!.alpha = (underlayOpacity * 255).toInt() } return } val animators = ArrayList() - if (hasOpacity) { + if (hasOpacityAnimation) { animators.add(ObjectAnimator.ofFloat(this, "alpha", opacity)) } - if (hasScale) { + if (hasScaleAnimation) { animators.add(ObjectAnimator.ofFloat(this, "scaleX", scale)) animators.add(ObjectAnimator.ofFloat(this, "scaleY", scale)) } - if (hasUnderlay) { + if (hasUnderlayAnimation) { animators.add(ObjectAnimator.ofInt(underlayDrawable!!, "alpha", (underlayOpacity * 255).toInt())) } currentAnimator = AnimatorSet().apply { From 2d316e6622adc4bfe5ba289fa632f37a0fbc115a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 1 Jul 2026 12:11:24 +0200 Subject: [PATCH 9/9] Trim comments --- .../RNGestureHandlerButtonViewManager.kt | 31 +++++-------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 9227381781..5fd5474992 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -440,12 +440,8 @@ class RNGestureHandlerButtonViewManager : private var isPointerInsideBounds = false private var isHovered = false - // Whether a real hover was active when the current press began. A - // hover-capable pointer (mouse, or a stylus that supports hover) fires - // ACTION_HOVER_ENTER before the press, so `isHovered` is already true at - // ACTION_DOWN; a stylus without hover support never does. The touch-stream - // derivation below uses this to only *maintain* an existing hover, never - // fabricate one for a non-hovering tool. + // Whether a hover was active at press-start. A hovering pointer fires + // ACTION_HOVER_ENTER first (so isHovered is already true at DOWN). private var hoverActiveAtPressStart = false private val shouldAnimateHover get() = isHovered && isEnabled @@ -454,9 +450,6 @@ class RNGestureHandlerButtonViewManager : private val restingScale get() = if (shouldAnimateHover) hoverScale else defaultScale private val restingUnderlayOpacity get() = if (shouldAnimateHover) hoverUnderlayOpacity else defaultUnderlayOpacity - // Whether any configured value differs from the idle default — used to skip - // animating a property that never changes (mirrors iOS hasOpacityAnimation - // / hasScaleAnimation / hasUnderlayAnimation). private val hasOpacityAnimation get() = activeOpacity != 1.0f || defaultOpacity != 1.0f || hoverOpacity != 1.0f private val hasScaleAnimation get() = activeScale != 1.0f || defaultScale != 1.0f || hoverScale != 1.0f private val hasUnderlayAnimation get() = underlayDrawable != null && @@ -583,15 +576,9 @@ class RNGestureHandlerButtonViewManager : lastEventTime = eventTime lastAction = action - // Android delivers no hover events while a button is held, so derive - // the hover state from the touch stream: hovered iff the pointer is - // within bounds. Gated on `hoverActiveAtPressStart` so it only - // maintains a hover that was already active (a real hovering pointer) — - // a stylus without hover support fires no hover events and must not - // enter the hover visual on a plain tap. Done before super.onTouchEvent - // so a press-out it triggers (release, or cancel-on-leave) settles on - // the correct resting visual — hover while still over the view, default - // once the pointer leaves. + // No hover events arrive while the button is held, so derive hover from + // the touch stream (within bounds). Gated on hoverActiveAtPressStart so + // it only maintains an already-active hover. when (event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> hoverActiveAtPressStart = isHovered MotionEvent.ACTION_MOVE, @@ -758,9 +745,8 @@ class RNGestureHandlerButtonViewManager : cancelPendingHoverOut() - // We have to defer hover-out due to MotionEvent orders. - // In case of a press, the hover-out is sent before the press-in, - // so we need to wait for the next frame to animate back to the default state. + // Hover-out arrives just before a press-down, so defer a frame to let a + // following press-in cancel it and keep the hover state through the press. val callback = Choreographer.FrameCallback { pendingHoverOut = null isHovered = false @@ -1063,9 +1049,6 @@ class RNGestureHandlerButtonViewManager : val changed = enabled != isEnabled super.setEnabled(enabled) - // Enabled is an input to the effective hover visual. - // `isHovered` keeps tracking across the disabled period, - // so re-evaluate the visual when it changes. if (changed && isHovered) { animateHoverState() }