您现在的位置是:首页 >技术教程 >SwiftUI 实现一个 iOS 上 Files App 兼容的文件资源管理器网站首页技术教程

SwiftUI 实现一个 iOS 上 Files App 兼容的文件资源管理器

大熊猫侯佩 2024-06-26 14:23:25
简介SwiftUI 实现一个 iOS 上 Files App 兼容的文件资源管理器

在这里插入图片描述

功能需求

在 SwiftUI 中自己白手起家写一个 iOS(或iPadOS)上迷你的文件资源管理器是有些难度滴,不过从 iOS 11 (2017年) 官方引入自家的 Files App 之后,我们就可以借助它的魔力轻松完成这一个功能了。

在这里插入图片描述

如上所示,我们使用 SwiftUI 原生功能完成了一个小巧的 iOS Files App 文件管理器,实现了 iOS 中 Files App 中文件的导入、导出、移动和删除等功能。

在本篇博文中,您将学到如下内容:

  • 如何在 App 中注册自定义文件类型?
  • 如何在 SwiftUI 中的 ForEach 循环中遍历异构数据集合([any])?
  • 如何在 SwiftUI 中导入、导出、移动以及删除文件?

请小伙伴们在飞行舱中稍事休息,本次航程将精彩纷呈!

Let‘s go!!!?


功能分析

1. 注册自定文件类型

如果我们 App 需要处理自定义文件,则需要在 Xcode 项目中注册该文件类型。

比如,我们希望实现一种自定义的形状(Shape)文件类型,供 App 导入导出:

在这里插入图片描述

首先,我们需要定义该文件的 UTType:

import UniformTypeIdentifiers

extension UTType {
    static let shapeFile = UTType(importedAs: "com.hopy.Shapes")
}

接着,在 Xcode 中选中 App 项目的 info 选项卡,并展开底部的 Imported Type Identifiers 子项,并填入对应的文件类型信息:

在这里插入图片描述

其中有几点需要注意:

  • Identifier 是我们之前创建的 UTType 类型:com.hopy.Shapes
  • Conforms to 填入的是 public.data 类型。因为我们希望该文件以 Data 的方式被读写,你也可以使用其它通用类型。

为了满足后面组成异构文件集合的需求,我们需要创建 IdentifiableFile 协议,以支持多个异构文件类型:

protocol IdentifiableFile: Identifiable, FileDocument {
    // 影子ID(shadow ID),后面会介绍其用途
    var sid: String { get }
    var url: URL? { get set }
    var fileName: String { get set }
}

最后,我们可以实现 Shape 文件结构的主体了:

struct ShapeFile: IdentifiableFile, Codable {
    
    enum ShapeColor: String, Codable, CaseIterable {
        case red, blue, green, yellow, gray
        
        var drawColor: Color {
            switch self {
            case .red:
                return .red
            case .yellow:
                return .yellow
            case .green:
                return .green
            case .blue:
                return .blue
            case .gray:
                return .gray
            }
        }
    }
    
    enum ShapeType: Codable, CaseIterable {
        case rect, circle, capsule
        
        var name: String {
            switch self {
            case .rect:
                return "矩形"
            case .circle:
                return "圆形"
            case .capsule:
                return "胶囊"
            }
        }
    }
    
    var id = UUID()
    var sid: String { id.uuidString }
    var fileName: String
    var url: URL?
    var title = "Untitled"
    var type = ShapeType.rect
    var color = ShapeColor.red
    
    init(fileName: String, title: String = "", type: ShapeType = .rect, color: ShapeColor = .red) {
        self.fileName = fileName
        self.title = title
        self.type = type
        self.color = color
    }
    
    @ViewBuilder static func draw(type: ShapeType, color: Color) -> some View {
        switch type {
        case .rect:
            Rectangle()
                .foregroundStyle(color.gradient)
        case .circle:
            Circle()
                .foregroundStyle(color.gradient)
        case .capsule:
            Capsule()
                .foregroundStyle(color.gradient)
        }
    }
}

extension ShapeFile: FileDocument {
	// 待实现
}

为了支持 SwiftUI 中的文件操作,我们需要自定义文件类型遵守 FileDocument 协议,相关实现将在后面详述。

2. 创建异构文件集合类型

除了自定义 Shape 文件类型以外,我们还想支持普通的文本(Text)文件类型,于是需要再创建一个类似的 TextFile 文件结构:

struct TextFile: IdentifiableFile, Equatable {
    var id = UUID()
    var sid: String { id.uuidString }
    
    var url: URL?
    var fileName: String
    var text = ""
    
    init(fileName: String, text: String) {
        self.fileName = fileName
        self.text = text
    }
    
    static func ==(lhs: TextFile, rhs: TextFile) -> Bool {
        lhs.url == rhs.url
    }
    
    static var stub: Self {
        TextFile(fileName: "无名文件", text: "Empty File")
    }
}

extension TextFile: FileDocument {
	// 待实现
}

此时,有了两种不同的文件类型,我们可以将它们放在异构集合中以便统一操作:

let someFiles: [any IdentifiableFile] = [
    TextFile(fileName: "txt", text: "hello world!"),
    ShapeFile(fileName: "shape"),
]

如上,我们使用异构集合来存放不同种类的文件,注意集合的类型是 [any IdentifiableFile] 。


想了解更多 Swift 5.5 后新引入的 some,any 关键字以及主关联类型知识的小伙伴们,请猛戳以下链接观赏:


3. 在 SwiftUI 的 ForEach 中遍历异构集合并显示

我们可能会这样在 SwiftUI 中遍历上面的异构文件集合,试图逐一在 List 中显示它们:


struct ContentView: View {
    
    @State private var files = someFiles

    var body: some View {
        List {
            // 如果我们希望在 FileCell 中修改 file 的内容,则需要使用 files 集合绑定:
            /* ForEach($files) { $file in
                    FileCell($file)
                }
            }*/
            ForEach(files){ file in
                FileCell(file: file)
            }
        }
    }
}

不幸的是,这样做无法通过编译:

在这里插入图片描述

通过前面代码可以确认,我们的文件类型绝对是遵守 Identifiable 协议的,但异构 any IdentifiableFile 类型却“不吃这一套”,编译器会认为 any Identifiable 不遵守 Identifiable。

所幸的是,我们可以手动让 ForEach 明白 Identifiable 的“真谛”:

在这里插入图片描述

上面的 sid 是之前实现的“影子id”属性,我们利用它来满足 ForEach 对 Identifiable 的渴望,它的类型必须为结构(Struct)。


注意:这里我们不能用前面 Identifiable 协议中定义的 id 属性,因为这违反了 id 的 Self 类型必须是类(Class)这一条件!
在这里插入图片描述


现在,我们可以用 ForEach 遍历 [any IdentifiableFile] 集合了,但如何处理传入 FileCell 中的 file (其类型为 any IdentifiableFile)对象呢?

很简单!我们可以在操作 file 潜在的真实对象之前,先对其解包(Unwrap),把 any IdentifiableFile 转换为实际的文件对象类型后再访问它:

struct FileCell: View {
    let file: any IdentifiableFile
    @State private var urlExpanding = false
    
    private var isShapeFile: Bool {
        if let _ = file as? ShapeFile {
            return true
        }
        return false
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            HStack {
                
                Image(systemName: isShapeFile ? "hexagon" : "doc.text")
                    .foregroundStyle(.blue.gradient)
                
                Text(file.fileName)
                    .font(.title2.bold())
                
                Spacer()
                
                if let shapeFile = file as? ShapeFile, !shapeFile.title.isEmpty {
                    Text("#(shapeFile.title)#")
                        .font(.subheadline)
                        .foregroundStyle(.gray.gradient)
                }
            }
            
            Button(action: {
                urlExpanding.toggle()
            }){
                Text(file.url?.absoluteString ?? "<还未导出>")
                    .font(.headline)
                    .lineLimit(urlExpanding ? nil : 2)
                    .multilineTextAlignment(.leading)
            }
            .buttonStyle(.borderless)
            .tint(.gray.opacity(0.88))
        }
    }
}

4. 导入、导出、移动以及删除文件

现在 SwiftUI 中的 ForEach 已经可以遍历和显示异构文件集合的对象了,接下来就让我们来逐一实现文件的导入、导出、移动以及删除操作吧。

4.1 文件导入

从 SwiftUI 2.0 开始,Apple 引入了新的 fileImporter() 修改器方法,专门用来将 Files App 中的文件导入到我们自己的 App 中去。

那么,Files App 中哪些文件对外可见呢?主要是以下几种:

  1. iCloud 云中的文件;
  2. 设备中其它 App 中可供访问的文件(比如:在 Documents 目录中,并允许外部发现的文件);
  3. 设备中其它文件资源 App 可对外访问的文件,比如 百度网盘,钉钉 中的文件;
  4. 共享的文件(比如多 iCloud 用户间共享的文件,或 共享服务器 中的文件)

我们可以在同一个 fileImporter() 方法中选择导入多种不同文件类型:

struct ContentView: View {
    
    @State private var files = [any IdentifiableFile]()
    @State private var importing = false

	private func isFileExist(_ url: URL) -> Bool {
        files.contains { $0.url == url }
    }

    var body: some View {
        List {
            ForEach(files){ file in
                FileCell(file)
            }
        }
        .fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .text, .shapeFile]){ result in
            switch result {
            case .success(let url):
                
                guard !isFileExist(url) else {
                    msg = "相同文件 (url.lastPathComponent) 已存在!!!"
                    return
                }
                
                Task.detached {
                    do {
                        let data = try await dataFromStream(url: url)
                        let decoder = JSONDecoder()
                        if var shapeFile = try? decoder.decode(ShapeFile.self, from: data) {
                            
                            // 需要设置一个不同的 id
                            shapeFile.id = UUID()
                            shapeFile.url = url
                            let tmp = shapeFile
                            
                            await MainActor.run {
                                files.append(tmp)
                            }
                        } else {
                            let text = String(data: data, encoding: .utf8) ?? ""
                            var textFile = TextFile(fileName: url.lastPathComponent, text: text)
                            textFile.url = url
                            let tmp = textFile
                            await MainActor.run {
                                files.append(tmp)
                            }
                        }
                    }catch{
                        await MainActor.run {
                            // 设置 msg 以便弹出 Alert 通知用户错误,实现从略...
                            msg = "ERR: (error.localizedDescription)"
                        }
                    }
                }
                
            case .failure(let error):
                print("ERR: (error)")
            }
        }
    }
}

在这里插入图片描述

在这里插入图片描述

如上,我们分别导入了 Text 和 Shape 两种不同文件。

4.2 FileDocument 协议

在实现文件导出之前,我们需要让自定义文件类型遵守 FileDocument 协议,其中要做 3 件事:

  1. 确定文件的 UTType;
  2. 实现 init(configuration: ReadConfiguration) 构造器去完成文件的读取操作;
  3. 实现 fileWrapper(configuration: WriteConfiguration) 方法去完成文件的保存操作;

同步读取大文件内容会造成界面的挂起,如果小伙伴们想了解大文件异步快速读取的知识,请移步如下链接观赏:


以下是 ShapeFile 结构遵守 FileDocument 协议的实现:

enum AppError: Error {
    case illegalFormat
}

extension ShapeFile: FileDocument {
    static var readableContentTypes: [UTType] {
        [.shapeFile]
    }
    
    init(configuration: ReadConfiguration) throws {
        if let data = configuration.file.regularFileContents {
            let tmp = try JSONDecoder().decode(Self.self, from: data)
            self = tmp
        }else{
            throw AppError.illegalFormat
        }
    }
    
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = try JSONEncoder().encode(self)
        let fw = FileWrapper(regularFileWithContents: data)
        fw.filename = fileName
        return fw
    }
}

可以看到,遵守 FileDocument 协议很容易,我们如法炮制完成 TextFile 的协议“契约”:

extension TextFile: FileDocument {
    static var readableContentTypes: [UTType] {
        [.plainText, .text]
    }
    
    init(configuration: ReadConfiguration) throws {
        if let data = configuration.file.regularFileContents {
            guard let fileName = configuration.file.filename else {
                throw AppError.illegalFormat
            }
            
            self.fileName = fileName
            text = String(decoding: data, as: UTF8.self)
        }else{
            throw AppError.illegalFormat
        }
    }
    
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = text.data(using: .utf8) ?? Data()
        let fw = FileWrapper(regularFileWithContents: data)
        fw.filename = fileName
        return fw
    }
}

注意:如果不希望导出到 Files App 中的文件是默认名称,我们必须在 fileWrapper(…) 方法中为 FileWrapper 对象正确设置其 filename 属性的值。

4.3 文件导出

现在,我们可以将遵守 FileDocument 协议的文件对象导出到 Files App 中去了。

同文件导入类似,我们可以使用 fileExporter() 修改器方法来完成文件的导出操作。不过 fileExporter() 方法仅支持单一种类文件类型的导出,对于我们的 App 来说,必须使用两个 fileExporter() 方法来分别支持 Text 和 Shape 文件的导出。

为了方便起见,我们定义了一个 FileProxy 结构来适配 fileExporter() 方法的调用:

struct FileProxy<File: IdentifiableFile> {
	// 是否执行文件导出(弹出文件导出窗口)
    var execute = false
    // 被导出的文件类型
    var file: File?
}

在下面的代码中,我们逐一实现了 TextFile 和 ShapeFile 文件的导出功能,并在文件成功导出后,在文件对象中存放其对应的保存位置(URL)以供后续使用:

struct ContentView: View {
    
    @State private var files = [any IdentifiableFile]()
    @State private var exportingTextProxy = FileProxy<TextFile>()
    @State private var exportingShapeProxy = FileProxy<ShapeFile>()


    var body: some View {
        List {
            ForEach(files){ file in
                FileCell(file)
            }
        }
        .fileExporter(isPresented: $exportingTextProxy.execute, document: exportingTextProxy.file, contentType: .plainText) { result in
            switch result {
            case .success(let url):
                guard let file = exportingTextProxy.file else {return}
                
                if file.url == nil {
                    let idx = files.firstIndex { $0.sid == file.sid }!
                    files[idx].url = url
                }
                
                msg = "文件 (file.fileName) 导出成功 (->(url.absoluteString))!"
            case .failure(let error):
                msg = error.localizedDescription
            }
        }
        .fileExporter(isPresented: $exportingShapeProxy.execute, document: exportingShapeProxy.file, contentType: .shapeFile) { result in
            switch result {
            case .success(let url):
                guard let file = exportingShapeProxy.file else {return}
                
                if file.url == nil {
                    let idx = files.firstIndex { $0.sid == file.sid }!
                    files[idx].url = url
                }
                
                msg = "文件 (file.fileName) 导出成功 (->(url.absoluteString))!"
            case .failure(let error):
                msg = error.localizedDescription
            }
        }
    }
}

在这里插入图片描述

4.4 文件移动

相对文件导出而言,文件移动就简单很多了。

为了移动(Files App里)指定路径中的文件,我们只需要使用文件保存位置的 URL 地址即可:

struct ContentView: View {
    
    @State private var files = [any IdentifiableFile]()
    @State private var moving = false
    @State private var movingFileURL = URL(filePath: "")


    var body: some View {
        List {
            ForEach(files){ file in
                FileCell(file)
            }
        }
        .fileMover(isPresented: $moving, file: movingFileURL) { result in
            switch result {
            case .success(let dstURL):
                let idx = files.firstIndex { $0.url == movingFileURL }!
                files[idx].url = dstURL
                msg = "文件 (movingFileURL.lastPathComponent) 已移动到 (dstURL)!"
            case .failure(let error):
                msg = error.localizedDescription
            }
        }
    }
}

注意在以上代码中,我们同样在文件成功移动后更新了它原有 url 属性值为新的路径。

在这里插入图片描述

4.5 文件删除

你可能会猜测,文件删除也有一个类似 fileDeleter() 的修改器方法…

答案是:你想多了… ?

对于文件删除操作,只需用我们的老朋友 FileManager 中的 removeItem 方法即可:

private let fm = FileManager.default

List {
    ForEach($files, id: .sid) { $file in
        NavigationLink(destination: {
            FileDetailsView(file: $file)
        }){
            FileCell(file: file)
                .swipeActions {
                    if let url = file.url {
                        Button("移动", action: {
                            movingFileURL = url
                            moving = true
                        })
                        .tint(.blue)
                    }
                    
                    Button("导出", action: {
                        if let textFile = file as? TextFile {
                            exportingTextProxy.file = textFile
                            exportingTextProxy.execute = true
                            
                        }else if let shapeFile = file as? ShapeFile {
                            exportingShapeProxy.file = shapeFile
                            exportingShapeProxy.execute = true
                        }
                        
                        
                    })
                    .tint(.orange)
                    
                    Button("删除", role: .destructive){
                        do {
                            if let url = file.url {
                                try fm.removeItem(at: url)
                            }
                            
                            files.removeAll {$0.sid == file.sid}
                        }catch{
                            msg = "删除文件失败: (error.localizedDescription)"
                        }
                    }
                }
        }
    }
}

注意在以上代码中,我们顺面补全了前面文件导出和移动操作中缺失的代码片段。

在这里插入图片描述


其实,我们也可以直接在 Files App 弹出的文件操作窗口中完成文件的删除、重命名、共享等操作:

在这里插入图片描述


调用 FileManager#removeItem() 方法后,Files App 里存储路径对应的文件立即“灰飞烟灭”,童叟无欺!

5. 如何解决目前 SwiftUI 文件操作的一些小怪癖?

在以上代码中,我们分别在 SwiftUI 中实现了文件的导入、导出和移动等操作。

这看似很好很和谐,不过如果在同一个 View 中串行调用上述这些文件操作对应的修改器方法时,则会让秃头码农们“欲哭无泪”:总有些文件操作窗口无法弹出,具体哪些窗口弹出失灵则和这些修改器的顺序有关:

List {
    ForEach($files, id: .sid) { $file in
        NavigationLink(destination: {
            FileDetailsView(file: $file)
        }){
            FileCell(file: file)
                .swipeActions {...}
        }
    }
}
.fileMover(...) {...}
.fileExporter(...) {...}    // TextFile 导出
.fileExporter(...) {...}    // ShapeFile 导出
.fileImporter(...) {...}

如上代码所示,无论我们怎么调整这些文件操作修改器的相对顺序,总有些文件操作窗口无法被弹出。

为什么会这样呢?

答案是:这应该是 SwiftUI 中的一个“怪癖”!说明 SwiftUI 文件操作功能未经严格测试就拿来给我等“小白鼠”使用,这也是 SwiftUI 目前还不能完全实现商业软件开发的佐证吧!

虽然不能在同一个 View 上串行调用这些文件操作修改器,我们也不是完全没有办法,一种解决方法是将不同的文件操作修改器方法放在不同的视图(View)上:

NavigationStack {
    ZStack {
        Text("")
            .frame(size: .zero)
            .hidden()
            // .fileMover 不能和其它 fileXXX 修改器方法放在一起,否则依照它们之间的排放顺序,总会有几个修改器方法无法生效。
            .fileMover(...) { result in
                ...
            }
            
        
        Text("")
            .frame(size: .zero)
            .hidden()
            .fileExporter(...) { result in
                ...
            }
            
        Text("")
            .frame(size: .zero)
            .hidden()
            .fileExporter() { result in
                ...
            }

        List {
            ForEach($files, id: .sid) { $file in
                NavigationLink(destination: {
                    FileDetailsView(file: $file)
                }){
                    FileCell(file: file)
                }
            }
        }
        .fileImporter(...){ result in
            ...
        }
    }
    .navigationTitle("文件管理器")
}

至此,我们在自己的 App 中实现了以上全部的文件导入、导出、移动和删除功能,棒棒哒!!!??

尾声

源代码哪里寻?

因为全部源代码较多,这里不便贴出。

不过,如果您是我本系列博文专栏的订阅读者,可以私信我免费获取完整源代码。

总结

在本篇博文中,我们使用 SwiftUI 完成了一个 iOS(iPadOS类似)中的文件资源管理器,其中逐一实现了 Files App 里文件的导入、导出、移动和删除等操作。

那么,最后还得照例问一下小伙伴:你们学会了么??


结束语

Hi,我是大熊猫侯佩,一名非自由App开发者,希望我的文章可以解决你的痛点、难点问题。

如果还有问题欢迎在下面一起讨论吧 ?

感谢观赏,再会。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。