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 exists, 7 ExternalStorageDirectoryPath, 8 getAllExternalFilesDirs, 9 readDir, 10} from 'react-native-fs'; 11import {FlatList} from 'react-native-gesture-handler'; 12import useColors from '@/hooks/useColors'; 13import IconButton from '@/components/base/iconButton'; 14import FileItem from './fileItem'; 15import Empty from '@/components/base/empty'; 16import useHardwareBack from '@/hooks/useHardwareBack'; 17import {useNavigation} from '@react-navigation/native'; 18import Loading from '@/components/base/loading'; 19import {useParams} from '@/core/router'; 20import StatusBar from '@/components/base/statusBar'; 21import VerticalSafeAreaView from '@/components/base/verticalSafeAreaView'; 22import globalStyle from '@/constants/globalStyle'; 23import Button from '@/components/base/textButton.tsx'; 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( 153 (item: IFileItem | IFileItem[], nextChecked: boolean) => { 154 if (multi) { 155 if (!Array.isArray(item)) { 156 item = [item]; 157 } 158 setCheckedItems(prev => { 159 const itemPaths = (item as IFileItem[]).map(_ => _.path); 160 const newCheckedItem = prev.filter( 161 _ => !itemPaths.includes(_.path), 162 ); 163 if (nextChecked) { 164 return [...newCheckedItem, ...(item as IFileItem[])]; 165 } else { 166 return newCheckedItem; 167 } 168 }); 169 } else { 170 setCheckedItems( 171 nextChecked ? (Array.isArray(item) ? item : [item]) : [], 172 ); 173 } 174 }, 175 [], 176 ); 177 178 const renderItem = ({item}: {item: IFileItem}) => ( 179 <FileItem 180 path={item.path} 181 type={item.type} 182 parentPath={currentPath.path} 183 onItemPress={currentChecked => { 184 if (item.type === 'folder') { 185 setCurrentPath(prev => ({ 186 parent: prev, 187 path: item.path, 188 })); 189 } else { 190 selectPath(item, !currentChecked); 191 } 192 }} 193 checked={checkedPaths.includes(item.path)} 194 onCheckedChange={checked => { 195 selectPath(item, checked); 196 }} 197 /> 198 ); 199 200 const currentPageAllChecked = useMemo(() => { 201 return ( 202 filesData.length && 203 filesData.every(file => checkedPaths.includes(file.path)) 204 ); 205 }, [filesData, checkedPaths]); 206 207 const renderHeader = () => { 208 return multi ? ( 209 <View style={style.selectAll}> 210 <Button 211 onPress={() => { 212 if (currentPageAllChecked) { 213 selectPath(filesData, false); 214 } else { 215 selectPath(filesData, true); 216 } 217 }}> 218 {currentPageAllChecked ? '全不选' : '全选'} 219 </Button> 220 </View> 221 ) : null; 222 }; 223 224 return ( 225 <VerticalSafeAreaView style={globalStyle.fwflex1}> 226 <StatusBar /> 227 <View style={[style.header, {backgroundColor: colors.appBar}]}> 228 <IconButton 229 sizeType="small" 230 name="arrow-long-left" 231 color={colors.appBarText} 232 onPress={() => { 233 // 返回上一级 234 if (currentPath.parent !== null) { 235 setCurrentPath(currentPath.parent); 236 } 237 }} 238 /> 239 <ThemeText 240 numberOfLines={2} 241 ellipsizeMode="head" 242 fontColor={'appBarText'} 243 style={style.headerPath}> 244 {currentPath.path} 245 </ThemeText> 246 </View> 247 {loading ? ( 248 <Loading /> 249 ) : ( 250 <> 251 <FlatList 252 ListHeaderComponent={renderHeader} 253 ListEmptyComponent={Empty} 254 style={globalStyle.fwflex1} 255 data={filesData} 256 getItemLayout={(_, index) => ({ 257 length: ITEM_HEIGHT, 258 offset: ITEM_HEIGHT * index, 259 index, 260 })} 261 renderItem={renderItem} 262 /> 263 </> 264 )} 265 <Pressable 266 onPress={async () => { 267 if (checkedItems.length) { 268 const shouldBack = await onAction?.(checkedItems); 269 if (shouldBack) { 270 navigation.goBack(); 271 } 272 } 273 }}> 274 <View 275 style={[ 276 style.scanBtn, 277 { 278 backgroundColor: colors.appBar, 279 }, 280 ]}> 281 <ThemeText 282 fontColor={'appBarText'} 283 opacity={checkedItems.length > 0 ? undefined : 0.6}> 284 {actionText} 285 {multi && checkedItems?.length > 0 286 ? ` (选中${checkedItems.length})` 287 : ''} 288 </ThemeText> 289 </View> 290 </Pressable> 291 </VerticalSafeAreaView> 292 ); 293} 294 295const style = StyleSheet.create({ 296 header: { 297 height: rpx(88), 298 flexDirection: 'row', 299 alignItems: 'center', 300 width: '100%', 301 paddingHorizontal: rpx(24), 302 }, 303 headerPath: { 304 marginLeft: rpx(28), 305 }, 306 scanBtn: { 307 width: '100%', 308 height: rpx(120), 309 alignItems: 'center', 310 justifyContent: 'center', 311 }, 312 selectAll: { 313 width: '100%', 314 height: ITEM_HEIGHT, 315 paddingHorizontal: rpx(24), 316 flexDirection: 'row', 317 alignItems: 'center', 318 }, 319}); 320