环境
- Xcode 11.3
- Swift 5.1
- YYBlog
需求
有时候在电脑上下载好了电影,但是想用手机看,而系统又没有自带这套操作的工具,
于是就干脆自己写一个吧,顺便练习下刚学的Swift UI。
说明一下,这篇文章主要是演示Swift UI,工程里面使用的播放控件是基于IJKMediaFramework封装好的一个ViewController,在Github上找的这个工程 Swift-IJKPlayer小改了下,懒得自己再写🙃,也练习了Swift UI中使用UIKit。
完整工程代码:YYVedioPlayer,github单个文件限制100M,就放到gitlab去了
先来看下最终效果:
分析
需求很简单,思路也很简单,只需要2个界面就ok:
- 一个播放器界面
- 一个列表页可以展示指定目录的所有文件和子目录
- 点击文件就将对应url传递给播放器界面
- 点击目录就push一个新的列表页并展示对应的内容
- 左滑删除功能
当我们打开App的时候,首先要展示的是Documents下面的内容,因为在Info.plist里面设置UIFileSharingEnabled = true后,电脑里的文件只能拷贝到App的这个目录。
下面就开始撸代码了。
Service层
首先,创建一个VedioManager,提供文件模型和相关的方法:
- load方法根据传入的路径,返回File数组(目录放在数组前面,普通文件在后面)
- delete方法删除指定的File,以及它包含的内容
里面用到了YYFile,这个其实是JohnSundell 写的库Files,很好用,我只是照着写了一遍,方便更好理解和使用。
另外他的博客全是Swift相关教程,很屌很炸天。
代码如下:
extension VedioManager {
struct File: Hashable {
let name: String
let path: String
let isFolder: Bool
}
}
class VedioManager {
static let dirDocument: URL = {
let urls = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
return urls[urls.endIndex - 1]
}()
static let root = dirDocument.path
static func load(at path: String) -> [File] {
if let folder = try? YYFile().createFolderIfNeeded(at: path) {
let folders = folder.subfolders.map {
File(name: $0.name, path: $0.path, isFolder: true)
}
let files = folder.files.map {
File(name: $0.name, path: $0.path, isFolder: false)
}
return folders + files
}
return []
}
static func delete(_ file: File) -> Bool {
do {
try FileManager.default.removeItem(atPath: file.path)
return true
} catch {
print(error)
}
return false
}
}
列表页
如上图,列表页很简单,主要就是展示[VedioManager.File]数组,让我们看看怎么用Swift UI方式构建。
首先,创建代表列表页的VedioList:
import SwiftUI
struct VedioList {}
为什么是个空结构体?
因为,这里我们会用到Introducing Container views in SwiftUI这篇文章里面提到的思想,引入容器视图和渲染视图,这样做的好处建议大家详细看文章,最后总结大概如下:
这位作者也是介绍Swift相关教程,风格简洁一些,同样很屌很炸天。
容器视图应该只做与数据流相关的事情:
- 存储视图的状态
- 处理生命周期(onAppear / onDisappear)
- 使用ObservableObject获取数据
- 为“渲染”视图提供操作处理程序
渲染视图应该只执行与渲染相关的事情:
- 使用SwiftUI提供的原始组件构建用户界面。
- 使用其他渲染视图构建用户界面。
- 使用数据作为输入来呈现用户界面,不存储任何状态。
Content View
先来看看渲染视图,主要工作如下:
- 根据传入的File数组创建cell
- 处理左滑删除的响应
按照上面的思想,应该是这样:
extension VedioList {
struct Content: View {
var files: [VedioManager.File]
var delete: (_ offsets: IndexSet) -> Void
var body: some View {
List {
ForEach(files, id: \.self) { file in
self.cell(for: file)
}
.onDelete(perform: delete)
}
}
private func cell(for file: VedioManager.File) -> AnyView {
file.isFolder ?
NavigationLink(file.name, destination: Container(path: file.path)).eraseToAnyView() :
NavigationLink(file.name, destination:VedioPlayer.Container(file: file)).eraseToAnyView()
}
}
}
可以看到,用Swift UI,代码量真的很少,很少,也比较简单。
需要注意的是,当我们点击一个cell的时候,会根据是否是目录,导航到不同界面,这里用到了AnyView。
不过根据文章How to return different view types,还可以使用Group。使用AnyView会降低性能,建议不要经常使用。
Container View
Container视图的主要工作就是
- onAppear时加载并更新指定目录的数据
- 处理删除逻辑
代码也很简单,而且当标记为@State的属性files发生变化时,Content View会自动更新,非常方便!
extension VedioList {
struct Container: View {
let path: String
@State
private var files: [VedioManager.File] = []
var body: some View {
Content(files: files, delete: delete)
.navigationBarTitle(path.lastPathComponent)
.onAppear(perform: loadData)
}
private func loadData() {
print(path)
let files = VedioManager.load(at: path)
DispatchQueue.main.async {
self.files = files
}
}
private func delete(at offsets: IndexSet) {
for idx in offsets {
let file = files[idx]
if VedioManager.delete(file) {
files.remove(at: idx)
}
}
}
}
}
这里有个坑,注意loadData方法里面的self.files = files这句代码,如果不放到DispatchQueue.main.async里,
当点击目录push到新的VedioLis.Containert时,会报table view Invalid update crash。。
播放器页
同样,先创建代表播放器页的VedioPlayer:
import SwiftUI
struct VedioPlayer {}
Content View
我们的播放器是PlayerViewController,要在SwiftUI中使用,只需要一个类或结构体,实现协议UIViewControllerRepresentable即可,核心方法:
- makeUIViewController,返回要展示的UIViewController对象,这里是我们的PlayerViewController(),设置好视频地址和标题即可,presentation.wrappedValue.dismiss()表示popViewController()
- updateUIViewController,是在状态发生改变时,SwiftUI通知UIKit的方式,我们播放器相关的所有逻辑都在PlayerViewController里面,所以是空的
代码如下:
extension VedioPlayer {
struct Content: UIViewControllerRepresentable {
@Environment(\.presentationMode) var presentation
let url: String
let title: String
func makeUIViewController(context: UIViewControllerRepresentableContext<Content>) -> PlayerViewController {
let vc = PlayerViewController()
vc.prepareFor(url: url, title: title)
vc.tapBackHandler = {
self.presentation.wrappedValue.dismiss()
}
return vc
}
func updateUIViewController(_ uiViewController: PlayerViewController, context: UIViewControllerRepresentableContext<Content>) {
}
}
}
Container View
Container视图的工作就是根据传入的VedioManager.File,配置Content view的视频地址和标题,注意这里也有个坑,SwiftUI中想让导航条隐藏,必须要设置标题。。
代码如下:
extension VedioPlayer {
struct Container: View {
let file: VedioManager.File
var body: some View {
/// For some reason, SwiftUI requires that you also set .navigationBarTitle for .navigationBarHidden to work properly.
Content(url: file.path, title: file.name)
.edgesIgnoringSafeArea(.all)
.navigationBarTitle(Text(file.name), displayMode: .inline)
.navigationBarHidden(true)
.onDisappear { print("onDisappear") }
}
}
}
Root View
最后就是我们的root View,NavigationView就是UINavigationController了:
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
VedioList.Container(path: VedioManager.root)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
然后,收工!!!