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