本年度课程快到尾声了,聊一点与课程内容无关的题外话,在swiftUI之前大部人都是使用UIKit开发APP,新版本SwiftUI(今年是3.0)发布后,几乎涵盖了UIKit所有内容,之所以现在SwiftUI在国内目前还不流行,大部分原因是资源与向下兼容的问题,大家都知道国内很多中小厂都是面向GitHub编程的,但国外程序员更容易接受新的事物,现在布局SwiftUI正是时候,但目前与SwiftUI学习相关的书籍极少,全是英文不说还特别的贵,动辄400+,还不如直接看文档划算,如果要系统的学习本框架,极力推荐斯坦福cs193p,我去年学习了2020版本后对比国内的讲解要深入许多,所以此课程目前是网上最好的SwiftUI学习教程(没有之一)。由于是英文的原因,翻译的字幕不太准确,所以可能学习时间会很久,有毅力的可以多看几遍,代码一定要一行一行自己撸,这样才能深刻理解代码背后的原因。
回到课程的理论部分,我们来了解今天要学习的内容,老师将会使用UIKit调用设备的相机拍照后将照片载入到我们的背景里。由于SwiftUI还没有可调用相机的功能,我们不得不借助UIKit。
在UIKit中,VIew并不那么优雅,它使用MVC的开发模式,视图通过controller来控制。通过使用controller对视图细节进行调节:
1、UIKit的2个集成点:UIViewRepresentable 和 UIViewControllerRepresentable
通过UIViewRepresentable 让 SwiftUI获取UIKit的视图。而UIViewControllerRepresentable的使用则是将UIKit的视图组合中的一个放入SwiftUI视图中。因为SwiftUI中没有controller(控制器)的概念。
//定义一个可以嵌入到SwiftUI里的UIKit
struct TextView: UIViewRepresentable {
class Coordinator : NSObject, UITextViewDelegate {
//委托对象内容
}
func makeCoordinator() -> Coordinator {
//制作一个委托对象
}
func makeUIView(context: Context) -> UITextView {
//制作一个视图
}
func updateUIView(_ uiView: UITextView, context: Context) {
//处理视图的更新
}
}
在初始化过程中首先调用 Make xxx方法,然后再调用 update xxx 方法, update方法可以多次调用,只要是请求更新的时候都可以调用, 这两个方法是呈现视图仅有的两个必须方法,创建UIKit嵌入一般有5个主要的组成部分:
i.func makeUIView(context: Context) -> View视图创建、func makeUIViewController (context: Context) -> Controller控制器的创建
通过makeUIView制作一个UIView或者UIViewController。它获取这个上下文Context,然后返回View(视图)或者Controller(控制器),下面的代码里返回的是一个UIKit的滚动文本视图:
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()//定义一个可滚动的多行文本区域。
textView.delegate = context.coordinator//将coordinator交给委托
textView.isScrollEnabled = true //结节定义:启用内容滚动
textView.isEditable = true//结节定义:启用编辑功能
textView.isUserInteractionEnabled = true//结节定义:启用了用户交互
return textView //将定义好的视图返回
}
通过围绕对UIKit定义返回一个包装好的视图或者控制器提供给SwiftUI使用。
ii.func updateUIView(View, context: Context) 视图更新、func updateUIController(Controller, context: Context) 控制器更新
SwiftUI与UIKit不同之处是前者拥有自动更新的反应机制(MVVM),但如果要将UIKit嵌入进来我们需要需要手动处理更新来让UIKit适合SwiftUI,所以我们要使用updateUIView(更新视图)或者updateUIViewController(更新控制器)。它会在我们需要更新时被调用(通过监视Context变化)。
@Binding var text: String //通过绑定传入需要更新的值
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text //使用text的内容更新uiView里的文本内容
}
iii.func makeCoordinator() -> Coordinator 创建可做为委托的对象
因为UIKit很多的东西都需要使用委托完成,通过makeCoordinator创建一个委托对象,将需要委托的事物通过委托对象来调用完成,用于实现常见模式,帮助你协调你的视图和 SwiftUI 包括委派,数据源和目标动作。
func makeCoordinator() -> Coordinator {
Coordinator(self)//将self委托给Coordinator
}
//下面是 Coordinator 的定义演示代码
class Coordinator : NSObject, UITextViewDelegate {
var textView: TextView
init(_ uiTextView: TextView) {
self.textView = uiTextView
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
return true
}
func textViewDidChange(_ textView: UITextView) {
self.textView.text = textView.text
}
}
iv.Context 上下文
在上传的功能函数里传输的context就是指的这个上下文,里面是有关于一些动画的,但主要还是Coordinator(那个委托的东西),传入这个参数是为了方便在控制器上设置委托,通常只有Controller才有委托。
v. func dismantleUIView(View,coordinator:Coordinator) 回收视图、func dismantleUIController(Controller,coordinator:Coordinator) 回收控制器
当我们需要主动清理内存或者其它东西时(视图/控制器消失之前调用)我们就需要到这个函数。通常情况下系统会自动回收这一步,我们可以不用写这个函数。
2、Delegation 委托
当我们与SwiftUI集成时,我们必须要理解什么是delegate(委托),由于我们在UIKit中使用闭包的机会不多,比如当调用了相机拍了一张照片后,我们想获取到这个照片信息,这时候就需要通过delegation(委托)来实现这个功能。
delegation让我们实际符合协议的变量,在这个协议中实现了某此功能,如将照片选中保存到delegate里。这个过程就像是老板有事件委托给你去处理一样。
现在的我们的功能只能在iPad上能正常使用,今天的演示将让我们可以在iPhone上兼容并正常工作。
1、给iPhone的表情编辑增加关闭功能
在iPhone的横屏里不能向下滑动关闭表情编辑窗口,我们想像Manager一样,在顶部增加一个关闭按钮,但是Manage放置这个按钮很容易,PaletteManager.swift里的代码可以看到,整个管理都处于:NavigationView{}里面,在下面只需要一个.toolbar {ToolbarItem {...} }就能在顶部增加按钮的位置。
//popover在iPhone的横屏里不能正常关闭,需要在顶上增加“关闭”
.popover(item: $paletteToEdit) { palette in
PaletteEditor(palette: $store.palettes[palette])
}
//sheet使用了$managing设置为true显示
//在PaletteManager使用环境变量调用关闭功能
//Button("关闭") {presentationMode.wrappedValue.dismiss()}
.sheet(isPresented: $managing) {
PaletteManager() //整个都处于NavigationView中,所以很方便放置“关闭”
}
现在我们的.popover也想和上面的.sheet实现同样效果,在.navigationTitle("表情选择器管理") 旁边放置一个 关闭按钮,我们可以通过对View扩展来实现为.popover包裹在NavigationView里面。
i.首先将为popover增加关闭闭包代码(13:02):
//为popover增加一个wrappedInNavigationViewToMakeDismissable修改器
.popover(item: $paletteToEdit) { palette in
PaletteEditor(palette: $store.palettes[palette])
//增加此修改器并执行闭包:paletteToEdit = nil
.wrappedInNavigationViewToMakeDismissable { paletteToEdit = nil }
}
//popover通过绑定paletteToEdit让其显示或者关闭,所以这里我们闭包只要设置为nil即可将其关闭。
ii.扩展View增加wrappedInNavigationViewToMakeDismissable修改器(14:43)
我们在UtilityViews.swift里增加以下代码,为View扩展:
extension View{
@ViewBuilder //此函数声明了一个不透明的返回类型,但在函数体中没有用于推断底层类型的返回语句
//dismiss为一个Optional闭包,将满足条件的self包裹在NavigationView里并增加dismissable修改器
//知识点:(()->Void)? 设置一个Optional闭包
func wrappedInNavigationViewToMakeDismissable(_ dismiss:(()->Void)?) -> some View {
//非iPad,闭包代码存在的情况
if UIDevice.current.userInterfaceIdiom != .pad, let dismiss = dismiss{
NavigationView{
self
.navigationBarTitleDisplayMode(.inline)//设置为小标题
.dismissable(dismiss)//学习二次调用扩展里的修改器
//下面的.toolbar也能正常工作
//.toolbar {
// ToolbarItem(placement: .cancellationAction) {
// Button("关闭"){ dismiss()}
// }
// }
}
.navigationViewStyle(StackNavigationViewStyle())//(22:06)一种由一次只显示一个顶部视图的视图堆栈表示的导航视图样式。
//当在NavigationView中有足够的水平空间时(iPhone横屏、iPad、Mac),SwiftUI默认会自动放置左、右2个视图。
}else{
self //不修改返回
}
}
@ViewBuilder
//参数和上面一样,只为增加“关闭”按钮,并且可以在其它地方通用
func dismissable(_ dismiss:(()->Void)?) -> some View {
if UIDevice.current.userInterfaceIdiom != .pad, let dismiss = dismiss{
//在toolbar里增加按钮
self.toolbar{
//cancellationAction让按钮显示在左上角(19:30)
ToolbarItem(placement: .cancellationAction){
Button("关闭"){ dismiss()}//显示按钮并可调用闭包
}
}
}else{
self //不修改返回
}
}
}
只要视图使用了wrappedInNavigationViewToMakeDismissable这个修改器就会自动被NavigationView包裹并增加一个关闭按钮,调用需要执行的闭包代码。
上面我们通过扩展View制作了2个修改器,dismissable也可以通用到.sheet里的PaletteManager()视图里,我们将原来的代码修改一下:
//使用通用的dismissable来执行关闭的闭包代码
.dismissable{ presentationMode.wrappedValue.dismiss() }//替换下面被注释掉的代码的功能
//注意看代码,这里相当于放了2个.toolbar,假设我们在dismissable里没有放置.cancellationAction
//这里的关闭按钮并不会显示,因为2个都被显示到了右上角,将被下面的“edit”修改器替换掉
.toolbar {
ToolbarItem { EditButton() }//编辑按钮
// ToolbarItem(placement: .navigationBarLeading) {
// if presentationMode.wrappedValue.isPresented,
// UIDevice.current.userInterfaceIdiom != .pad {
// Button("关闭") {
// presentationMode.wrappedValue.dismiss()
// }
// }
// }
}
上面所做的事物只是为了学习通过对View的扩展为其增加嵌套和功能,我们将在下面的学习中移除增加的相关代码。
2、为iPhone增加粘贴背景图片功能:
在iPad中我们可以通过拖放将图片放到背景里。但在iPhone里不能分屏,所以我们无法直接拖拽到画板,我们需要为iPhone增加一个粘贴按钮和功能。
i.增加“粘贴”按钮
由于iPhone顶部位置有限,我们如果增加了“粘贴”按钮后,“撤销/重做”按钮则会被替换,我们尝试使用ToolbarItemGroup将按钮放到合适的地方:
.toolbar {
//可设置以下几个常见的排列方式:
//.navigationBarTrailing 首选,将在右上角对齐显示多个按钮
//.navigationBarLeading 在上角
//.bottomBar 将项目放置在底部工具栏中。
//.navigation 显示在导航位置,根据系统显示不同
//.automatic 系统自动放置
ToolbarItemGroup(placement: .navigationBarTrailing){
//撤销与重做按钮移除掉
// UndoButton(
// undo: undoManager?.optionalUndoMenuItemTitle,
// redo: undoManager?.optionalRedoMenuItemTitle
// )
AnimatedActionButton(title: "粘贴背景图片", systemImage: "doc.on.clipboard"){
pasteBackground()//粘贴功能
}
//根据需要显示 撤销/重做 按钮
if let undoManager = undoManager{ //undoManager != nil
if undoManager.canUndo {
AnimatedActionButton(title: undoManager.undoActionName, systemImage: "arrow.uturn.backward") {
undoManager.undo() //撤销
}
}
if undoManager.canRedo {
AnimatedActionButton(title: undoManager.redoActionName, systemImage: "arrow.uturn.forward") {
undoManager.redo()//重做
}
}
}
}
}
上面的ToolBar最多的时候会显示3个图标,有点影响美观,我们可以将其更改为上下文菜单。
ii.顶部图标压缩成“上下文”菜单(35:10)
将.toolbar修改为.compactableToolbar,然后去实现这个.compactableToolbar(可压缩的工具栏),compactableToolbar可以计算出我们的位置是否能足够显示所有的工具栏图片,当位置不够时自动压缩成“上下文”菜单。
在iPhone上,我们通过@Environment(\.horizontalSizeClass)获取这个环境的水平大小级别。当然垂直也有大小级别获取(\.verticalSizeClass),只有2个枚举值:compact(紧凑)和regular(常规)。
首先我们在UtilityViews.swift里创建一个修改器:
//监听水平尺寸状态根据条件返回带有内容的上下文菜单的单个按钮(如果水平压缩)或者不变的content
struct CompactableIntoContextMenu: ViewModifier {
@Environment(.horizontalSizeClass) var horizontalSizeClass //从环境变量获取当前水平尺寸
var compact: Bool { horizontalSizeClass == .compact }//监听是否处于宽屏模式
func body(content: Content) -> some View {
if compact {
//处于紧凑的尺寸类(此按钮主要用于显示图标和长按触发contextMenu)
Button {
//无动作,长按弹出“上下文”菜单
} label: {
Image(systemName: "ellipsis.circle")
}
.contextMenu {
content //将原来的工具栏图标压缩到“上下文”菜单里
}
} else {
content //在顶上显示所有工具栏图标
}
}
}
然后我们创建.compactableToolbar视图,并应用上面创建ViewModifier:
extension View {
//替换toolBar 在水平紧凑的环境中,它在工具栏中放置一个按钮要弹出上下文菜单,限制内容为@ViewBuilder而非ToolbarItems
func compactableToolbar(@ViewBuilder content: () -> Content) -> some View where Content: View {
self.toolbar {//创建一个工具栏并放入闭包里的内容
content().modifier(CompactableIntoContextMenu())//应用我们创建的modifier
}
}
}
上面通过对View扩展创建了一个compactableToolbar,在屏幕宽度不够时显示...图标,长按弹出下面的样式:
iii.完成粘贴的功能函数:
//粘贴背景
private func pasteBackground() {
//尝试从系统的粘贴板获取jpegData数据,1.0为不压缩。
if let imageData = UIPasteboard.general.image?.jpegData(compressionQuality: 1.0) {
document.setBackground(.imageData(imageData), undoManager: undoManager)//调用VM功能
} else if let url = UIPasteboard.general.url?.imageURL { //过滤后的图片URL
document.setBackground(.url(url), undoManager: undoManager)
} else {
//IdentifiableAlert 升级了,新增加了几个好用的init
alertToShow = IdentifiableAlert(
title: "背景粘贴不成功",
message: "您目前还没有没有复制图像或者图像地址。"
)
}
}
下面附上IdentifiableAlert升级后的代码:
//返回一个可识别的弹出提示/警告框
struct IdentifiableAlert: Identifiable {
var id: String //识别ID
var alert: () -> Alert //提示/警告内容
//传入闭包内容,方便自定义
init(id: String, alert: @escaping () -> Alert) {
self.id = id
self.alert = alert
}
//所有参数都可使用String
init(id: String, title: String, message: String) {
self.id = id
alert = { Alert(title: Text(title), message: Text(message), dismissButton: .default(Text("OK"))) }
}
//自动生成id内容为String
init(title: String, message: String) {
self.id = title + message
alert = { Alert(title: Text(title), message: Text(message), dismissButton: .default(Text("OK"))) }
}
}
在课程里的理论部分我们讲解了怎么样将UIKit的控制器/视图嵌入到SwiftUI里面来。现在我们将通过演示使用UIKit的代码访问硬件设备:
1、制作待嵌入的UIKit控制器:
我们新建一个Camera.swift文件,里面写入代码(52:30):
import SwiftUI
struct Camera: UIViewControllerRepresentable {
var handlePickedImage: (UIImage?) -> Void//与SwiftUI数据通讯交换
//相机是否可用
static var isAvailable: Bool {
//UIImagePickerController用于管理拍照、录制电影和从用户的媒体库中选择项目的系统接口(54:00)。
UIImagePickerController.isSourceTypeAvailable(.camera)//查询设备是否支持使用指定的源类型选择媒体。
}
//创建自定义实例,用于将视图控制器的更改传递给swiftui界面的其他部分。
func makeCoordinator() -> Coordinator {
//返回给处理委托的协调员(class Coordinator)
Coordinator(handlePickedImage: handlePickedImage)
}
//制作一个控制器
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()//管理拍照、录制电影和从用户的媒体库中选择项目的系统接口。
picker.sourceType = .camera//让picker控制器显示的选择器界面的类型为设备的内置摄像头。
picker.allowsEditing = true//指示是否允许用户编辑选定的静态图像或电影(56:20)。
picker.delegate = context.coordinator//picker的委托对象(拍照完成后委托给谁来处理:通过makeCoordinator把数据交给class Coordinator)。
return picker
}
//更新控制器
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
// 啥事儿也不做。写来就是为了符合协议(52:29)
}
//被委托的对象处理拍照完成后要做的事情(58:01)
//NSObject是UIKit的基类,必须符合这个协议
//UIImagePickerControllerDelegate实现才能与图像选择器接口交互的方法的协议
//UINavigationControllerDelegate为UIImagePickerControllerDelegate的前提协议,啥事也不做
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
var handlePickedImage: (UIImage?) -> Void //从makeCoordinator获取
init(handlePickedImage: @escaping (UIImage?) -> Void) {
self.handlePickedImage = handlePickedImage //使用Coordinator初始化这个闭包
}
//告诉委托用户取消了挑选操作(符合UIImagePickerControllerDelegate协议)。
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
handlePickedImage(nil)//将handlePickedImage设置为nil
}
//告诉委托用户选择了静态图像或电影(符合UIImagePickerControllerDelegate协议)(1:02:30)。
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
handlePickedImage((info[.editedImage] ?? info[.originalImage]) as? UIImage)//将选择的照片转成UIImage并交给handlePickedImage
}
}
}
2、SwiftUI调用相机(1:03:31)
上面我们通过UIKit获取用户拍照后的照片结果,现在我们制作 SwiftUI调用 这个UIKit控制器的接口:
在EmojiArtDocumentView.swift的.compactableToolbar里增加2个功能图标:
// 用相机拍照,添加背景设置
if Camera.isAvailable { //检查相机是否可用
AnimatedActionButton(title: "拍照", systemImage: "camera") {
backgroundPicker = .camera//选择器类型为.camera
}
}
// 通过从用户的照片库中选择一张照片来添加背景设置
if PhotoLibrary.isAvailable { //检查照片库是否可用
AnimatedActionButton(title: "选择照片", systemImage: "photo") {
backgroundPicker = .library//选择器类型为.library
}
}
上面选择照片的功能将在下面制作,通过点击不同的图标调用对应的功能,并使用.sheet做为视图容器:
.sheet(item: $backgroundPicker) { pickerType in
switch pickerType {
//用户点击"拍照"调用Camera.swift文件
case .camera: Camera(handlePickedImage: { image in handlePickedBackgroundImage(image) })
//用户点击"选择照片"调用PhotoLibrary.swift文件
case .library: PhotoLibrary(handlePickedImage: { image in handlePickedBackgroundImage(image) })
}
}
定义3个依赖的数据/功能:
// 控制相机或相册页是否打开(或两者都没有)
@State private var backgroundPicker: BackgroundPickerType?//注意类型是下面的枚举
// enum控制要显示的照片选择页
enum BackgroundPickerType: Identifiable {
case camera
case library
var id: BackgroundPickerType { self }//符合Identifiable协议,.sheet需要(1:07:23)
}
// 从相机或照片库获取到的图像处理函数
private func handlePickedBackgroundImage(_ image: UIImage?) {
autozoom = true//自动缩放
if let imageData = image?.jpegData(compressionQuality: 1.0) { //尝试从设备获取到UIImage
document.setBackground(.imageData(imageData), undoManager: undoManager)//调用VM功能
}
backgroundPicker = nil//关闭.sheet
}
你不能随意在IOS设备里访问用户联系人、定位、照片、照片库等,需要请求用户同意授权才可以,我们需要使用info.plist里配置请求:
Privacy - Camera Usage Description :String = "这里写一些请求访问相机拍照并放到xxx等内容,由自己定义即可"
Privacy - Photo Library Usage Description:String = "我想看你的照片,你要是不同意就算了,我下次就不问你了。"
当程序启动时就会询问用户是否允许让程序访问相册、相机,里面还有很多关于隐私权限。
3、SwiftUI调用照片库
最后附上PhotoLibrary.swift文件的源代码,和上面的相机差不多,只是调用的库 不一样:
import SwiftUI
import PhotosUI //导入依赖
struct PhotoLibrary: UIViewControllerRepresentable {
var handlePickedImage: (UIImage?) -> Void
static var isAvailable: Bool {
return true //始终可用
}
func makeCoordinator() -> Coordinator {
Coordinator(handlePickedImage: handlePickedImage)
}
//控制器使用的是PHPickerViewController
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration()//向选择器视图控制器提供配置数据的对象。
configuration.filter = .images//这里和相机参数不同
//提供了从用户的照片库中选择资源的用户界面。
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
// nothing to do
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
var handlePickedImage: (UIImage?) -> Void
init(handlePickedImage: @escaping (UIImage?) -> Void) {
self.handlePickedImage = handlePickedImage
}
//符合PHPickerViewControllerDelegate协议
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
let found = results.map { $0.itemProvider }.loadObjects(ofType: UIImage.self) { [weak self] image in
self?.handlePickedImage(image)
}
if !found {
handlePickedImage(nil)//未选择
}
}
}
}
本课学习了将UIKit以View或者控制器的方式嵌入到SwiftUI里,我们使UIKit里的照片库与相机API,目前SwiftUI还没有类似接口可以,我们只能使用老方式,所以理解UIKit的嵌入比较重要。
除非注明,网络人的文章均为原创,转载请以链接形式标明本文地址:https://www.55mx.com/post/161
《CS193p2021学习笔记第十五课:将UIKit集成到SwiftUI》的网友评论(0)