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