Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/docs-gesture-handler/docs/components/touchable.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ defaultUnderlayOpacity?: number;

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

<Badges platforms={['web']}>
<Badges platforms={['web', 'android']}>
### hoverOpacity
</Badges>

Expand All @@ -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).

<Badges platforms={['web']}>
<Badges platforms={['web', 'android']}>
### hoverScale
</Badges>

Expand All @@ -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).

<Badges platforms={['web']}>
<Badges platforms={['web', 'android']}>
### hoverUnderlayOpacity
</Badges>

Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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
Expand Down Expand Up @@ -331,6 +332,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) {
Expand Down Expand Up @@ -381,6 +407,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
Expand All @@ -402,7 +436,24 @@ 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

// 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

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

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.
Expand Down Expand Up @@ -501,6 +552,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)
Expand All @@ -518,14 +575,30 @@ class RNGestureHandlerButtonViewManager :
if (lastEventTime != eventTime || lastAction != action || action == MotionEvent.ACTION_CANCEL) {
lastEventTime = eventTime
lastAction = action

// 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,
MotionEvent.ACTION_UP,
MotionEvent.ACTION_POINTER_UP,
->
if (hoverActiveAtPressStart) {
isHovered = isWithinBounds(event)
Comment thread
j-piasecki marked this conversation as resolved.
}
MotionEvent.ACTION_CANCEL -> isHovered = false
}

val handled = super.onTouchEvent(event)

// Replay press-in / press-out animations across drag transitions.
if (handled && canRespondToTouches()) {
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) {
Expand All @@ -548,6 +621,15 @@ class RNGestureHandlerButtonViewManager :
return false
}

override fun onHoverEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_HOVER_ENTER -> onHoverIn()
MotionEvent.ACTION_HOVER_EXIT -> onHoverOut()
}

return super.onHoverEvent(event)
}

private fun getAnimatorDurationScale(): Float = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ValueAnimator.getDurationScale()
} else {
Expand All @@ -562,21 +644,18 @@ class RNGestureHandlerButtonViewManager :
}

private fun applyStartAnimationState() {
if (activeOpacity != 1.0f || defaultOpacity != 1.0f) {
if (hasOpacityAnimation) {
alpha = defaultOpacity
}
if (activeScale != 1.0f || defaultScale != 1.0f) {
if (hasScaleAnimation) {
scaleX = defaultScale
scaleY = defaultScale
}
underlayDrawable?.alpha = (defaultUnderlayOpacity * 255).toInt()
}

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
if (!hasOpacity && !hasScale && !hasUnderlay) {
if (!hasOpacityAnimation && !hasScaleAnimation && !hasUnderlayAnimation) {
return
}

Expand All @@ -594,28 +673,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<Animator>()
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 {
Expand All @@ -635,6 +714,57 @@ class RNGestureHandlerButtonViewManager :
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, tapAnimationInDuration.toLong())
}

private fun animateHoverState() {
if (isPressed) {
return
}

if (shouldAnimateHover) {
animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong())
} else {
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, hoverAnimationOutDuration.toLong())
}
}

private fun onHoverIn() {
cancelPendingHoverOut()

if (isHovered) {
return
}
Comment thread
j-piasecki marked this conversation as resolved.

isHovered = true
animateHoverState()
}

private fun onHoverOut() {
if (isPressed) {
isHovered = false
return
}

cancelPendingHoverOut()

// 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
animateHoverState()
}
Comment thread
j-piasecki marked this conversation as resolved.

pendingHoverOut = callback
Choreographer.getInstance().postFrameCallback(callback)
}

private fun cancelPendingHoverOut() {
pendingHoverOut?.let { Choreographer.getInstance().removeFrameCallback(it) }
pendingHoverOut = null
}

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()
Expand All @@ -645,20 +775,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.
Comment thread
m-bert marked this conversation as resolved.
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,
Expand Down Expand Up @@ -783,8 +913,10 @@ class RNGestureHandlerButtonViewManager :
super.onDetachedFromWindow()
pendingPressOut?.let { handler.removeCallbacks(it) }
pendingPressOut = null
cancelPendingHoverOut()
currentAnimator?.cancel()
currentAnimator = null
isHovered = false
applyStartAnimationState()

if (touchResponder === this) {
Expand Down Expand Up @@ -913,6 +1045,15 @@ class RNGestureHandlerButtonViewManager :
}
}

override fun setEnabled(enabled: Boolean) {
val changed = enabled != isEnabled
super.setEnabled(enabled)

if (changed && isHovered) {
animateHoverState()
}
}

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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,38 +102,38 @@ 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.
*/
hoverOpacity?: number | undefined;

/**
* Web only.
* Web and Android only.
*
* Scale applied to the button when it is hovered. Defaults to
* `defaultScale` when not set.
*/
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.
*/
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.
*/
Expand Down
Loading
Loading