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