Skip to content

Commit cb8847c

Browse files
committed
feat(example): replace expo-image-picker with custom ImagePickerSheet
Use TrueSheet + MediaLibrary to build a custom picker that reads from original files, preserving GPS EXIF metadata.
1 parent c844646 commit cb8847c

2 files changed

Lines changed: 205 additions & 25 deletions

File tree

example/src/App.tsx

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,23 @@
11
import { useEffect, useRef, useState } from 'react';
2-
import {
3-
StyleSheet,
4-
View,
5-
Pressable,
6-
Platform,
7-
Linking,
8-
Text,
9-
} from 'react-native';
2+
import { StyleSheet, View, Pressable, Linking, Text } from 'react-native';
103
import { CameraView, useCameraPermissions } from 'expo-camera';
11-
import * as ImagePicker from 'expo-image-picker';
4+
125
import * as MediaLibrary from 'expo-media-library';
136
import { Image } from 'expo-image';
147
import * as Exify from '@lodev09/react-native-exify';
158
import type { ExifTags } from '@lodev09/react-native-exify';
169

1710
import { mockPosition, json } from './utils';
1811
import { PromptSheet, type PromptSheetRef } from './components/PromptSheet';
12+
import {
13+
ImagePickerSheet,
14+
type ImagePickerSheetRef,
15+
} from './components/ImagePickerSheet';
1916

2017
export default function App() {
2118
const cameraRef = useRef<CameraView>(null);
2219
const promptRef = useRef<PromptSheetRef>(null);
20+
const pickerRef = useRef<ImagePickerSheetRef>(null);
2321
const [preview, setPreview] = useState<string>();
2422

2523
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
@@ -91,27 +89,16 @@ export default function App() {
9189
console.log('saved asset:', asset.uri);
9290

9391
setPreview(asset.uri);
92+
pickerRef.current?.refresh();
9493
await readExif(asset.uri);
9594
};
9695

9796
const pickImage = async () => {
98-
const result = await ImagePicker.launchImageLibraryAsync({
99-
mediaTypes: ['images'],
100-
quality: 1,
101-
});
102-
103-
if (result.canceled) return null;
97+
const result = await pickerRef.current?.pick();
98+
if (!result) return null;
10499

105-
const asset = result.assets[0];
106-
if (!asset) return null;
107-
108-
const uri =
109-
Platform.OS === 'ios' && asset.assetId
110-
? `ph://${asset.assetId}`
111-
: asset.uri;
112-
113-
setPreview(asset.uri);
114-
return uri;
100+
setPreview(result.preview);
101+
return result.uri;
115102
};
116103

117104
const openLibrary = async () => {
@@ -168,6 +155,7 @@ export default function App() {
168155
<View style={styles.container}>
169156
<CameraView ref={cameraRef} style={styles.camera} facing="back" />
170157
<PromptSheet ref={promptRef} />
158+
<ImagePickerSheet ref={pickerRef} />
171159
<View style={styles.controls}>
172160
<Pressable
173161
onPress={openLibrary}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import {
2+
forwardRef,
3+
useCallback,
4+
useImperativeHandle,
5+
useRef,
6+
useState,
7+
} from 'react';
8+
import {
9+
FlatList,
10+
Platform,
11+
Pressable,
12+
StyleSheet,
13+
Text,
14+
View,
15+
useWindowDimensions,
16+
type ListRenderItemInfo,
17+
} from 'react-native';
18+
import * as MediaLibrary from 'expo-media-library';
19+
import { Image } from 'expo-image';
20+
import { TrueSheet } from '@lodev09/react-native-true-sheet';
21+
22+
const NUM_COLUMNS = 3;
23+
const GAP = 2;
24+
const PAGE_SIZE = 60;
25+
26+
export interface ImagePickerResult {
27+
uri: string;
28+
preview: string;
29+
}
30+
31+
export interface ImagePickerSheetRef {
32+
pick: () => Promise<ImagePickerResult | null>;
33+
refresh: () => void;
34+
}
35+
36+
export const ImagePickerSheet = forwardRef<ImagePickerSheetRef>(
37+
(_props, ref) => {
38+
const sheetRef = useRef<TrueSheet>(null);
39+
const resolveRef = useRef<
40+
((result: ImagePickerResult | null) => void) | null
41+
>(null);
42+
43+
const [assets, setAssets] = useState<MediaLibrary.Asset[]>([]);
44+
const [endCursor, setEndCursor] = useState<string>();
45+
const [hasMore, setHasMore] = useState(true);
46+
47+
const { width } = useWindowDimensions();
48+
const itemSize = (width - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS;
49+
50+
const loadAssets = useCallback(async (after?: string) => {
51+
const result = await MediaLibrary.getAssetsAsync({
52+
first: PAGE_SIZE,
53+
after,
54+
mediaType: MediaLibrary.MediaType.photo,
55+
sortBy: [[MediaLibrary.SortBy.creationTime, false]],
56+
});
57+
58+
setAssets((prev) =>
59+
after ? [...prev, ...result.assets] : result.assets
60+
);
61+
setEndCursor(result.endCursor);
62+
setHasMore(result.hasNextPage);
63+
}, []);
64+
65+
const loadMore = useCallback(() => {
66+
if (hasMore && endCursor) {
67+
loadAssets(endCursor);
68+
}
69+
}, [hasMore, endCursor, loadAssets]);
70+
71+
useImperativeHandle(ref, () => ({
72+
pick: () => {
73+
loadAssets();
74+
return new Promise<ImagePickerResult | null>((resolve) => {
75+
resolveRef.current = resolve;
76+
sheetRef.current?.present();
77+
});
78+
},
79+
refresh: () => loadAssets(),
80+
}));
81+
82+
const getAssetUri = useCallback((asset: MediaLibrary.Asset) => {
83+
if (Platform.OS === 'ios') {
84+
return `ph://${asset.id}`;
85+
}
86+
return asset.uri;
87+
}, []);
88+
89+
const handleSelect = useCallback(
90+
(asset: MediaLibrary.Asset) => {
91+
const uri = getAssetUri(asset);
92+
resolveRef.current?.({ uri, preview: asset.uri });
93+
resolveRef.current = null;
94+
sheetRef.current?.dismiss();
95+
},
96+
[getAssetUri]
97+
);
98+
99+
const handleDismiss = useCallback(() => {
100+
resolveRef.current?.(null);
101+
resolveRef.current = null;
102+
}, []);
103+
104+
const renderItem = useCallback(
105+
({ item }: ListRenderItemInfo<MediaLibrary.Asset>) => (
106+
<Pressable
107+
onPress={() => handleSelect(item)}
108+
style={[styles.item, { width: itemSize, height: itemSize }]}
109+
>
110+
<Image
111+
source={{ uri: item.uri }}
112+
style={styles.image}
113+
recyclingKey={item.id}
114+
/>
115+
</Pressable>
116+
),
117+
[handleSelect, itemSize]
118+
);
119+
120+
const header = useCallback(
121+
() => (
122+
<View style={styles.header}>
123+
<Text style={styles.title}>Recents</Text>
124+
<Pressable
125+
style={styles.cancelButton}
126+
onPress={() => sheetRef.current?.dismiss()}
127+
>
128+
<Text style={styles.cancelText}>Cancel</Text>
129+
</Pressable>
130+
</View>
131+
),
132+
[]
133+
);
134+
135+
return (
136+
<TrueSheet
137+
ref={sheetRef}
138+
detents={[0.6, 1]}
139+
scrollable
140+
cornerRadius={14}
141+
backgroundColor="#1c1c1e"
142+
onDidDismiss={handleDismiss}
143+
header={header}
144+
>
145+
<FlatList
146+
data={assets}
147+
renderItem={renderItem}
148+
keyExtractor={keyExtractor}
149+
numColumns={NUM_COLUMNS}
150+
columnWrapperStyle={styles.row}
151+
onEndReached={loadMore}
152+
onEndReachedThreshold={0.5}
153+
/>
154+
</TrueSheet>
155+
);
156+
}
157+
);
158+
159+
const keyExtractor = (item: MediaLibrary.Asset) => item.id;
160+
161+
const styles = StyleSheet.create({
162+
header: {
163+
flexDirection: 'row',
164+
alignItems: 'center',
165+
justifyContent: 'space-between',
166+
paddingHorizontal: 16,
167+
paddingTop: 14,
168+
paddingBottom: 10,
169+
},
170+
title: {
171+
color: '#fff',
172+
fontSize: 17,
173+
fontWeight: '600',
174+
},
175+
cancelButton: {
176+
paddingVertical: 4,
177+
paddingHorizontal: 8,
178+
},
179+
cancelText: {
180+
color: '#0a84ff',
181+
fontSize: 17,
182+
},
183+
row: {
184+
gap: GAP,
185+
},
186+
item: {
187+
overflow: 'hidden',
188+
},
189+
image: {
190+
flex: 1,
191+
},
192+
});

0 commit comments

Comments
 (0)