You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI递归文件树视图更新问题:删除文件后界面不刷新求助

SwiftUI文件树:解决删除后视图不更新、展开状态丢失及外部变更检测问题

问题核心

  • 删除文件后视图未同步更新:仅修改文件系统,未联动更新rootFiles数据源,子视图无法通知父视图刷新
  • 重新加载重置展开状态:isExpanded是FileView局部状态,重新创建视图时状态全部丢失
  • 外部文件变更无感知:未监听文件系统变化,无法自动触发界面更新

解决方案及代码实现

关键优化点

  1. 展开状态全局存储:用文件URL作为唯一标识,在父视图中维护所有文件夹的展开状态
  2. 局部数据刷新:删除文件时仅移除数据源中对应节点,无需全量重新加载目录
  3. 文件系统监听:通过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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 05:54:59