diff --git a/.changeset/single-tap-events.md b/.changeset/single-tap-events.md new file mode 100644 index 0000000..7acfa3b --- /dev/null +++ b/.changeset/single-tap-events.md @@ -0,0 +1,29 @@ +--- +'react-native-gesture-image-viewer': minor +--- + +feat: add cross-platform single tap support for `GestureViewer` + +This release adds `onSingleTap` to `GestureViewer` so you can handle confirmed single taps without overlaying an extra pressable on top of the viewer. + +```tsx + setShowControls((prev) => !prev)} +/> +``` + +It also adds a `tap` event to `useGestureViewerEvent`, currently emitting confirmed single taps with `{ kind: 'single', x, y, index }`. + +```tsx +useGestureViewerEvent('tap', (event) => { + if (event.kind === 'single') { + console.log(`Tapped item ${event.index} at (${event.x}, ${event.y})`); + } +}); +``` + +This improves common viewer UI patterns such as toggling headers, toolbars, counters, or captions on tap while preserving swipe, pinch, dismiss, and double-tap zoom behavior. + +Related discussion: https://github.com/saseungmin/react-native-gesture-image-viewer/discussions/157 diff --git a/docs/docs/2.x/en/guide/usage/basic-usage.mdx b/docs/docs/2.x/en/guide/usage/basic-usage.mdx index 423631d..9792d7f 100644 --- a/docs/docs/2.x/en/guide/usage/basic-usage.mdx +++ b/docs/docs/2.x/en/guide/usage/basic-usage.mdx @@ -88,6 +88,12 @@ function App() { console.log(`Rotation changed from ${data.previousRotation}° to ${data.rotation}°`); }); + useGestureViewerEvent('tap', (data) => { + if (data.kind === 'single') { + console.log(`Tapped item ${data.index} at (${data.x}, ${data.y})`); + } + }); + return ; } ``` diff --git a/docs/docs/2.x/en/guide/usage/gesture-viewer-props.mdx b/docs/docs/2.x/en/guide/usage/gesture-viewer-props.mdx index 475be14..4569716 100644 --- a/docs/docs/2.x/en/guide/usage/gesture-viewer-props.mdx +++ b/docs/docs/2.x/en/guide/usage/gesture-viewer-props.mdx @@ -102,6 +102,29 @@ function App() { } ``` +### `onSingleTap` + +Runs when the viewer confirms a single tap. This is useful for toggling viewer chrome such as headers, footers, captions, and action buttons without overlaying another pressable on top of the viewer. + +- May resolve slightly later when double-tap zoom is enabled because the viewer waits to confirm it is not a double tap +- Does not fire for swipe, pinch, dismiss, or double-tap zoom gestures + +```tsx +import { GestureViewer } from 'react-native-gesture-image-viewer'; + +function App() { + const [showControls, setShowControls] = useState(true); + + return ( + setShowControls((prev) => !prev)} // [!code highlight] + /> + ); +} +``` + ### `initialIndex` (default: `0`) Sets the initial index value. @@ -144,9 +167,18 @@ export interface GestureViewerProps { * A callback function that is called to render the item. */ renderItem: (item: ItemT, index: number) => React.ReactElement; + /** + * A callback function that is called when a single tap is confirmed on the viewer content. + * @remarks + * - The callback runs only after the tap is resolved as a single tap, so it may be slightly delayed when double-tap zoom is enabled. + * - It is not called for swipe, pinch, dismiss, or double-tap zoom gestures. + * - Prefer this callback over overlaying a pressable in `renderContainer` for fullscreen tap handling. + */ + onSingleTap?: (event: GestureViewerSingleTapEvent) => void; /** * A callback function that is called to render the container. * @remarks Useful for composing additional UI (e.g., close button, toolbars) around the viewer. + * Prefer `onSingleTap` for fullscreen tap handling instead of overlaying a pressable over the viewer content. * The second argument provides control helpers such as `dismiss()` to close the viewer. * * @param children - The viewer content to be rendered inside your container. diff --git a/docs/docs/2.x/en/guide/usage/handling-viewer-events.mdx b/docs/docs/2.x/en/guide/usage/handling-viewer-events.mdx index 0f58db1..38532a3 100644 --- a/docs/docs/2.x/en/guide/usage/handling-viewer-events.mdx +++ b/docs/docs/2.x/en/guide/usage/handling-viewer-events.mdx @@ -1,6 +1,6 @@ # Handling Viewer Events -`GestureViewer` provides a way to subscribe to specific events from the viewer using the `useGestureViewerEvent` hook. This allows you to respond to real-time gesture changes like zoom and rotation. +`GestureViewer` provides a way to subscribe to specific events from the viewer using the `useGestureViewerEvent` hook. This allows you to respond to viewer interactions such as zoom, rotation, and taps. ## useGestureViewerEvent @@ -16,6 +16,12 @@ function App() { console.log(`Rotation changed from ${data.previousRotation}° to ${data.rotation}°`); }); + useGestureViewerEvent('tap', (data) => { + if (data.kind === 'single') { + console.log(`Tapped item ${data.index} at (${data.x}, ${data.y})`); + } + }); + return ; } ``` @@ -52,7 +58,12 @@ function useGestureViewerEvent( ): void; ``` -| Event Type | Description | Callback Data | -| :--------------- | :------------------------------------------------------------- | :----------------------------------------------- | -| `zoomChange` | Fired when the zoom scale changes during pinch gestures | `{ scale: number, previousScale: number }` | -| `rotationChange` | Fired when the rotation angle changes during rotation gestures | `{ rotation: number, previousRotation: number }` | +| Event Type | Description | Callback Data | +| :--------------- | :--------------------------------------------------------------------------------------- | :-------------------------------------------------------- | +| `zoomChange` | Fired when the zoom scale changes during pinch gestures | `{ scale: number, previousScale: number }` | +| `rotationChange` | Fired when the rotation angle changes during rotation gestures | `{ rotation: number, previousRotation: number }` | +| `tap` | Fired when a tap is confirmed by the viewer. Currently emits confirmed single taps only. | `{ kind: 'single', x: number, y: number, index: number }` | + +:::tip +If you want to handle the `tap` event directly from a `GestureViewer` prop, you can use [`onSingleTap`](/guide/usage/gesture-viewer-props.html#onsingletap). +::: diff --git a/docs/docs/2.x/en/guide/usage/style-customization.mdx b/docs/docs/2.x/en/guide/usage/style-customization.mdx index 8d138ec..4dfd04e 100644 --- a/docs/docs/2.x/en/guide/usage/style-customization.mdx +++ b/docs/docs/2.x/en/guide/usage/style-customization.mdx @@ -29,3 +29,7 @@ function App() { | `containerStyle` | Allows custom styling of the container that wraps the list component. | `flex: 1` | | `backdropStyle` | Allows customization of the viewer's background style. | `backgroundColor: black; StyleSheet.absoluteFill;` | | `renderContainer(children, helpers)` | Allows custom wrapper component around ``. | | + +:::tip +Use `onSingleTap` for fullscreen tap handling. `renderContainer` is best for composing surrounding UI such as headers, close buttons, and toolbars. +::: diff --git a/docs/docs/2.x/ko/guide/usage/basic-usage.mdx b/docs/docs/2.x/ko/guide/usage/basic-usage.mdx index a81bf86..0eaa0ac 100644 --- a/docs/docs/2.x/ko/guide/usage/basic-usage.mdx +++ b/docs/docs/2.x/ko/guide/usage/basic-usage.mdx @@ -89,6 +89,12 @@ function App() { console.log(`Rotation changed from ${data.previousRotation}° to ${data.rotation}°`); }); + useGestureViewerEvent('tap', (data) => { + if (data.kind === 'single') { + console.log(`Tapped item ${data.index} at (${data.x}, ${data.y})`); + } + }); + return ; } ``` diff --git a/docs/docs/2.x/ko/guide/usage/gesture-viewer-props.mdx b/docs/docs/2.x/ko/guide/usage/gesture-viewer-props.mdx index 8c7636a..d0d8192 100644 --- a/docs/docs/2.x/ko/guide/usage/gesture-viewer-props.mdx +++ b/docs/docs/2.x/ko/guide/usage/gesture-viewer-props.mdx @@ -102,6 +102,29 @@ function App() { } ``` +### `onSingleTap` + +뷰어에서 싱글 탭이 확정되면 실행됩니다. 헤더, 푸터, 캡션, 액션 버튼처럼 뷰어 컨트롤 UI를 토글할 때 유용하며, 뷰어 위에 별도의 pressable을 덮지 않아도 됩니다. + +- 더블 탭 줌이 활성화되어 있으면 더블 탭이 아닌지 확인한 뒤 실행되므로 약간 늦게 호출될 수 있습니다 +- 스와이프, 핀치, dismiss, 더블 탭 줌 제스처에서는 호출되지 않습니다 + +```tsx +import { GestureViewer } from 'react-native-gesture-image-viewer'; + +function App() { + const [showControls, setShowControls] = useState(true); + + return ( + setShowControls((prev) => !prev)} // [!code highlight] + /> + ); +} +``` + ### `initialIndex` (기본값: `0`) 초기 인덱스 값을 설정할 수 있습니다. @@ -144,9 +167,18 @@ export interface GestureViewerProps { * A callback function that is called to render the item. */ renderItem: (item: ItemT, index: number) => React.ReactElement; + /** + * A callback function that is called when a single tap is confirmed on the viewer content. + * @remarks + * - The callback runs only after the tap is resolved as a single tap, so it may be slightly delayed when double-tap zoom is enabled. + * - It is not called for swipe, pinch, dismiss, or double-tap zoom gestures. + * - Prefer this callback over overlaying a pressable in `renderContainer` for fullscreen tap handling. + */ + onSingleTap?: (event: GestureViewerSingleTapEvent) => void; /** * A callback function that is called to render the container. * @remarks Useful for composing additional UI (e.g., close button, toolbars) around the viewer. + * Prefer `onSingleTap` for fullscreen tap handling instead of overlaying a pressable over the viewer content. * The second argument provides control helpers such as `dismiss()` to close the viewer. * * @param children - The viewer content to be rendered inside your container. diff --git a/docs/docs/2.x/ko/guide/usage/handling-viewer-events.mdx b/docs/docs/2.x/ko/guide/usage/handling-viewer-events.mdx index 2f192a8..5f47495 100644 --- a/docs/docs/2.x/ko/guide/usage/handling-viewer-events.mdx +++ b/docs/docs/2.x/ko/guide/usage/handling-viewer-events.mdx @@ -1,6 +1,6 @@ # 뷰어 이벤트 처리하기 -`useGestureViewerEvent` 훅을 사용하여 `GestureViewer`의 특정 이벤트를 구독할 수 있습니다. 줌이나 회전과 같은 실시간 제스처 변화에 반응할 수 있습니다. +`useGestureViewerEvent` 훅을 사용하여 `GestureViewer`의 특정 이벤트를 구독할 수 있습니다. 줌, 회전, 탭과 같은 뷰어 상호작용에 반응할 수 있습니다. ## useGestureViewerEvent @@ -16,6 +16,12 @@ function App() { console.log(`회전 변경: ${data.previousRotation}° → ${data.rotation}°`); }); + useGestureViewerEvent('tap', (data) => { + if (data.kind === 'single') { + console.log(`아이템 ${data.index} 탭: (${data.x}, ${data.y})`); + } + }); + return ; } ``` @@ -52,7 +58,12 @@ function useGestureViewerEvent( ): void; ``` -| 이벤트 타입 | 설명 | 콜백 데이터 | -| :--------------- | :---------------------------------------- | :----------------------------------------------- | -| `zoomChange` | 핀치 제스처 중 줌 스케일이 변경될 때 발생 | `{ scale: number, previousScale: number }` | -| `rotationChange` | 회전 제스처 중 회전 각도가 변경될 때 발생 | `{ rotation: number, previousRotation: number }` | +| 이벤트 타입 | 설명 | 콜백 데이터 | +| :--------------- | :--------------------------------------------------------------------- | :-------------------------------------------------------- | +| `zoomChange` | 핀치 제스처 중 줌 스케일이 변경될 때 발생 | `{ scale: number, previousScale: number }` | +| `rotationChange` | 회전 제스처 중 회전 각도가 변경될 때 발생 | `{ rotation: number, previousRotation: number }` | +| `tap` | 뷰어에서 탭이 확정되면 발생합니다. 현재는 확정된 싱글 탭만 emit합니다. | `{ kind: 'single', x: number, y: number, index: number }` | + +:::tip +`tap` 이벤트를 `GestureViewer` prop로 직접 처리하고 싶다면 [`onSingleTap`](/ko/guide/usage/gesture-viewer-props.html#onsingletap)을 사용할 수 있습니다. +::: diff --git a/docs/docs/2.x/ko/guide/usage/style-customization.mdx b/docs/docs/2.x/ko/guide/usage/style-customization.mdx index febbc4e..1f06eee 100644 --- a/docs/docs/2.x/ko/guide/usage/style-customization.mdx +++ b/docs/docs/2.x/ko/guide/usage/style-customization.mdx @@ -29,3 +29,7 @@ function App() { | `containerStyle` | 리스트 컴포넌트를 감싸는 컨테이너의 커스텀 스타일을 지정할 수 있습니다. | `flex: 1` | | `backdropStyle` | 뷰어의 배경 스타일을 커스터마이징할 수 있습니다. | `backgroundColor: black; StyleSheet.absoluteFill;` | | `renderContainer(children, helpers)` | ``를 레핑하는 커스텀 래퍼 컴포넌트를 지정할 수 있습니다. | | + +:::tip +전체 화면 탭 처리는 `onSingleTap` 사용을 권장합니다. `renderContainer`는 헤더, 닫기 버튼, 툴바처럼 주변 UI를 구성할 때 가장 잘 맞습니다. +::: diff --git a/example/src/Example.tsx b/example/src/Example.tsx index a286158..a4a8e3d 100644 --- a/example/src/Example.tsx +++ b/example/src/Example.tsx @@ -12,13 +12,28 @@ import { } from 'react-native-gesture-image-viewer'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -const images = [ - 'https://picsum.photos/400/200', - 'https://picsum.photos/300/200', - 'https://picsum.photos/200/200', - 'https://picsum.photos/200/300', - 'https://picsum.photos/200/400', -]; +const photos = [ + { + uri: 'https://picsum.photos/400/200', + note: 'Single tap anywhere to hide or show the viewer controls.', + }, + { + uri: 'https://picsum.photos/300/200', + note: 'Pinch to zoom and swipe left or right to move between items.', + }, + { + uri: 'https://picsum.photos/200/200', + note: 'Use the toolbar buttons for zoom, rotate, and reset actions.', + }, + { + uri: 'https://picsum.photos/200/300', + note: 'Swipe down to dismiss the viewer at any time.', + }, + { + uri: 'https://picsum.photos/200/400', + note: 'Loop mode lets you keep paging without stopping at the end.', + }, +] as const; function Example() { const [visible, setVisible] = useState(false); @@ -47,6 +62,12 @@ function Example() { console.log(`Rotation changed from ${data.previousRotation}° to ${data.rotation}°`); }); + useGestureViewerEvent('tap', (data) => { + if (data.kind === 'single') { + console.log(`Tapped item ${data.index} at (${data.x}, ${data.y})`); + } + }); + const renderImage = useCallback((imageUrl: string) => { return ( setEnableLoop(!enableLoop)} /> - Click on thumbnail to open! + Click on a thumbnail to open the viewer. - {images.map((uri, index) => ( + {photos.map(({ uri }, index) => ( modalOpen(index)}> @@ -84,13 +105,14 @@ function Example() { > uri)} initialIndex={selectedIndex} onDismiss={() => setVisible(false)} onDismissStart={() => setShowExternalUI(false)} enableLoop={enableLoop} ListComponent={FlashList} renderItem={renderImage} + onSingleTap={() => setShowExternalUI((prev) => !prev)} backdropStyle={{ backgroundColor: '#181818' }} renderContainer={(children, helpers) => ( @@ -213,9 +235,10 @@ function Example() { onPress={goToNext} /> - {`${currentIndex + 1} / ${totalCount}`} + + {`${currentIndex + 1} / ${totalCount}`} + + {photos[currentIndex]?.note} )} @@ -259,6 +282,16 @@ const styles = StyleSheet.create({ fontSize: 22, fontWeight: 'bold', }, + subtext: { + textAlign: 'center', + color: '#666', + maxWidth: 320, + }, + noteText: { + textAlign: 'center', + color: 'white', + paddingHorizontal: 24, + }, }); export default Example; diff --git a/src/GestureViewer.tsx b/src/GestureViewer.tsx index 40d198f..56d3f8b 100644 --- a/src/GestureViewer.tsx +++ b/src/GestureViewer.tsx @@ -59,7 +59,7 @@ export function GestureViewer({ dismissGesture, zoomGesture, nativeScrollGesture, - onWebDoubleClick, + onWebClick, onMomentumScrollEnd, onScroll, onScrollBeginDrag, @@ -191,7 +191,9 @@ export function GestureViewer({ diff --git a/src/GestureViewerManager.ts b/src/GestureViewerManager.ts index c4509d3..594d8cb 100644 --- a/src/GestureViewerManager.ts +++ b/src/GestureViewerManager.ts @@ -86,6 +86,10 @@ class GestureViewerManager { this.emitEvent('rotationChange', { rotation, previousRotation }); }; + emitTap = (data: GestureViewerEventData['tap']) => { + this.emitEvent('tap', data); + }; + getState(): GestureViewerState { return { currentIndex: this.currentIndex, diff --git a/src/__tests__/manager.test.ts b/src/__tests__/manager.test.ts new file mode 100644 index 0000000..1a96929 --- /dev/null +++ b/src/__tests__/manager.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +jest.mock('react-native-reanimated', () => ({ + withTiming: (value: number) => value, +})); + +jest.mock('react-native-gesture-handler', () => ({ + FlatList: function GestureFlatList() { + return null; + }, + ScrollView: function GestureScrollView() { + return null; + }, +})); + +import GestureViewerManager from '../GestureViewerManager'; + +describe('GestureViewerManager tap events', () => { + it('emits tap events to tap listeners and supports unsubscribe', () => { + const manager = new GestureViewerManager(); + const tapListener = jest.fn(); + const zoomListener = jest.fn(); + + const unsubscribeTap = manager.addEventListener('tap', tapListener); + manager.addEventListener('zoomChange', zoomListener); + + manager.emitTap({ kind: 'single', x: 12, y: 34, index: 2 }); + + expect(tapListener).toHaveBeenCalledTimes(1); + expect(tapListener).toHaveBeenCalledWith({ kind: 'single', x: 12, y: 34, index: 2 }); + expect(zoomListener).not.toHaveBeenCalled(); + + unsubscribeTap(); + manager.emitTap({ kind: 'single', x: 1, y: 2, index: 0 }); + + expect(tapListener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 0ae3418..f619b3e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,6 +8,7 @@ export type { GestureViewerEventType, GestureViewerProps, GestureViewerState, + GestureViewerSingleTapEvent, } from './types'; export { useGestureViewerController } from './useGestureViewerController'; export { useGestureViewerEvent } from './useGestureViewerEvent'; diff --git a/src/types.ts b/src/types.ts index 10013aa..bdb4747 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,6 +75,13 @@ export type TriggerRect = { height: number; }; +export type GestureViewerSingleTapEvent = { + x: number; + y: number; + index: number; + item: ItemT; +}; + export interface TriggerAnimationConfig extends WithTimingConfig { /** * Animation duration in milliseconds @@ -121,9 +128,18 @@ export interface GestureViewerProps { * A callback function that is called to render the item. */ renderItem: (item: ItemT, index: number) => React.ReactElement; + /** + * A callback function that is called when a single tap is confirmed on the viewer content. + * @remarks + * - The callback runs only after the tap is resolved as a single tap, so it may be slightly delayed when double-tap zoom is enabled. + * - It is not called for swipe, pinch, dismiss, or double-tap zoom gestures. + * - Prefer this callback over overlaying a pressable in `renderContainer` for fullscreen tap handling. + */ + onSingleTap?: (event: GestureViewerSingleTapEvent) => void; /** * A callback function that is called to render the container. * @remarks Useful for composing additional UI (e.g., close button, toolbars) around the viewer. + * Prefer `onSingleTap` for fullscreen tap handling instead of overlaying a pressable over the viewer content. * The second argument provides control helpers such as `dismiss()` to close the viewer. * * @param children - The viewer content to be rendered inside your container. @@ -425,11 +441,14 @@ export type GestureViewerState = { readonly totalCount: number; }; -export type GestureViewerEventType = 'zoomChange' | 'rotationChange'; +export type GestureViewerEventType = 'zoomChange' | 'rotationChange' | 'tap'; + +export type SingleTapEventData = { kind: 'single'; x: number; y: number; index: number }; export type GestureViewerEventData = { zoomChange: { scale: number; previousScale: number | null }; rotationChange: { rotation: number; previousRotation: number | null }; + tap: SingleTapEventData; }; export type GestureViewerEventCallback = ( diff --git a/src/useGestureViewer.ts b/src/useGestureViewer.ts index 48de2ab..df51cb1 100644 --- a/src/useGestureViewer.ts +++ b/src/useGestureViewer.ts @@ -34,6 +34,7 @@ export const useGestureViewer = ({ data, initialIndex = 0, onDismiss, + onSingleTap, width: customWidth, dismiss, enableDoubleTapZoom = true, @@ -68,6 +69,9 @@ export const useGestureViewer = ({ const triggerRectRef = useRef(null); const pendingIndexRef = useRef(initialIndex); const onAnimationCompleteRef = useRef(triggerAnimation?.onAnimationComplete); + const onSingleTapRef = useRef(onSingleTap); + const dataRef = useRef(data); + const managerRef = useRef(manager); const isValidTriggerRect = useCallback((rect: TriggerRect | null): rect is TriggerRect => { return !!rect && rect.width > 0 && rect.height > 0; @@ -176,23 +180,13 @@ export const useGestureViewer = ({ [dataLength, manager, resetTransformState], ); - const emitZoomChange = useCallback( - (currentScale: number, prevScale: number | null) => { - if (manager) { - manager.emitZoomChange(currentScale, prevScale); - } - }, - [manager], - ); + const emitZoomChange = useCallback((currentScale: number, prevScale: number | null) => { + managerRef.current?.emitZoomChange(currentScale, prevScale); + }, []); - const emitRotationChange = useCallback( - (currentRotation: number, prevRotation: number | null) => { - if (manager) { - manager.emitRotationChange(currentRotation, prevRotation); - } - }, - [manager], - ); + const emitRotationChange = useCallback((currentRotation: number, prevRotation: number | null) => { + managerRef.current?.emitRotationChange(currentRotation, prevRotation); + }, []); const onAnimationComplete = useCallback(() => { onAnimationCompleteRef.current?.(); @@ -317,6 +311,18 @@ export const useGestureViewer = ({ onAnimationCompleteRef.current = triggerAnimation?.onAnimationComplete; }, [triggerAnimation?.onAnimationComplete]); + useEffect(() => { + dataRef.current = data; + }, [data]); + + useEffect(() => { + managerRef.current = manager; + }, [manager]); + + useEffect(() => { + onSingleTapRef.current = onSingleTap; + }, [onSingleTap]); + useEffect(() => { if (shouldStartTriggerAnimation && triggerRectRef.current) { const startX = triggerRectRef.current.x + triggerRectRef.current.width / 2 - width / 2; @@ -647,6 +653,40 @@ export const useGestureViewer = ({ ], ); + const emitSingleTap = useCallback((x: number, y: number) => { + const index = pendingIndexRef.current; + const currentData = dataRef.current; + + if (index < 0 || index >= currentData.length) { + return; + } + + managerRef.current?.emitTap({ kind: 'single', x, y, index }); + + const item = currentData[index]; + + if (item === undefined) { + return; + } + + onSingleTapRef.current?.({ x, y, index, item }); + }, []); + + const singleTapGesture = useMemo( + () => + Gesture.Tap() + .enabled(Platform.OS !== 'web') + .numberOfTaps(1) + .onEnd((event, success) => { + if (!success) { + return; + } + + scheduleOnRN(emitSingleTap, event.x, event.y); + }), + [emitSingleTap], + ); + const doubleTapGesture = useMemo( () => Gesture.Tap() @@ -667,9 +707,14 @@ export const useGestureViewer = ({ [enableDoubleTapZoom, height, maxZoomScale, scale, translateX, translateY, width], ); + const tapGesture = useMemo( + () => Gesture.Exclusive(doubleTapGesture, singleTapGesture), + [doubleTapGesture, singleTapGesture], + ); + const zoomGesture = useMemo( - () => Gesture.Race(zoomPinchGesture, Gesture.Exclusive(zoomPanGesture, doubleTapGesture)), - [zoomPinchGesture, zoomPanGesture, doubleTapGesture], + () => Gesture.Race(zoomPinchGesture, Gesture.Exclusive(zoomPanGesture, tapGesture)), + [zoomPinchGesture, zoomPanGesture, tapGesture], ); const animatedStyle = useAnimatedStyle(() => ({ @@ -702,30 +747,30 @@ export const useGestureViewer = ({ return Gesture.Native().requireExternalGestureToFail(dismissGestureRef); }, []); - const { onMomentumScrollEnd, onScroll, onScrollBeginDrag, onWebDoubleClick } = - useGestureViewerPaging({ - adjustedInitialIndex, - autoPlay, - autoPlayInterval, - currentIndex, - dataLength, - enableDoubleTapZoom, - enableHorizontalSwipe, - enableLoop, - height, - isRotated, - isZoomed, - itemSpacing, - manager, - maxZoomScale, - scale, - scrollTo, - syncCurrentIndex, - syncPendingIndex, - translateX, - translateY, - width, - }); + const { onMomentumScrollEnd, onScroll, onScrollBeginDrag, onWebClick } = useGestureViewerPaging({ + adjustedInitialIndex, + autoPlay, + autoPlayInterval, + currentIndex, + dataLength, + enableDoubleTapZoom, + enableHorizontalSwipe, + enableLoop, + height, + isRotated, + isZoomed, + itemSpacing, + manager, + maxZoomScale, + onSingleTap: emitSingleTap, + scale, + scrollTo, + syncCurrentIndex, + syncPendingIndex, + translateX, + translateY, + width, + }); return { animatedStyle, @@ -739,7 +784,7 @@ export const useGestureViewer = ({ isZoomed, listRef, nativeScrollGesture, - onWebDoubleClick, + onWebClick, onMomentumScrollEnd, onScroll, diff --git a/src/useGestureViewerEvent.ts b/src/useGestureViewerEvent.ts index 15a5185..9207ae9 100644 --- a/src/useGestureViewerEvent.ts +++ b/src/useGestureViewerEvent.ts @@ -8,7 +8,7 @@ import type { GestureViewerEventCallback, GestureViewerEventType } from './types * Hook for subscribing to GestureViewer events on the default instance. * * This hook allows you to listen to specific events from the default GestureViewer instance - * (with ID 'default'), such as zoom changes or rotation changes. Events are automatically + * (with ID 'default'), such as zoom changes, rotation changes, or confirmed tap events. Events are automatically * throttled to prevent excessive callback invocations during gestures. * * @param eventType - The type of event to listen for @@ -25,6 +25,13 @@ import type { GestureViewerEventCallback, GestureViewerEventType } from './types * useGestureViewerEvent('rotationChange', (data) => { * console.log(`Rotation changed from ${data.previousRotation}° to ${data.rotation}°`); * }); + * + * // Listen to confirmed single taps on the default instance (ID: 'default') + * useGestureViewerEvent('tap', (data) => { + * if (data.kind === 'single') { + * console.log(`Tapped item ${data.index} at (${data.x}, ${data.y})`); + * } + * }); * ``` */ export function useGestureViewerEvent( @@ -53,6 +60,13 @@ export function useGestureViewerEvent( * useGestureViewerEvent('modal-viewer', 'rotationChange', (data) => { * updateRotationIndicator(data.rotation); * }); + * + * // Listen to confirmed single taps on a specific instance + * useGestureViewerEvent('gallery', 'tap', (data) => { + * if (data.kind === 'single') { + * console.log(`Gallery tap on item ${data.index}`); + * } + * }); * ``` */ export function useGestureViewerEvent( diff --git a/src/useGestureViewerPaging.ts b/src/useGestureViewerPaging.ts index 45dd60f..0e29bf6 100644 --- a/src/useGestureViewerPaging.ts +++ b/src/useGestureViewerPaging.ts @@ -123,6 +123,6 @@ export function useGestureViewerPaging({ onMomentumScrollEnd, onScroll, onScrollBeginDrag, - onWebDoubleClick: undefined, + onWebClick: undefined, }; } diff --git a/src/useGestureViewerPaging.types.ts b/src/useGestureViewerPaging.types.ts index dc186bd..68aa921 100644 --- a/src/useGestureViewerPaging.types.ts +++ b/src/useGestureViewerPaging.types.ts @@ -1,3 +1,4 @@ +import type { MouseEvent } from 'react'; import type { ScrollViewProps } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; @@ -18,6 +19,7 @@ export type UseGestureViewerPagingArgs = { itemSpacing: number; manager: GestureViewerManager | null; maxZoomScale: number; + onSingleTap?: (x: number, y: number) => void; scale: SharedValue; scrollTo: (index: number, animated: boolean) => void; syncCurrentIndex: (nextIndex: number) => void; @@ -27,9 +29,13 @@ export type UseGestureViewerPagingArgs = { width: number; }; +export type WebClickTarget = { + getBoundingClientRect: () => { left: number; top: number }; +}; + export type UseGestureViewerPagingResult = { onMomentumScrollEnd?: ScrollViewProps['onMomentumScrollEnd']; onScroll?: ScrollViewProps['onScroll']; onScrollBeginDrag?: ScrollViewProps['onScrollBeginDrag']; - onWebDoubleClick?: (event: any) => void; + onWebClick?: (event: MouseEvent) => void; }; diff --git a/src/useGestureViewerPaging.web.ts b/src/useGestureViewerPaging.web.ts index 4459692..7ebbedc 100644 --- a/src/useGestureViewerPaging.web.ts +++ b/src/useGestureViewerPaging.web.ts @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, type MouseEvent } from 'react'; import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; import type { UseGestureViewerPagingArgs, UseGestureViewerPagingResult, + WebClickTarget, } from './useGestureViewerPaging.types'; import { applyTapZoomAtPoint } from './utils/tapZoom'; import { @@ -35,6 +36,7 @@ export function useGestureViewerPaging({ enableHorizontalSwipe, enableLoop, height, + onSingleTap, isRotated, isZoomed, itemSpacing, @@ -48,6 +50,8 @@ export function useGestureViewerPaging({ translateY, width, }: UseGestureViewerPagingArgs): UseGestureViewerPagingResult { + const webSingleTapTimerRef = useRef | null>(null); + const webScrollRuntimeRef = useRef({ actor: 'idle', isAutoplayPausedByUser: false, @@ -59,6 +63,13 @@ export function useGestureViewerPaging({ resumeAutoplayTimer: null, }); + const clearWebSingleTapTimer = useCallback(() => { + if (webSingleTapTimerRef.current) { + clearTimeout(webSingleTapTimerRef.current); + webSingleTapTimerRef.current = null; + } + }, []); + const clearWebSettleTimer = useCallback(() => { const runtime = webScrollRuntimeRef.current; @@ -232,10 +243,11 @@ export function useGestureViewerPaging({ useEffect(() => { return () => { + clearWebSingleTapTimer(); clearWebSettleTimer(); clearWebAutoplayResumeTimer(); }; - }, [clearWebAutoplayResumeTimer, clearWebSettleTimer]); + }, [clearWebAutoplayResumeTimer, clearWebSettleTimer, clearWebSingleTapTimer]); useEffect(() => { if ( @@ -351,50 +363,62 @@ export function useGestureViewerPaging({ manager?.handleScrollBeginDrag(); }, [beginWebUserInteraction, clearWebSettleTimer, manager]); - const onWebDoubleClick = useCallback( - (event: any) => { - if (!enableDoubleTapZoom || event?.nativeEvent?.detail !== 2) { + const onWebClick = useCallback( + (event: MouseEvent) => { + const detail = event.detail; + const rect = event.currentTarget.getBoundingClientRect(); + const resolvedX = event.clientX - rect.left; + const resolvedY = event.clientY - rect.top; + + if (!enableDoubleTapZoom) { + if (!onSingleTap) { + return; + } + + clearWebSingleTapTimer(); + onSingleTap(resolvedX, resolvedY); return; } - pauseWebAutoplayWithoutPagingInteraction(); - scheduleWebAutoplayResume(); + if (detail === 2) { + clearWebSingleTapTimer(); - const nativeEvent = event?.nativeEvent; - const eventTarget = event?.currentTarget ?? nativeEvent?.currentTarget ?? nativeEvent?.target; - let locationX = nativeEvent?.locationX; - let locationY = nativeEvent?.locationY; + pauseWebAutoplayWithoutPagingInteraction(); + scheduleWebAutoplayResume(); - if ( - (!Number.isFinite(locationX) || !Number.isFinite(locationY)) && - typeof eventTarget?.getBoundingClientRect === 'function' && - Number.isFinite(nativeEvent?.clientX) && - Number.isFinite(nativeEvent?.clientY) - ) { - const rect = eventTarget.getBoundingClientRect(); + applyTapZoomAtPoint({ + x: resolvedX, + y: resolvedY, + width, + height, + maxZoomScale, + scale, + translateX, + translateY, + }); + return; + } - locationX = nativeEvent.clientX - rect.left; - locationY = nativeEvent.clientY - rect.top; + if (detail !== 1 || !onSingleTap) { + return; } - applyTapZoomAtPoint({ - x: Number.isFinite(locationX) ? locationX : width / 2, - y: Number.isFinite(locationY) ? locationY : height / 2, - width, - height, - maxZoomScale, - scale, - translateX, - translateY, - }); + clearWebSingleTapTimer(); + + webSingleTapTimerRef.current = setTimeout(() => { + onSingleTap(resolvedX, resolvedY); + webSingleTapTimerRef.current = null; + }, 250); }, [ + clearWebSingleTapTimer, enableDoubleTapZoom, height, maxZoomScale, + onSingleTap, pauseWebAutoplayWithoutPagingInteraction, - scheduleWebAutoplayResume, scale, + scheduleWebAutoplayResume, translateX, translateY, width, @@ -405,6 +429,6 @@ export function useGestureViewerPaging({ onMomentumScrollEnd, onScroll, onScrollBeginDrag, - onWebDoubleClick, + onWebClick, }; }