Skip to content

Commit a373442

Browse files
authored
[Android] Handle hover in Touchable (#4282)
## Description This PR brings hover effects for the `Touchable` component on Android. Previously, animations based on hover were possible only on web. Hovering out is handled in post-frame callback. This is due to the fact that on Android `ACTION_HOVER_EXIT` is dispatched right before `ACTION_DOWN`, so without delaying hover out effect we would be left with flickers. ## Test plan <details> <summary>Tested on existing Touchable example and on the code below:</summary> ```tsx import * as React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { GestureHandlerRootView, Touchable as GHTouchable, } from 'react-native-gesture-handler'; const SLOW = 700; export default function App() { const [pressCount, setPressCount] = React.useState(0); const [disabled, setDisabled] = React.useState(false); // Auto-toggle `disabled` every 2s so hover mask/resume can be tested with a // mouse held still over the button — tapping a separate control would move // the pointer off first. Hover and watch: the visual masks to default while // disabled and resumes the hover look when re-enabled, pointer unmoved. React.useEffect(() => { const id = setInterval(() => setDisabled((d) => !d), 2000); return () => clearInterval(id); }, []); return ( <GestureHandlerRootView style={styles.root}> <Text style={styles.title}>Slow hover + press</Text> <Text style={styles.hint}> Use a mouse / stylus. Hover to grow & fade, press to shrink & fade more. Transitions should never flicker through the default state. </Text> <View style={styles.stage}> <GHTouchable // Press (active) visuals defaultOpacity={1} defaultScale={1} activeOpacity={0.3} activeScale={0.8} // Hover visuals hoverOpacity={0.6} hoverScale={1.2} // Underlay so the change is extra visible underlayColor="black" defaultUnderlayOpacity={0} hoverUnderlayOpacity={0.15} activeUnderlayOpacity={0.35} // Slow everything down: in/out for both tap and hover animationDuration={{ tap: { in: SLOW, out: SLOW }, hover: { in: SLOW, out: SLOW }, }} disabled={disabled} style={styles.button} onPress={() => setPressCount((c) => c + 1)}> <Text style={styles.buttonText}>Hover / Press me</Text> </GHTouchable> </View> <Text style={styles.counter}> {disabled ? 'DISABLED' : 'enabled'} · Presses: {pressCount} </Text> </GestureHandlerRootView> ); } const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#ecf0f1', alignItems: 'center', justifyContent: 'center', padding: 24, }, title: { fontSize: 22, fontWeight: 'bold', color: '#2c3e50', marginBottom: 8, }, hint: { fontSize: 14, color: '#7f8c8d', textAlign: 'center', marginBottom: 40, }, stage: { width: 260, height: 260, alignItems: 'center', justifyContent: 'center', }, button: { width: 180, height: 180, borderRadius: 24, backgroundColor: '#8e44ad', alignItems: 'center', justifyContent: 'center', }, buttonText: { color: 'white', fontSize: 18, fontWeight: 'bold', }, counter: { marginTop: 40, fontSize: 16, color: '#2c3e50', fontWeight: 'bold', }, }); ``` </details>
1 parent 1a713d0 commit a373442

4 files changed

Lines changed: 175 additions & 26 deletions

File tree

packages/docs-gesture-handler/docs/components/touchable.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ defaultUnderlayOpacity?: number;
237237

238238
Defines the initial opacity of underlay when the button is inactive. By default set to `0`.
239239

240-
<Badges platforms={['web']}>
240+
<Badges platforms={['web', 'android']}>
241241
### hoverOpacity
242242
</Badges>
243243

@@ -247,7 +247,7 @@ hoverOpacity?: number;
247247

248248
Defines the opacity of the whole component when the button is hovered. By default falls back to [`defaultOpacity`](#defaultopacity).
249249

250-
<Badges platforms={['web']}>
250+
<Badges platforms={['web', 'android']}>
251251
### hoverScale
252252
</Badges>
253253

@@ -257,7 +257,7 @@ hoverScale?: number;
257257

258258
Defines the scale of the whole component when the button is hovered. By default falls back to [`defaultScale`](#defaultscale).
259259

260-
<Badges platforms={['web']}>
260+
<Badges platforms={['web', 'android']}>
261261
### hoverUnderlayOpacity
262262
</Badges>
263263

@@ -296,7 +296,7 @@ Press and hover animation timing, in milliseconds. Defaults to 50ms for the in p
296296

297297
Each animation has two phases — `in` (running while the pointer engages the component) and `out` (running after the pointer releases) — across two categories:
298298
- `tap` — applies to presses.
299-
- `hover` — pointer hover (web only).
299+
- `hover` — pointer hover (web and Android).
300300

301301
`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.
302302

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

Lines changed: 158 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import android.graphics.drawable.shapes.RectShape
1717
import android.os.Build
1818
import android.os.SystemClock
1919
import android.util.TypedValue
20+
import android.view.Choreographer
2021
import android.view.KeyEvent
2122
import android.view.MotionEvent
2223
import android.view.View
@@ -331,6 +332,31 @@ class RNGestureHandlerButtonViewManager :
331332
view.activeUnderlayOpacity = activeUnderlayOpacity
332333
}
333334

335+
@ReactProp(name = "hoverOpacity")
336+
override fun setHoverOpacity(view: ButtonViewGroup, hoverOpacity: Float) {
337+
view.hoverOpacity = hoverOpacity
338+
}
339+
340+
@ReactProp(name = "hoverScale")
341+
override fun setHoverScale(view: ButtonViewGroup, hoverScale: Float) {
342+
view.hoverScale = hoverScale
343+
}
344+
345+
@ReactProp(name = "hoverUnderlayOpacity")
346+
override fun setHoverUnderlayOpacity(view: ButtonViewGroup, hoverUnderlayOpacity: Float) {
347+
view.hoverUnderlayOpacity = hoverUnderlayOpacity
348+
}
349+
350+
@ReactProp(name = "hoverAnimationInDuration")
351+
override fun setHoverAnimationInDuration(view: ButtonViewGroup, value: Int) {
352+
view.hoverAnimationInDuration = if (value > 0) value else 0
353+
}
354+
355+
@ReactProp(name = "hoverAnimationOutDuration")
356+
override fun setHoverAnimationOutDuration(view: ButtonViewGroup, value: Int) {
357+
view.hoverAnimationOutDuration = if (value > 0) value else 0
358+
}
359+
334360
@ReactProp(name = ViewProps.POINTER_EVENTS)
335361
override fun setPointerEvents(view: ButtonViewGroup, pointerEvents: String?) {
336362
view.pointerEvents = when (pointerEvents) {
@@ -381,6 +407,14 @@ class RNGestureHandlerButtonViewManager :
381407
var defaultOpacity: Float = 1.0f
382408
var activeScale: Float = 1.0f
383409
var defaultScale: Float = 1.0f
410+
var hoverAnimationInDuration: Int = 50
411+
var hoverAnimationOutDuration: Int = 100
412+
var hoverOpacity: Float = -1f
413+
get() = if (field < 0f) defaultOpacity else field
414+
var hoverScale: Float = -1f
415+
get() = if (field < 0f) defaultScale else field
416+
var hoverUnderlayOpacity: Float = -1f
417+
get() = if (field < 0f) defaultUnderlayOpacity else field
384418
var underlayColor: Int? = null
385419
set(color) = withBackgroundUpdate {
386420
field = color
@@ -402,7 +436,24 @@ class RNGestureHandlerButtonViewManager :
402436
private var underlayDrawable: PaintDrawable? = null
403437
private var pressInTimestamp = 0L
404438
private var pendingPressOut: Runnable? = null
439+
private var pendingHoverOut: Choreographer.FrameCallback? = null
405440
private var isPointerInsideBounds = false
441+
private var isHovered = false
442+
443+
// Whether a hover was active at press-start. A hovering pointer fires
444+
// ACTION_HOVER_ENTER first (so isHovered is already true at DOWN).
445+
private var hoverActiveAtPressStart = false
446+
447+
private val shouldAnimateHover get() = isHovered && isEnabled
448+
449+
private val restingOpacity get() = if (shouldAnimateHover) hoverOpacity else defaultOpacity
450+
private val restingScale get() = if (shouldAnimateHover) hoverScale else defaultScale
451+
private val restingUnderlayOpacity get() = if (shouldAnimateHover) hoverUnderlayOpacity else defaultUnderlayOpacity
452+
453+
private val hasOpacityAnimation get() = activeOpacity != 1.0f || defaultOpacity != 1.0f || hoverOpacity != 1.0f
454+
private val hasScaleAnimation get() = activeScale != 1.0f || defaultScale != 1.0f || hoverScale != 1.0f
455+
private val hasUnderlayAnimation get() = underlayDrawable != null &&
456+
(activeUnderlayOpacity != defaultUnderlayOpacity || hoverUnderlayOpacity != defaultUnderlayOpacity)
406457

407458
// When non-null the ripple is drawn in dispatchDraw (above background, below children).
408459
// When null the ripple lives on the foreground drawable instead.
@@ -501,6 +552,12 @@ class RNGestureHandlerButtonViewManager :
501552
val eventTime = event.eventTime
502553
val action = event.action
503554

555+
if (event.actionMasked == MotionEvent.ACTION_DOWN ||
556+
event.actionMasked == MotionEvent.ACTION_POINTER_DOWN
557+
) {
558+
cancelPendingHoverOut()
559+
}
560+
504561
if (touchResponder != null && touchResponder !== this && touchResponder!!.exclusive) {
505562
if (isPressed) {
506563
setPressed(false)
@@ -518,14 +575,30 @@ class RNGestureHandlerButtonViewManager :
518575
if (lastEventTime != eventTime || lastAction != action || action == MotionEvent.ACTION_CANCEL) {
519576
lastEventTime = eventTime
520577
lastAction = action
578+
579+
// No hover events arrive while the button is held, so derive hover from
580+
// the touch stream (within bounds). Gated on hoverActiveAtPressStart so
581+
// it only maintains an already-active hover.
582+
when (event.actionMasked) {
583+
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> hoverActiveAtPressStart = isHovered
584+
MotionEvent.ACTION_MOVE,
585+
MotionEvent.ACTION_UP,
586+
MotionEvent.ACTION_POINTER_UP,
587+
->
588+
if (hoverActiveAtPressStart) {
589+
isHovered = isWithinBounds(event)
590+
}
591+
MotionEvent.ACTION_CANCEL -> isHovered = false
592+
}
593+
521594
val handled = super.onTouchEvent(event)
522595

523596
// Replay press-in / press-out animations across drag transitions.
524597
if (handled && canRespondToTouches()) {
525598
when (event.actionMasked) {
526599
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> isPointerInsideBounds = true
527600
MotionEvent.ACTION_MOVE -> {
528-
val inside = event.x >= 0 && event.y >= 0 && event.x < width && event.y < height
601+
val inside = isWithinBounds(event)
529602
if (inside != isPointerInsideBounds) {
530603
isPointerInsideBounds = inside
531604
if (inside) {
@@ -548,6 +621,15 @@ class RNGestureHandlerButtonViewManager :
548621
return false
549622
}
550623

624+
override fun onHoverEvent(event: MotionEvent): Boolean {
625+
when (event.actionMasked) {
626+
MotionEvent.ACTION_HOVER_ENTER -> onHoverIn()
627+
MotionEvent.ACTION_HOVER_EXIT -> onHoverOut()
628+
}
629+
630+
return super.onHoverEvent(event)
631+
}
632+
551633
private fun getAnimatorDurationScale(): Float = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
552634
ValueAnimator.getDurationScale()
553635
} else {
@@ -562,21 +644,18 @@ class RNGestureHandlerButtonViewManager :
562644
}
563645

564646
private fun applyStartAnimationState() {
565-
if (activeOpacity != 1.0f || defaultOpacity != 1.0f) {
647+
if (hasOpacityAnimation) {
566648
alpha = defaultOpacity
567649
}
568-
if (activeScale != 1.0f || defaultScale != 1.0f) {
650+
if (hasScaleAnimation) {
569651
scaleX = defaultScale
570652
scaleY = defaultScale
571653
}
572654
underlayDrawable?.alpha = (defaultUnderlayOpacity * 255).toInt()
573655
}
574656

575657
private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float, durationMs: Long) {
576-
val hasOpacity = activeOpacity != 1.0f || defaultOpacity != 1.0f
577-
val hasScale = activeScale != 1.0f || defaultScale != 1.0f
578-
val hasUnderlay = activeUnderlayOpacity != defaultUnderlayOpacity && underlayDrawable != null
579-
if (!hasOpacity && !hasScale && !hasUnderlay) {
658+
if (!hasOpacityAnimation && !hasScaleAnimation && !hasUnderlayAnimation) {
580659
return
581660
}
582661

@@ -594,28 +673,28 @@ class RNGestureHandlerButtonViewManager :
594673
val durationScale = getAnimatorDurationScale()
595674
val effectiveDurationMs = (durationMs * durationScale).toLong()
596675
if (effectiveDurationMs < (display?.minimumFrameTime ?: 16f)) {
597-
if (hasOpacity) {
676+
if (hasOpacityAnimation) {
598677
alpha = opacity
599678
}
600-
if (hasScale) {
679+
if (hasScaleAnimation) {
601680
scaleX = scale
602681
scaleY = scale
603682
}
604-
if (hasUnderlay) {
683+
if (hasUnderlayAnimation) {
605684
underlayDrawable!!.alpha = (underlayOpacity * 255).toInt()
606685
}
607686
return
608687
}
609688

610689
val animators = ArrayList<Animator>()
611-
if (hasOpacity) {
690+
if (hasOpacityAnimation) {
612691
animators.add(ObjectAnimator.ofFloat(this, "alpha", opacity))
613692
}
614-
if (hasScale) {
693+
if (hasScaleAnimation) {
615694
animators.add(ObjectAnimator.ofFloat(this, "scaleX", scale))
616695
animators.add(ObjectAnimator.ofFloat(this, "scaleY", scale))
617696
}
618-
if (hasUnderlay) {
697+
if (hasUnderlayAnimation) {
619698
animators.add(ObjectAnimator.ofInt(underlayDrawable!!, "alpha", (underlayOpacity * 255).toInt()))
620699
}
621700
currentAnimator = AnimatorSet().apply {
@@ -635,6 +714,57 @@ class RNGestureHandlerButtonViewManager :
635714
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, tapAnimationInDuration.toLong())
636715
}
637716

717+
private fun animateHoverState() {
718+
if (isPressed) {
719+
return
720+
}
721+
722+
if (shouldAnimateHover) {
723+
animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong())
724+
} else {
725+
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, hoverAnimationOutDuration.toLong())
726+
}
727+
}
728+
729+
private fun onHoverIn() {
730+
cancelPendingHoverOut()
731+
732+
if (isHovered) {
733+
return
734+
}
735+
736+
isHovered = true
737+
animateHoverState()
738+
}
739+
740+
private fun onHoverOut() {
741+
if (isPressed) {
742+
isHovered = false
743+
return
744+
}
745+
746+
cancelPendingHoverOut()
747+
748+
// Hover-out arrives just before a press-down, so defer a frame to let a
749+
// following press-in cancel it and keep the hover state through the press.
750+
val callback = Choreographer.FrameCallback {
751+
pendingHoverOut = null
752+
isHovered = false
753+
animateHoverState()
754+
}
755+
756+
pendingHoverOut = callback
757+
Choreographer.getInstance().postFrameCallback(callback)
758+
}
759+
760+
private fun cancelPendingHoverOut() {
761+
pendingHoverOut?.let { Choreographer.getInstance().removeFrameCallback(it) }
762+
pendingHoverOut = null
763+
}
764+
765+
private fun isWithinBounds(event: MotionEvent): Boolean =
766+
event.x >= 0 && event.y >= 0 && event.x < width && event.y < height
767+
638768
private fun animatePressOut() {
639769
pendingPressOut?.let { handler.removeCallbacks(it) }
640770
val tapInMs = tapAnimationInDuration.toLong()
@@ -645,20 +775,20 @@ class RNGestureHandlerButtonViewManager :
645775

646776
if (longPressMs >= 0 && elapsed >= longPressMs) {
647777
// Long-press release - use the configured long-press out duration.
648-
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, longPressOutMs)
778+
animateTo(restingOpacity, restingScale, restingUnderlayOpacity, longPressOutMs)
649779
} else if (elapsed >= tapInMs) {
650780
// Press-in animation fully finished — release with the configured out duration.
651-
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapOutMs)
781+
animateTo(restingOpacity, restingScale, restingUnderlayOpacity, tapOutMs)
652782
// elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play
653783
} else if (elapsed * 2 >= tapOutMs) {
654-
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed)
784+
animateTo(restingOpacity, restingScale, restingUnderlayOpacity, elapsed)
655785
} else {
656786
val remaining = tapInMs - elapsed
657787
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining)
658788

659789
val runnable = Runnable {
660790
pendingPressOut = null
661-
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapOutMs)
791+
animateTo(restingOpacity, restingScale, restingUnderlayOpacity, tapOutMs)
662792
}
663793
pendingPressOut = runnable
664794
// The animator scales `remaining` by ANIMATOR_DURATION_SCALE internally,
@@ -783,8 +913,10 @@ class RNGestureHandlerButtonViewManager :
783913
super.onDetachedFromWindow()
784914
pendingPressOut?.let { handler.removeCallbacks(it) }
785915
pendingPressOut = null
916+
cancelPendingHoverOut()
786917
currentAnimator?.cancel()
787918
currentAnimator = null
919+
isHovered = false
788920
applyStartAnimationState()
789921

790922
if (touchResponder === this) {
@@ -913,6 +1045,15 @@ class RNGestureHandlerButtonViewManager :
9131045
}
9141046
}
9151047

1048+
override fun setEnabled(enabled: Boolean) {
1049+
val changed = enabled != isEnabled
1050+
super.setEnabled(enabled)
1051+
1052+
if (changed && isHovered) {
1053+
animateHoverState()
1054+
}
1055+
}
1056+
9161057
override fun setPressed(pressed: Boolean) {
9171058
// button can be pressed alongside other button if both are non-exclusive and it doesn't have
9181059
// any pressed children (to prevent pressing the parent when children is pressed).

packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,38 +102,38 @@ export interface ButtonProps extends ViewProps, AccessibilityProps {
102102
activeUnderlayOpacity?: number | undefined;
103103

104104
/**
105-
* Web only.
105+
* Web and Android only.
106106
*
107107
* Opacity applied to the button when it is hovered. Defaults to
108108
* `defaultOpacity` when not set.
109109
*/
110110
hoverOpacity?: number | undefined;
111111

112112
/**
113-
* Web only.
113+
* Web and Android only.
114114
*
115115
* Scale applied to the button when it is hovered. Defaults to
116116
* `defaultScale` when not set.
117117
*/
118118
hoverScale?: number | undefined;
119119

120120
/**
121-
* Web only.
121+
* Web and Android only.
122122
*
123123
* Opacity applied to the underlay when the button is hovered. Defaults
124124
* to `defaultUnderlayOpacity` when not set.
125125
*/
126126
hoverUnderlayOpacity?: number | undefined;
127127

128128
/**
129-
* Web only.
129+
* Web and Android only.
130130
*
131131
* Duration of the hover-in animation, in milliseconds. Defaults to 50ms.
132132
*/
133133
hoverAnimationInDuration?: number | undefined;
134134

135135
/**
136-
* Web only.
136+
* Web and Android only.
137137
*
138138
* Duration of the hover-out animation, in milliseconds. Defaults to 100ms.
139139
*/

0 commit comments

Comments
 (0)