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