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