1import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; 2import {Pressable, StyleSheet, View} from 'react-native'; 3import rpx from '@/utils/rpx'; 4import ThemeText from '@/components/base/themeText'; 5import { 6 ExternalStorageDirectoryPath, 7 readDir, 8 getAllExternalFilesDirs, 9 exists, 10} from 'react-native-fs'; 11import {FlatList} from 'react-native-gesture-handler'; 12import useColors from '@/hooks/useColors'; 13import Color from 'color'; 14import IconButton from '@/components/base/iconButton'; 15import FileItem from './fileItem'; 16import Empty from '@/components/base/empty'; 17import useHardwareBack from '@/hooks/useHardwareBack'; 18import {useNavigation} from '@react-navigation/native'; 19import Loading from '@/components/base/loading'; 20import {useParams} from '@/entry/router'; 21import {SafeAreaView} from 'react-native-safe-area-context'; 22import StatusBar from '@/components/base/statusBar'; 23 24interface IPathItem { 25 path: string; 26 parent: null | IPathItem; 27} 28 29interface IFileItem { 30 path: string; 31 type: 'file' | 'folder'; 32} 33 34const ITEM_HEIGHT = rpx(96); 35 36export default function FileSelector() { 37 const { 38 fileType = 'file-and-folder', 39 multi = true, 40 actionText = '确定', 41 matchExtension, 42 onAction, 43 } = useParams<'file-selector'>() ?? {}; 44 45 const [currentPath, setCurrentPath] = useState<IPathItem>({ 46 path: '/', 47 parent: null, 48 }); 49 const currentPathRef = useRef<IPathItem>(currentPath); 50 const [filesData, setFilesData] = useState<IFileItem[]>([]); 51 const [checkedItems, setCheckedItems] = useState<IFileItem[]>([]); 52 const getCheckedPaths = useMemo( 53 () => () => checkedItems.map(_ => _.path), 54 [checkedItems], 55 ); 56 const navigation = useNavigation(); 57 const colors = useColors(); 58 const [loading, setLoading] = useState(false); 59 60 useEffect(() => { 61 (async () => { 62 // 路径变化时,重新读取 63 setLoading(true); 64 try { 65 if (currentPath.path === '/') { 66 try { 67 const allExt = await getAllExternalFilesDirs(); 68 if (allExt.length > 1) { 69 const sdCardPaths = allExt.map(sdp => 70 sdp.substring(0, sdp.indexOf('/Android')), 71 ); 72 if ( 73 ( 74 await Promise.all( 75 sdCardPaths.map(_ => exists(_)), 76 ) 77 ).every(val => val) 78 ) { 79 setFilesData( 80 sdCardPaths.map(_ => ({ 81 type: 'folder', 82 path: _, 83 })), 84 ); 85 } 86 } else { 87 setCurrentPath({ 88 path: ExternalStorageDirectoryPath, 89 parent: null, 90 }); 91 return; 92 } 93 } catch { 94 setCurrentPath({ 95 path: ExternalStorageDirectoryPath, 96 parent: null, 97 }); 98 return; 99 } 100 } else { 101 const res = (await readDir(currentPath.path)) ?? []; 102 let folders: IFileItem[] = []; 103 let files: IFileItem[] = []; 104 if ( 105 fileType === 'folder' || 106 fileType === 'file-and-folder' 107 ) { 108 folders = res 109 .filter(_ => _.isDirectory()) 110 .map(_ => ({ 111 type: 'folder', 112 path: _.path, 113 })); 114 } 115 if (fileType === 'file' || fileType === 'file-and-folder') { 116 files = res 117 .filter( 118 _ => 119 _.isFile() && 120 (matchExtension 121 ? matchExtension(_.path) 122 : true), 123 ) 124 .map(_ => ({ 125 type: 'file', 126 path: _.path, 127 })); 128 } 129 setFilesData([...folders, ...files]); 130 } 131 } catch { 132 setFilesData([]); 133 } 134 setLoading(false); 135 currentPathRef.current = currentPath; 136 })(); 137 }, [currentPath.path]); 138 139 useHardwareBack(() => { 140 // 注意闭包 141 const _currentPath = currentPathRef.current; 142 if (_currentPath.parent !== null) { 143 setCurrentPath(_currentPath.parent); 144 } else { 145 navigation.goBack(); 146 } 147 return true; 148 }); 149 150 const selectPath = useCallback((item: IFileItem, nextChecked: boolean) => { 151 if (multi) { 152 setCheckedItems(prev => { 153 if (nextChecked) { 154 return [...prev, item]; 155 } else { 156 return prev.filter(_ => _ !== item); 157 } 158 }); 159 } else { 160 setCheckedItems(nextChecked ? [item] : []); 161 } 162 }, []); 163 164 const renderItem = ({item}: {item: IFileItem}) => ( 165 <FileItem 166 path={item.path} 167 type={item.type} 168 parentPath={currentPath.path} 169 onItemPress={currentChecked => { 170 if (item.type === 'folder') { 171 setCurrentPath(prev => ({ 172 parent: prev, 173 path: item.path, 174 })); 175 } else { 176 selectPath(item, !currentChecked); 177 } 178 }} 179 checked={getCheckedPaths().includes(item.path)} 180 onCheckedChange={checked => { 181 selectPath(item, checked); 182 }} 183 /> 184 ); 185 186 return ( 187 <SafeAreaView style={style.wrapper}> 188 <StatusBar /> 189 <View style={[style.header, {backgroundColor: colors.primary}]}> 190 <IconButton 191 size="small" 192 name="keyboard-backspace" 193 onPress={() => { 194 // 返回上一级 195 if (currentPath.parent !== null) { 196 setCurrentPath(currentPath.parent); 197 } 198 }} 199 /> 200 <ThemeText 201 numberOfLines={2} 202 ellipsizeMode="head" 203 style={style.headerPath}> 204 {currentPath.path} 205 </ThemeText> 206 </View> 207 {loading ? ( 208 <Loading /> 209 ) : ( 210 <> 211 <FlatList 212 ListEmptyComponent={Empty} 213 style={style.wrapper} 214 data={filesData} 215 getItemLayout={(_, index) => ({ 216 length: ITEM_HEIGHT, 217 offset: ITEM_HEIGHT * index, 218 index, 219 })} 220 renderItem={renderItem} 221 /> 222 </> 223 )} 224 <Pressable 225 onPress={async () => { 226 if (checkedItems.length) { 227 const shouldBack = await onAction?.(checkedItems); 228 if (shouldBack) { 229 navigation.goBack(); 230 } 231 } 232 }}> 233 <View 234 style={[ 235 style.scanBtn, 236 { 237 backgroundColor: Color(colors.primary) 238 .alpha(0.8) 239 .toString(), 240 }, 241 ]}> 242 <ThemeText 243 fontColor={ 244 checkedItems.length > 0 ? 'normal' : 'secondary' 245 }> 246 {actionText} 247 {multi && checkedItems?.length > 0 248 ? ` (选中${checkedItems.length})` 249 : ''} 250 </ThemeText> 251 </View> 252 </Pressable> 253 </SafeAreaView> 254 ); 255} 256 257const style = StyleSheet.create({ 258 header: { 259 height: rpx(88), 260 flexDirection: 'row', 261 alignItems: 'center', 262 width: rpx(750), 263 paddingHorizontal: rpx(24), 264 }, 265 headerPath: { 266 marginLeft: rpx(28), 267 }, 268 wrapper: { 269 width: rpx(750), 270 flex: 1, 271 }, 272 scanBtn: { 273 width: rpx(750), 274 height: rpx(120), 275 alignItems: 'center', 276 justifyContent: 'center', 277 }, 278}); 279