阅读 224

[SwiftUI 100 天] 使用 Coordinator 管理 SwiftUI 的视图控制器

译自 www.hackingwithswift.com/books/ios-s…
更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀

前面我们学习了如何用 UIViewControllerRepresentable 封装 UIKit 视图控制器,以便它们可以被 SwiftUI 使用。我们还特别聚焦了 UIImagePickerController。不过,我们也遇到一个问题:尽管可以显示图像选择器,用户选择图像后我们没有被通知到。

对此,SwiftUI 的解决方案是 coordinators,它对来自 UIKit 背景的同学可能带来混淆,因为他们也有一个叫 coordinators 的设计模式,当扮演的是完全不同的角色。明确一下,SwiftUI 的 coordinators 和众多 UIKit 开发者使用的 coordinator 模式一点关系都没有。如果你之前用过这个模式,需要暂且把它从大脑中请出去,以免困惑。

SwiftUI 的 coordinators 被设计来扮演 UIKit 视图控制器的委托的角色。记住,“委托” 是那些对各处发生的事件做出响应的对象。举个例子,UIKit 让我们附着一个委托对象到文本框视图,每当用户输入任何东西时,委托都会被通知。这意味着,UIKit 开发者可以在不创建自定义文本框类型的前提下,修改文本框的工作方式。

在 SwiftUI 中使用 coordinator 要求你了解一些 UIKit 的工作方式,因此集成 UIKit 的视图控制器在意料之中。为了演示这一点,我们要升级 ImagePicker 视图,以便返回用户选择的图像。

下面是改造之前的代码:

struct ImagePicker: UIViewControllerRepresentable {
    func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
        let picker = UIImagePickerController()
        return picker
    }

    func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {

    }
}复制代码

我们会一步一步来,因为工作量其实不小 —— 如果代码费了你不少时间理解,不要感觉沮丧,因为第一次遇到 coordinator 时,它们的确是个难点。

首先,在 ImagePicker 结构体中写一个嵌套类:

class Coordinator {
}复制代码

你稍后会知道为什么这里必须使用类而不是结构体。虽然不要求一定是嵌套类,不过使用嵌套类的好处是能够使代码封装更整洁,否则你可能会迷失在许多视图控制器和协调器的混杂之中。

尽管这个类处于 UIViewControllerRepresentable 结构体中,SwiftUI 不会自动应用这个类作为视图的 coordinator。相反,我们需要添加一个叫 makeCoordinator() 的新方法,如果有实现这个方法,SwiftUI 会自动调用它。我们需要做的是创建一个 Coordinator 类的实例并返回。

现在 Coordinator 类还没有具体的功能,我只要简单返回就好了:

func makeCoordinator() -> Coordinator {
    Coordinator()
}复制代码

下一步是让 UIImagePickerController 知道,当事件发生时,让委托的 coordinator 处理。这只需要一行代码,添加下面这行代码:

picker.delegate = context.coordinator复制代码

只有这行代码是无法编译通过的。在修复之前,我需要一些篇幅来解释发生的事情。我们不会自行调用 makeCoordinator();SwiftUI 会在 ImagePicker 创建时自动做这个动作。更好的是,SwiftUI 会自动关联协调器和我们的 ImagePicker 结构体,其含义是:当它调用 makeUIViewController()updateUIViewController() 时,它会自动传递协调器对象给我们。

因此,上面那行代码告诉 Swift 采用协调器作为 UIImagePickerController 的委托,也就是说,当 image picker 控制器内部发生变化的时候 —— 比如用户选择了一张图片 —— 它会将这个动作报告给我们的协调器。

代码无法编译通过的原因在于 Swift 检查我们的协调器类,发现它无法扮演 UIImagePickerController, 委托的角色。为了修复这个问题,我们需要修改 Coordinator类:

class Coordinator {复制代码

变为:

class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {复制代码

这样一来带来三个变化:

  1. 使得类继承 NSObject,它几乎是 UIKit 里一切东西的父类。NSObject 使得 Objective-C 能够向对象询问其支持的运行时功能,也就是说,image picker 可以发出这些的提问 “现在用户选择了一张图片,你打算怎么做?”
  2. 使得类遵循 UIImagePickerControllerDelegate 协议,这个协议添加了侦测到用户选择图片后的应对功能(NSObject 让 Objective-C 检查功能,而这个协议实际提供功能)
  3. 使得类遵循 UINavigationControllerDelegate 协议,这个协议能够侦测到用户在 image picker 的不同屏之间移动的行为。

现在你明白为什么我们需要用到一个 Coordinator 类了吧:我们需要继承自 NSObject 以便 Objective-C 能够查询我们支持的功能。

至此,我们实现了一个 ImagePicker 结构体,内部封装了一个 UIImagePickerController。我们配置这个控制器在感兴趣的事件发生时和 Coordinator 类交互。

UIImagePickerControllerDelegate 协议定义了两个可选方法给我们实现:一个用于用户选择图片后的处理,一个用于用户点击取消后的处理。如果我们不实现取消的处理方法,UIKit 会自动关闭 image picker 控制器,所以我们可以跳过这个方法。但选择图片的处理方法很重要:我们需要捕捉选中的图片并且拿去使用。问题是,我们该怎么做?

我们暂且把 UIKit 放到一边,从功能的角度单纯地思考一下。我们实现 ImagePicker 的功能是为了拿到图片,而我们是在 ContentView 结构体里通过 sheet 呈现的 ImagePicker。因此,我们得拿到图片,然后关掉 sheet。

这里需要用到的 SwiftUI 的 @Binding 属性包装器,它能让我们创建一个从 ImagePicker 到任何创建这个 ImagePicker 的东西的绑定。也就是说,我们在 image picker 里设置绑定值,但这个值更新的实际值时存储在别的地方的 —— 例如,我们的 ContentView

把这个属性添加到 ImagePicker

@Binding var image: UIImage?复制代码

添加完属性,我们还希望图片选择之后关闭 sheet。刚才我们还不处理图片选择的逻辑,因此默认的逻辑是 UIKit 提供的,即关闭视图,但是一旦我们注入自定义的函数,我们就需要手动自行处理关闭逻辑了。

把下面这个属性也添加到 ImagePicker,以便我们可以通过程序化手段关闭视图:

@Environment(\.presentationMode) var presentationMode复制代码

现在我们已经把属性添加到 ImagePicker,但图像选择首先通知的是 Coordinator 类。

相比于直接把数据再往下一层放进 Coordinator 类,更好的方式是告诉协调器它的父对象是谁,让它直接透过引用修改这些数据。给 Coordinator 类添加属性并修改构造器:

var parent: ImagePicker

init(_ parent: ImagePicker) {
    self.parent = parent
}复制代码

相应地修改 makeCoordinator()

func makeCoordinator() -> Coordinator {
    Coordinator(self)
}复制代码

至此,整个 ImagePicker 结构体看起来是这样的:

struct ImagePicker: UIViewControllerRepresentable {
    @Environment(\.presentationMode) var presentationMode
    @Binding var image: UIImage?

    class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
        var parent: ImagePicker

        init(_ parent: ImagePicker) {
            self.parent = parent
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
        let picker = UIImagePickerController()
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {

    }
}复制代码

最后,我们准备好读取 UIImagePickerController 的响应,这是透过实现一个特定的方法来完成的。UIKit 会在我们的 Coordinator 类中寻找这个方法,因为它是 image picker 委托类。如果方法被找到,则会被调用。

方法名很长,你可以利用 Xcode 的代码补全。在 Coordinator 类里,输入:“didFinishPicking”,Xcode 的代码补全应该会提供一个精确的方法,选择它,得到下面的代码:

func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
    code            
}复制代码

这个方法接收一个字典,其中的键是 UIImagePickerController.InfoKey 类型,值是 Any 类型。从中找到被用户选择的图片是我们的任务。我们找到它,赋给 parent,然后关闭 image picker。

把 “code” 占位符替换成下面这样:

if let uiImage = info[.originalImage] as? UIImage {
    parent.image = uiImage
}

parent.presentationMode.wrappedValue.dismiss()复制代码

注意,我们需要使用 UIImage 类型转换,那是因为字典里提供了各种各样的数据类型,我们需要小心操作。

写到这里,我猜你已经开始怀念 SwiftUI 的简洁了。好在 ImagePicker 结构体需要的东西基本上都在这了。

最后,我们要回到 ContentView.swift。下面是它之前的状态:

struct ContentView: View {
    @State private var image: Image?
    @State private var showingImagePicker = false

    var body: some View {
        VStack {
            image?
                .resizable()
                .scaledToFit()

            Button("Select Image") {
                self.showingImagePicker = true
            }
        }
        .sheet(isPresented: $showingImagePicker) {
            ImagePicker()
        }
    }
}复制代码

为了集成 ImagePicker 视图,我们要再添加一个 @State 图片属性,它是传给 image picker 用的:

@State private var inputImage: UIImage?复制代码

接下来,我们要实现一个方法,用于加载 inputImage

把下面这个方法添加到 ContentView

func loadImage() {
    guard let inputImage = inputImage else { return }
    image = Image(uiImage: inputImage)
}复制代码

最后,修改 sheet() modifier:

  • 我们需要把 inputImage 属性传给 image picker,以便用户选择图片后更新给它。
  • 我们还需要在 sheet 关闭后调用 loadImage() 方法。

第一个任务很简单:

ImagePicker(image: self.$inputImage)复制代码

第二个任务要求你了解一些新知识:一个可以传给 sheet() modifier 的额外的 onDismiss 参数,它可以让我们指定一个 sheet 关闭时执行的方法。这里,我们调用 loadImage()

.sheet(isPresented: $showingImagePicker, onDismiss: loadImage) {复制代码

这样我们就完工了。运行应用,尝试一下 —— 你可以点击按钮,浏览相册并从中选取照片。当照片被选择后,image picker 视图会消失,而你选择的图片将会被显示。

我能想象,到这里你可能觉得 UIKit 和协调器这些东西有点繁琐了。在我们继续之前,我简单总结一下整个过程:

  • 我们创建一个遵循 UIViewControllerRepresentable 的 SwiftUI 视图
  • 提供 makeUIViewController() 方法以创建某种 UIViewController,在我们的例子中是 UIImagePickerController
  • 添加一个嵌套的 Coordinator 类,扮演 UIKit 视图控制器和我们的 SwiftUI 视图之间的桥梁。
  • 给协调器提供一个 didFinishPickingMediaWithInfo 方法,它会在图片被选择时由 UIKit 触发。
  • 最后,我们给 ImagePicker 一个 @Binding 属性,以便它能把变化发回父视图。

值得一提的是,在你首次接触过协调器之后,后续的使用应该会简单一些。如果你对这整套东西感到困惑,我不会感觉意味。

不过别担心, 我们在后续的项目里还会用到这套东西,你有足够的练习机会!


我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~

                                                                    


关注下面的标签,发现更多相似文章
评论