diff --git a/apps/common-app/src/new_api/components/touchable/index.tsx b/apps/common-app/src/new_api/components/touchable/index.tsx index 2f5b9f52d1..4141b9625c 100644 --- a/apps/common-app/src/new_api/components/touchable/index.tsx +++ b/apps/common-app/src/new_api/components/touchable/index.tsx @@ -91,6 +91,60 @@ export default function TouchableExample() { + + Hover states + Hover-only feedback (web). + + + + + + + + + + Hover + press combined. + + + + + + + + + + Android ripple Configurable ripple effect on Touchable component. diff --git a/packages/docs-gesture-handler/docs/components/touchable.mdx b/packages/docs-gesture-handler/docs/components/touchable.mdx index 16b53853a1..a5f179ec19 100644 --- a/packages/docs-gesture-handler/docs/components/touchable.mdx +++ b/packages/docs-gesture-handler/docs/components/touchable.mdx @@ -172,6 +172,22 @@ defaultOpacity?: number; Defines the opacity of the whole component when the button is active. By default set to `1`. +### activeScale + +```ts +activeScale?: number; +``` + +Defines the scale of the whole component when the button is active. + +### defaultScale + +```ts +defaultScale?: number; +``` + +Defines the scale of the whole component when the button is inactive. By default set to `1`. + ### activeUnderlayOpacity ```ts @@ -188,6 +204,46 @@ defaultUnderlayOpacity?: number; Defines the initial opacity of underlay when the button is inactive. By default set to `0`. + +### hoverOpacity + + +```ts +hoverOpacity?: number; +``` + +Defines the opacity of the whole component when the button is hovered. By default falls back to [`defaultOpacity`](#defaultopacity). + + +### hoverScale + + +```ts +hoverScale?: number; +``` + +Defines the scale of the whole component when the button is hovered. By default falls back to [`defaultScale`](#defaultscale). + + +### hoverUnderlayOpacity + + +```ts +hoverUnderlayOpacity?: number; +``` + +Defines the opacity of the underlay when the button is hovered. By default falls back to [`defaultUnderlayOpacity`](#defaultunderlayopacity). + + +### hoverAnimationDuration + + +```ts +hoverAnimationDuration?: number; +``` + +Duration of the hover animation, in milliseconds. By default falls back to [`tapAnimationDuration`](#tapanimationduration). + ### underlayColor ```ts diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index d2fa0ea62b..4fdb6ab6c1 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -88,6 +88,38 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { */ activeUnderlayOpacity?: number | undefined; + /** + * Web only. + * + * Opacity applied to the button when it is hovered. Defaults to + * `defaultOpacity` when not set. + */ + hoverOpacity?: number | undefined; + + /** + * Web only. + * + * Scale applied to the button when it is hovered. Defaults to + * `defaultScale` when not set. + */ + hoverScale?: number | undefined; + + /** + * Web only. + * + * Opacity applied to the underlay when the button is hovered. Defaults + * to `defaultUnderlayOpacity` when not set. + */ + hoverUnderlayOpacity?: number | undefined; + + /** + * Web only. + * + * Duration of the hover animation, in milliseconds. Defaults to + * `tapAnimationDuration` when not set (or set to any negative value). + */ + hoverAnimationDuration?: number | undefined; + /** * Opacity applied to the button when it is not pressed. */ diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index 973b999879..68044c1cb8 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -9,9 +9,13 @@ type ButtonProps = ViewProps & { enabled?: boolean; pressAndHoldAnimationDuration?: number; tapAnimationDuration?: number; + hoverAnimationDuration?: number; activeOpacity?: number; activeScale?: number; activeUnderlayOpacity?: number; + hoverOpacity?: number; + hoverScale?: number; + hoverUnderlayOpacity?: number; defaultOpacity?: number; defaultScale?: number; defaultUnderlayOpacity?: number; @@ -23,9 +27,13 @@ export const ButtonComponent = ({ enabled = true, pressAndHoldAnimationDuration: pressAndHoldAnimationDurationProp = -1, tapAnimationDuration: tapAnimationDurationProp = 100, + hoverAnimationDuration: hoverAnimationDurationProp = -1, activeOpacity = 1, activeScale = 1, activeUnderlayOpacity = 0, + hoverOpacity: hoverOpacityProp, + hoverScale: hoverScaleProp, + hoverUnderlayOpacity: hoverUnderlayOpacityProp, defaultOpacity = 1, defaultScale = 1, defaultUnderlayOpacity = 0, @@ -40,8 +48,18 @@ export const ButtonComponent = ({ pressAndHoldAnimationDurationProp < 0 ? tapAnimationDuration : pressAndHoldAnimationDurationProp; + const hoverAnimationDuration = + hoverAnimationDurationProp < 0 + ? tapAnimationDuration + : hoverAnimationDurationProp; + + const hoverOpacity = hoverOpacityProp ?? defaultOpacity; + const hoverScale = hoverScaleProp ?? defaultScale; + const hoverUnderlayOpacity = + hoverUnderlayOpacityProp ?? defaultUnderlayOpacity; const [pressed, setPressed] = React.useState(false); + const [hovered, setHovered] = React.useState(false); const [currentDuration, setCurrentDuration] = React.useState( pressAndHoldAnimationDuration ); @@ -156,14 +174,58 @@ export const ButtonComponent = ({ [pressAndHoldAnimationDuration, tapAnimationDuration] ); + const handlePointerEnter = React.useCallback( + (event: NativeSyntheticEvent<{ pointerType?: string }>) => { + if (!enabled || event.nativeEvent.pointerType === 'touch') { + return; + } + // Skip duration update while pressed so the press transition owns it. + if (!pressed) { + setCurrentDuration(hoverAnimationDuration); + } + setHovered(true); + }, + [enabled, pressed, hoverAnimationDuration] + ); + + const handlePointerLeave = React.useCallback( + (event: NativeSyntheticEvent<{ pointerType?: string }>) => { + pressOut(event); + if (event.nativeEvent.pointerType === 'touch') { + return; + } + if (!pressed) { + setCurrentDuration(hoverAnimationDuration); + } + setHovered(false); + }, + [pressOut, pressed, hoverAnimationDuration] + ); + + // Mask hover at render rather than clearing the state. Avoids a state + // write inside an effect, and lets hover resume naturally when `enabled` + // flips back to true while the pointer is still inside. + const effectiveHovered = hovered && enabled; + const currentUnderlayOpacity = pressed ? activeUnderlayOpacity - : defaultUnderlayOpacity; + : effectiveHovered + ? hoverUnderlayOpacity + : defaultUnderlayOpacity; const hasUnderlay = underlayColor != null; - const hasOpacity = activeOpacity !== 1 || defaultOpacity !== 1; - const currentOpacity = pressed ? activeOpacity : defaultOpacity; - const hasScale = activeScale !== 1 || defaultScale !== 1; - const currentScale = pressed ? activeScale : defaultScale; + const hasOpacity = + activeOpacity !== 1 || hoverOpacity !== 1 || defaultOpacity !== 1; + const currentOpacity = pressed + ? activeOpacity + : effectiveHovered + ? hoverOpacity + : defaultOpacity; + const hasScale = activeScale !== 1 || hoverScale !== 1 || defaultScale !== 1; + const currentScale = pressed + ? activeScale + : effectiveHovered + ? hoverScale + : defaultScale; const easing = 'cubic-bezier(0.5, 1, 0.89, 1)'; const transitionProps: string[] = []; @@ -177,6 +239,7 @@ export const ButtonComponent = ({ return ( + onPointerLeave={handlePointerLeave}> {hasUnderlay && (