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
29 changes: 29 additions & 0 deletions .changeset/single-tap-events.md
Original file line number Diff line number Diff line change
@@ -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
<GestureViewer
data={images}
renderItem={renderImage}
onSingleTap={() => 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
6 changes: 6 additions & 0 deletions docs/docs/2.x/en/guide/usage/basic-usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <GestureViewer data={images} renderItem={renderImage} />;
}
```
Expand Down
32 changes: 32 additions & 0 deletions docs/docs/2.x/en/guide/usage/gesture-viewer-props.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<GestureViewer
data={images}
renderItem={renderImage}
onSingleTap={() => setShowControls((prev) => !prev)} // [!code highlight]
/>
Comment thread
saseungmin marked this conversation as resolved.
);
}
```

### `initialIndex` (default: `0`)

Sets the initial index value.
Expand Down Expand Up @@ -144,9 +167,18 @@ export interface GestureViewerProps<ItemT, LC> {
* 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<ItemT>) => 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.
Expand Down
21 changes: 16 additions & 5 deletions docs/docs/2.x/en/guide/usage/handling-viewer-events.mdx
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 <GestureViewer data={images} renderItem={renderImage} />;
}
```
Expand Down Expand Up @@ -52,7 +58,12 @@ function useGestureViewerEvent<T extends GestureViewerEventType>(
): 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 }` |
Comment thread
saseungmin marked this conversation as resolved.

:::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).
:::
4 changes: 4 additions & 0 deletions docs/docs/2.x/en/guide/usage/style-customization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<GestureViewer />`. | |

:::tip
Use `onSingleTap` for fullscreen tap handling. `renderContainer` is best for composing surrounding UI such as headers, close buttons, and toolbars.
:::
6 changes: 6 additions & 0 deletions docs/docs/2.x/ko/guide/usage/basic-usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <GestureViewer data={images} renderItem={renderImage} />;
}
```
Expand Down
32 changes: 32 additions & 0 deletions docs/docs/2.x/ko/guide/usage/gesture-viewer-props.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<GestureViewer
data={images}
renderItem={renderImage}
onSingleTap={() => setShowControls((prev) => !prev)} // [!code highlight]
/>
Comment thread
saseungmin marked this conversation as resolved.
);
}
```

### `initialIndex` (기본값: `0`)

초기 인덱스 값을 설정할 수 있습니다.
Expand Down Expand Up @@ -144,9 +167,18 @@ export interface GestureViewerProps<ItemT, LC> {
* 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<ItemT>) => 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.
Expand Down
21 changes: 16 additions & 5 deletions docs/docs/2.x/ko/guide/usage/handling-viewer-events.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 뷰어 이벤트 처리하기

`useGestureViewerEvent` 훅을 사용하여 `GestureViewer`의 특정 이벤트를 구독할 수 있습니다. 줌이나 회전과 같은 실시간 제스처 변화에 반응할 수 있습니다.
`useGestureViewerEvent` 훅을 사용하여 `GestureViewer`의 특정 이벤트를 구독할 수 있습니다. 줌, 회전, 탭과 같은 뷰어 상호작용에 반응할 수 있습니다.

## useGestureViewerEvent

Expand All @@ -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 <GestureViewer data={images} renderItem={renderImage} />;
}
```
Expand Down Expand Up @@ -52,7 +58,12 @@ function useGestureViewerEvent<T extends GestureViewerEventType>(
): 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 }` |
Comment thread
saseungmin marked this conversation as resolved.

:::tip
`tap` 이벤트를 `GestureViewer` prop로 직접 처리하고 싶다면 [`onSingleTap`](/ko/guide/usage/gesture-viewer-props.html#onsingletap)을 사용할 수 있습니다.
:::
4 changes: 4 additions & 0 deletions docs/docs/2.x/ko/guide/usage/style-customization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ function App() {
| `containerStyle` | 리스트 컴포넌트를 감싸는 컨테이너의 커스텀 스타일을 지정할 수 있습니다. | `flex: 1` |
| `backdropStyle` | 뷰어의 배경 스타일을 커스터마이징할 수 있습니다. | `backgroundColor: black; StyleSheet.absoluteFill;` |
| `renderContainer(children, helpers)` | `<GestureViewer />`를 레핑하는 커스텀 래퍼 컴포넌트를 지정할 수 있습니다. | |

:::tip
전체 화면 탭 처리는 `onSingleTap` 사용을 권장합니다. `renderContainer`는 헤더, 닫기 버튼, 툴바처럼 주변 UI를 구성할 때 가장 잘 맞습니다.
:::
59 changes: 46 additions & 13 deletions example/src/Example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<Image
Expand All @@ -65,9 +86,9 @@ function Example() {
title={`Loop: ${enableLoop ? 'ON' : 'OFF'}`}
onPress={() => setEnableLoop(!enableLoop)}
/>
<Text style={styles.text}>Click on thumbnail to open!</Text>
<Text style={styles.text}>Click on a thumbnail to open the viewer.</Text>
<View style={styles.galleryContainer}>
{images.map((uri, index) => (
{photos.map(({ uri }, index) => (
<GestureTrigger key={uri} index={index} onPress={() => modalOpen(index)}>
<Pressable style={styles.thumb}>
<Image source={{ uri }} style={styles.thumbImage} contentFit="cover" />
Expand All @@ -84,13 +105,14 @@ function Example() {
>
<View style={{ flex: 1 }}>
<GestureViewer
data={images}
data={photos.map(({ uri }) => 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) => (
<View style={{ flex: 1 }}>
Expand Down Expand Up @@ -213,9 +235,10 @@ function Example() {
onPress={goToNext}
/>
</View>
<Text
style={{ textAlign: 'center', color: 'white' }}
>{`${currentIndex + 1} / ${totalCount}`}</Text>
<Text style={{ textAlign: 'center', color: 'white' }}>
{`${currentIndex + 1} / ${totalCount}`}
</Text>
<Text style={styles.noteText}>{photos[currentIndex]?.note}</Text>
</View>
</>
)}
Expand Down Expand Up @@ -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;
6 changes: 4 additions & 2 deletions src/GestureViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function GestureViewer<ItemT, LC>({
dismissGesture,
zoomGesture,
nativeScrollGesture,
onWebDoubleClick,
onWebClick,
onMomentumScrollEnd,
onScroll,
onScrollBeginDrag,
Expand Down Expand Up @@ -191,7 +191,9 @@ export function GestureViewer<ItemT, LC>({
<Animated.View style={[styles.background, backdropStyleProps, backdropStyle]} />
<Animated.View
style={[styles.content, animatedStyle]}
{...(Platform.OS === 'web' && { onClick: onWebDoubleClick })}
{...(Platform.OS === 'web' && {
onClick: onWebClick,
})}
{...(Platform.OS === 'web' &&
isFlashListLike(Component) && { dataSet: { 'flash-list-paging-enabled-fix': true } })}
>
Expand Down
4 changes: 4 additions & 0 deletions src/GestureViewerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading