SwiftUI递归文件树视图更新问题:删除文件后界面不刷新求助
SwiftUI文件树:解决删除后视图不更新、展开状态丢失及外部变更检测问题
问题核心
- 删除文件后视图未同步更新:仅修改文件系统,未联动更新
rootFiles数据源,子视图无法通知父视图刷新 - 重新加载重置展开状态:
isExpanded是FileView局部状态,重新创建视图时状态全部丢失 - 外部文件变更无感知:未监听文件系统变化,无法自动触发界面更新
解决方案及代码实现
关键优化点
- 展开状态全局存储:用文件URL作为唯一标识,在父视图中维护所有文件夹的展开状态
- 局部数据刷新:删除文件时仅移除数据源中对应节点,无需全量重新加载目录
- 文件系统监听:通过
FileMonitor(macOS 13+)或NSMetadataQuery监听目录变化,自动触发更新
修改后的完整代码:
import SwiftUI import Foundation struct FileModel: Identifiable, Equatable { let id = UUID() var parentDirectory: URL? let fileURL: URL var subFiles: [FileModel]? static func == (lhs: FileModel, rhs: FileModel) -> Bool { lhs.fileURL == rhs.fileURL } var systemImage: String { if let subFiles { if fileURL.pathExtension == "app" { return "app.badge.fill" } return subFiles.isEmpty ? "folder" : "folder.fill" } else { switch fileURL.pathExtension { case "app": return "app.fill" case "png", "jpg", "tiff": return "photo.fill" case "pdf": return "doc.richtext" case "csv", "json": return "list.bullet.indent" case "bom": return "doc.richtext" case "stl", "obj", "usdz": return "cube.transparent.fill" case "mp3", "wav": return "waveform" default: return "doc.fill" } } } } class FileOperations { static let shared = FileOperations() func deleteFile(url: URL) throws { try FileManager.default.removeItem(at: url) } func loadDirectory(url: URL) async -> [FileModel] { var fileModels = [FileModel]() do { let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) await withTaskGroup(of: FileModel?.self) { group in for content in contents { group.addTask { var isDirectory: ObjCBool = false FileManager.default.fileExists(atPath: content.path, isDirectory: &isDirectory) if isDirectory.boolValue { let subFiles = await self.loadDirectory(url: content) return FileModel(parentDirectory: url, fileURL: content, subFiles: subFiles) } else { return FileModel(parentDirectory: url, fileURL: content, subFiles: nil) } } } for await fileModel in group { if let fileModel = fileModel { fileModels.append(fileModel) } } } } catch { print("Error loading directory: \(error)") } return fileModels } func removeFile(from files: inout [FileModel], url: URL) -> Bool { for (index, file) in files.enumerated() { if file.fileURL == url { files.remove(at: index) return true } if var subFiles = file.subFiles { if removeFile(from: &subFiles, url: url) { files[index].subFiles = subFiles return true } } } return false } } class DirectoryMonitor: ObservableObject { private var monitors = [URL: FileMonitor]() @Published var directoryChanged = false func startMonitoring(url: URL) { if monitors[url] != nil { stopMonitoring(url: url) } do { let monitor = try FileMonitor(url: url) monitor.handler = { [weak self] event in if event.contains(.contentChanged) || event.contains(.removed) || event.contains(.added) { DispatchQueue.main.async { self?.directoryChanged = true } } } try monitor.start() monitors[url] = monitor Task { let subDirs = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) .filter { url in var isDir: ObjCBool = false FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) return isDir.boolValue } for subDir in subDirs { startMonitoring(url: subDir) } } } catch { print("Failed to start monitor for \(url): \(error)") } } func stopMonitoring(url: URL) { monitors[url]?.stop() monitors.removeValue(forKey: url) } func stopAll() { monitors.values.forEach { $0.stop() } monitors.removeAll() } } struct FileView: View { let file: FileModel @Binding var expandedStates: [URL: Bool] var onDelete: (URL) -> Void private var isExpanded: Binding<Bool> { Binding( get: { expandedStates[file.fileURL, default: false] }, set: { expandedStates[file.fileURL] = $0 } ) } var body: some View { Group { if let subFiles = file.subFiles { DisclosureGroup(isExpanded: isExpanded) { ForEach(subFiles) { subFile in FileView(file: subFile, expandedStates: $expandedStates, onDelete: onDelete) } } label: { Label(file.fileURL.lastPathComponent, systemImage: file.systemImage) } } else { HStack { Image(systemName: file.systemImage) Text(file.fileURL.lastPathComponent) } } } .tag(file.fileURL) .contextMenu { Button("Show in finder") { NSWorkspace.shared.activateFileViewerSelecting([file.fileURL]) } Button("Open with external editor") { NSWorkspace.shared.open(file.fileURL) } Button("Delete") { do { try FileOperations.shared.deleteFile(url: file.fileURL) onDelete(file.fileURL) } catch { print(error) } } } } } struct ContentView: View { @State private var rootFiles: [FileModel] = [] @State private var selection: Set<URL>? @State private var showPicker = false @State private var expandedStates: [URL: Bool] = [:] @StateObject private var directoryMonitor = DirectoryMonitor() @State private var currentRootURL: URL? var body: some View { NavigationSplitView { List(selection: $selection) { ForEach(rootFiles) { file in FileView(file: file, expandedStates: $expandedStates, onDelete: handleFileDelete) } } .toolbar { ToolbarItem { Button("Load Directory") { showPicker.toggle() } } } } detail: { } .fileImporter(isPresented: $showPicker, allowedContentTypes: [.folder], onCompletion: { result in do { let url = try result.get() currentRootURL = url directoryMonitor.stopAll() directoryMonitor.startMonitoring(url: url) Task { rootFiles = await FileOperations.shared.loadDirectory(url: url) } } catch { print(error) } }) .onChange(of: directoryMonitor.directoryChanged) { oldValue, newValue in guard newValue, let rootURL = currentRootURL else { return } Task { let updatedFiles = await FileOperations.shared.loadDirectory(url: rootURL) DispatchQueue.main.async { rootFiles = updatedFiles } } directoryMonitor.directoryChanged = false } } private func handleFileDelete(url: URL) { FileOperations.shared.removeFile(from: &rootFiles, url: url) } } @available(macOS 13.0, *) class FileMonitor { private let url: URL private let fileDescriptor: CInt private let queue = DispatchQueue(label: "com.example.FileMonitor") private var source: DispatchSourceRead? var handler: ((DispatchSource.FileSystemEvent) -> Void)? init(url: URL) throws { self.url = url self.fileDescriptor = open(url.path, O_EVTONLY) guard fileDescriptor != -1 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: nil) } } func start() throws { let source = DispatchSource.makeReadSource(fileDescriptor: fileDescriptor, queue: queue) source.setEventHandler { [weak self] in guard let self = self else { return } let event = DispatchSource.FileSystemEvent(rawValue: source.data) self.handler?(event) } source.setCancelHandler { [weak self] in guard let self = self else { return } close(self.fileDescriptor) } source.resume() self.source = source } func stop() { source?.cancel() source = nil } deinit { stop() } }
核心修改说明
- 展开状态持久化:在
ContentView中用[URL: Bool]字典存储所有文件夹的展开状态,通过Binding传递给子视图,重新加载时不会丢失状态 - 局部数据更新:删除文件时递归查找并移除数据源中的对应节点,仅触发局部视图刷新
- 文件系统监听:
DirectoryMonitor封装FileMonitor监听目录及子目录变化,变更时自动重新加载目录并保留展开状态 - 数据模型优化:添加
Equatable协议实现,方便查找和删除操作
旧版macOS兼容方案
若需支持macOS 13以下版本,可替换FileMonitor为NSMetadataQuery:
class DirectoryMonitor: ObservableObject { private let query = NSMetadataQuery() @Published var directoryChanged = false func startMonitoring(url: URL) { query.predicate = NSMetadataPredicate(forItemsInDirectory: url) query.notificationBatchingInterval = 0.5 NotificationCenter.default.addObserver(self, selector: #selector(queryDidFinishGathering(_:)), name: .NSMetadataQueryDidFinishGathering, object: query) NotificationCenter.default.addObserver(self, selector: #selector(queryDidUpdate(_:)), name: .NSMetadataQueryDidUpdate, object: query) query.start() } func stopMonitoring() { query.stop() NotificationCenter.default.removeObserver(self, name: .NSMetadataQueryDidFinishGathering, object: query) NotificationCenter.default.removeObserver(self, name: .NSMetadataQueryDidUpdate, object: query) } @objc private func queryDidFinishGathering(_ notification: Notification) { directoryChanged = true } @objc private func queryDidUpdate(_ notification: Notification) { directoryChanged = true } }
内容的提问来源于stack exchange,提问作者Karl Ehrlich
相关产品推荐
相关产品推荐

