75142913在线留言
CS193p2021学习笔记第十五课:将UIKit集成到SwiftUI_IOS开发_网络人

CS193p2021学习笔记第十五课:将UIKit集成到SwiftUI

Kwok 发表于:2021-09-14 18:14:48 点击:52 评论: 0

本年度课程快到尾声了,聊一点与课程内容无关的题外话,在swiftUI之前大部人都是使用UIKit开发APP,新版本SwiftUI(今年是3.0)发布后,几乎涵盖了UIKit所有内容,之所以现在SwiftUI在国内目前还不流行,大部分原因是资源与向下兼容的问题,大家都知道国内很多中小厂都是面向GitHub编程的,但国外程序员更容易接受新的事物,现在布局SwiftUI正是时候,但目前与SwiftUI学习相关的书籍极少,全是英文不说还特别的贵,动辄400+,还不如直接看文档划算,如果要系统的学习本框架,极力推荐斯坦福cs193p,我去年学习了2020版本后对比国内的讲解要深入许多,所以此课程目前是网上最好的SwiftUI学习教程(没有之一)。由于是英文的原因,翻译的字幕不太准确,所以可能学习时间会很久,有毅力的可以多看几遍,代码一定要一行一行自己撸,这样才能深刻理解代码背后的原因。

回到课程的理论部分,我们来了解今天要学习的内容,老师将会使用UIKit调用设备的相机拍照后将照片载入到我们的背景里。由于SwiftUI还没有可调用相机的功能,我们不得不借助UIKit。

一、UIKit嵌入SwiftUI理论:

在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里。这个过程就像是老板有事件委托给你去处理一样。

二、代码演示:优化iPhone的显示与使用

现在的我们的功能只能在iPad上能正常使用,今天的演示将让我们可以在iPhone上兼容并正常工作。

1、给iPhone的表情编辑增加关闭功能

在iPhone的横屏里不能向下滑动关闭表情编辑窗口,我们想像Manager一样,在顶部增加一个关闭按钮,但是Manage放置这个按钮很容易,PaletteManager.swift里的代码可以看到,整个管理都处于:NavigationView{}里面,在下面只需要一个.toolbar {ToolbarItem {...} }就能在顶部增加按钮的位置。

CS193p2021学习笔记第十五课将UIKit集成到SwiftUI

//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可以计算出我们的位置是否能足够显示所有的工具栏图片,当位置不够时自动压缩成“上下文”菜单。

CS193p2021学习笔记第十五课将UIKit集成到SwiftUI

在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,在屏幕宽度不够时显示...图标,长按弹出下面的样式:

 CS193p2021学习笔记第十五课将UIKit集成到SwiftUI

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调用设备的相机与访问照片库

在课程里的理论部分我们讲解了怎么样将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
标签:cs193pUIKitSwiftUIKwok最后编辑于:2021-09-14 18:14:34
0
感谢打赏!

《CS193p2021学习笔记第十五课:将UIKit集成到SwiftUI》的网友评论(0)

本站推荐阅读

热门点击文章