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

news/2024/11/24 19:02:49/

在这里插入图片描述

功能需求

在 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 UniformTypeIdentifiersextension 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, grayvar drawColor: Color {switch self {case .red:return .redcase .yellow:return .yellowcase .green:return .greencase .blue:return .bluecase .gray:return .gray}}}enum ShapeType: Codable, CaseIterable {case rect, circle, capsulevar name: String {switch self {case .rect:return "矩形"case .circle:return "圆形"case .capsule:return "胶囊"}}}var id = UUID()var sid: String { id.uuidString }var fileName: Stringvar url: URL?var title = "Untitled"var type = ShapeType.rectvar color = ShapeColor.redinit(fileName: String, title: String = "", type: ShapeType = .rect, color: ShapeColor = .red) {self.fileName = fileNameself.title = titleself.type = typeself.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: Stringvar text = ""init(fileName: String, text: String) {self.fileName = fileNameself.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 关键字以及主关联类型知识的小伙伴们,请猛戳以下链接观赏:

  • 深入浅出 Swift 中的 some、any 关键字以及主关联类型(primary associated types)

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

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


struct ContentView: View {@State private var files = someFilesvar body: some View {List {// 如果我们希望在 FileCell 中修改 file 的内容,则需要使用 files 集合绑定:/* ForEach($files) { $file inFileCell($file)}}*/ForEach(files){ file inFileCell(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 = falseprivate 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 = falseprivate func isFileExist(_ url: URL) -> Bool {files.contains { $0.url == url }}var body: some View {List {ForEach(files){ file inFileCell(file)}}.fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .text, .shapeFile]){ result inswitch 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) {// 需要设置一个不同的 idshapeFile.id = UUID()shapeFile.url = urllet tmp = shapeFileawait MainActor.run {files.append(tmp)}} else {let text = String(data: data, encoding: .utf8) ?? ""var textFile = TextFile(fileName: url.lastPathComponent, text: text)textFile.url = urllet tmp = textFileawait 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) 方法去完成文件的保存操作;

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

  • Swift 如何闪电般异步读取大文件?

以下是 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 = fileNamereturn 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 = fileNametext = 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 = fileNamereturn 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 inFileCell(file)}}.fileExporter(isPresented: $exportingTextProxy.execute, document: exportingTextProxy.file, contentType: .plainText) { result inswitch 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 inswitch 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 inFileCell(file)}}.fileMover(isPresented: $moving, file: movingFileURL) { result inswitch result {case .success(let dstURL):let idx = files.firstIndex { $0.url == movingFileURL }!files[idx].url = dstURLmsg = "文件 \(movingFileURL.lastPathComponent) 已移动到 \(dstURL)!"case .failure(let error):msg = error.localizedDescription}}}
}

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

在这里插入图片描述

4.5 文件删除

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

答案是:你想多了… 😃

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

private let fm = FileManager.defaultList {ForEach($files, id: \.sid) { $file inNavigationLink(destination: {FileDetailsView(file: $file)}){FileCell(file: file).swipeActions {if let url = file.url {Button("移动", action: {movingFileURL = urlmoving = true}).tint(.blue)}Button("导出", action: {if let textFile = file as? TextFile {exportingTextProxy.file = textFileexportingTextProxy.execute = true}else if let shapeFile = file as? ShapeFile {exportingShapeProxy.file = shapeFileexportingShapeProxy.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 inNavigationLink(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 inNavigationLink(destination: {FileDetailsView(file: $file)}){FileCell(file: file)}}}.fileImporter(...){ result in...}}.navigationTitle("文件管理器")
}

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

尾声

源代码哪里寻?

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

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

总结

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

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


结束语

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

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

感谢观赏,再会。


http://www.ppmy.cn/news/85468.html

相关文章

【概念篇】浅谈 AOP、OOP、DDD、IOC

前言 在招聘要求中&#xff0c;有没有经常看见&#xff0c;对AOP、OOP、DDD、IOC有一定的认识&#xff0c;能够自主开发模块&#xff0c;这一类的要求。听起来是不是挺高大上&#xff0c;然后百度一搜&#xff0c;给你出来一堆概念性的东西——结果就是&#xff0c;东西…

(三) PID控制中的噪声过滤

在前面的章节里&#xff0c;我们谈到了积分环节由于执行器的物理限制导致的积分项Wind-up&#xff0c;通过设置饱和限幅器&#xff0c;选择性关闭积分环节来完成Wind-up问题。 今天我们要解决的问题就是微分环节可能存在的问题。通过传感器我们可以观测出一个被控量的大小&…

KAFKA:如何做到1秒发布百万级条消息

即使顺序读写&#xff0c;过于频繁的大量小I/O操作一样会造成磁盘的瓶颈&#xff0c;所以KAFKA在此处的处理是把这些消息集合在一起批量发送&#xff0c;这样减少对磁盘IO的过度读写&#xff0c;而不是一次发送 单个消息。 另一个是无效率的字节复制&#xff0c;尤其是在负…

FreeRTOS(7)----事件组

1.事件位 当收到一条消息并且把这条消息处理掉以后就可以将某个位&#xff08;标志&#xff09;置1&#xff0c;当队列中没有消息需要处理的时候就可以将这个位&#xff08;标志&#xff09;置0 当把队列中的消息通过网络发送输出以后就可以将某个位&#xff08;标志&#xf…

Lesson13---人工神经网络(2)

13 人工神经网络&#xff08;2&#xff09; 多层神经网络-非线性分类问题多层神经网络的损失函数不是凸函数&#xff0c;很难计算解析解通常采用梯度下降法&#xff0c;得到数据解&#xff0c;梯度下降法可以用来求解函数极值问题 批量梯度下降随机梯度下降小批量梯度下降 1…

每日一题——四数之和(双指针解法)

每日一题 四数之和 注&#xff1a; 如果大家没做过题目两数之和、三数之和&#xff0c;强烈建议先去做做&#xff0c;也可以参考我之前写的博客&#xff0c;这样做这一题会事半功倍&#xff0c;且由于本题思路和三数之和十分类似&#xff0c;故对于解题思路&#xff0c;也不会…

关于Sql 中 on和where的粗略理解

先看到题&#xff1a;统计复旦用户8月练题情况 牛客中的 原地址 通过这个题我们来探讨where和on 描述 题目&#xff1a; 现在运营想要了解复旦大学的每个用户在8月份练习的总题目数和回答正确的题目数情况&#xff0c;请取出相应明细数据&#xff0c;对于在8月份没有练习过的…

control_file_record_keep_time

control_file_record_keep_time 是一个参数&#xff0c;用于指定控制文件记录的保留时间。您可以通过以下步骤查看该参数的值&#xff1a; 登录到您的数据库服务器。 打开 SQL*Plus 或 SQL Developer 或任何其他 Oracle 数据库客户端工具。 连接到您的数据库实例。 运行以下…