功能需求
在 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 中哪些文件对外可见呢?主要是以下几种:
- iCloud 云中的文件;
- 设备中其它 App 中可供访问的文件(比如:在 Documents 目录中,并允许外部发现的文件);
- 设备中其它文件资源 App 可对外访问的文件,比如 百度网盘,钉钉 中的文件;
- 共享的文件(比如多 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 件事:
- 确定文件的 UTType;
- 实现 init(configuration: ReadConfiguration) 构造器去完成文件的读取操作;
- 实现 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开发者,希望我的文章可以解决你的痛点、难点问题。
如果还有问题欢迎在下面一起讨论吧 😉
感谢观赏,再会。