Skip to content

第65天 项目 13 第四部分

今天我们将把你刚刚学到的一些技巧付诸实践,包括如何创建自定义绑定、如何将用户选择的图像导入应用程序,以及如何对图像应用Core Filter效果。

希望你能注意到一点:我经常会带你回顾之前学过的主题。这并非偶然——正如自助类书籍作家拿破仑·希尔所写:“任何想法、计划或目标都可以通过反复思考植入脑海”。这种重复是我帮助你理解所有代码如何整合在一起的多种方法之一。

即便你今天未能完全理解这些概念,也没关系——我们在未来还会再次探讨它们。

今天你需要完成三个主题的实践,在这些主题中,你将练习使用PhotosPicker、通过Core Image过滤图像等内容。

  • 构建基础用户界面(UI)
  • 使用PhotosPicker将图像导入SwiftUI
  • 使用Core Image进行基础图像过滤

构建基础用户界面(UI)

作者:Paul Hudson 2023年12月12日

我们项目的第一步是构建基础用户界面,该界面包含以下元素:

  1. 一个NavigationStack,用于在顶部显示应用名称。
  2. 一个提示用户选择照片的区域,用户导入的图片将显示在该区域上方。
  3. 一个“强度”(Intensity)滑块,用于控制Core Image滤镜的应用强度,取值范围为0.0到1.0。
  4. 一个分享按钮,用于将处理后的图像导出应用程序。

一开始,我们不会把所有这些元素都添加进去,先添加足够的内容,让你理解各部分如何配合工作即可。

最初,用户尚未选择图像,因此我们将使用一个@State可选图像属性来表示这种状态。

首先,在ContentView中添加以下两个属性:

swift
@State private var processedImage: Image?
@State private var filterIntensity = 0.5

然后,将其body属性的内容修改为:

swift
NavigationStack {
    VStack {
        Spacer()

        // 图像区域

        Spacer()

        HStack {
            Text("Intensity")
            Slider(value: $filterIntensity)
        }
        .padding(.vertical)

        HStack {
            Button("Change Filter") {
                // 切换滤镜
            }

            Spacer()

            // 分享图片
        }
    }
    .padding([.horizontal, .bottom])
    .navigationTitle("Instafilter")
}

这里使用了两个Spacer,确保图像区域上下都有空间,同时也能让滤镜控制项固定在屏幕底部。

对于// 图像区域注释所在的位置,需要根据情况显示两种内容:如果用户已选择图像,则显示该图像;否则,显示一个简单的ContentUnavailableView,让用户知道该区域并非意外空白:

swift
if let processedImage {
    processedImage
        .resizable()
        .scaledToFit()
} else {
    ContentUnavailableView("No Picture", systemImage: "photo.badge.plus", description: Text("Tap to import a photo"))
}

我很喜欢在SwiftUI布局中直接放置可选视图,而且我认为将其与ContentUnavailableView配合使用效果特别好,因为一次只会显示其中一个视图。当然,目前点击该视图还不会有任何反应,但我们很快就会解决这个问题。

由于这段代码相对简单,我想简要说明如何进一步简化body属性——目前body中包含了大量布局代码,还有一个按钮动作。

虽然“切换滤镜”(Change Filter)按钮暂时不会包含复杂功能,但将按钮动作分离出来是一种良好的实践。

现在,我们只需在ContentView中添加一个空方法:

swift
func changeFilter() {
}

然后在“切换滤镜”按钮中调用该方法:

swift
Button("Change Filter", action: changeFilter)

学习过程中,将按钮动作等代码直接写在视图中是很常见的做法,但一旦进入实际项目开发,花些时间整理代码是很有必要的——相信我,从长远来看,这会让你的工作更轻松!

接下来,我还会分享更多类似的代码整理技巧,这样当你接近课程尾声时,会更有信心确保自己的代码规范整洁。

使用PhotosPicker将图像导入SwiftUI

作者:Paul Hudson 2023年12月12日

要让这个项目真正可用,我们需要允许用户从图库中选择照片,然后在ContentView中显示该照片。这需要一些思考,主要是因为Core Image的工作方式与SwiftUI相比有些迂回。

首先,我们需要在ContentView顶部导入PhotosUI框架,然后添加一个额外的@State属性,用于跟踪用户选择的图片:

swift
@State private var selectedItem: PhotosPickerItem?

其次,我们需要在希望触发图像选择的位置放置一个PhotosPicker视图。在这个应用中,我们可以将整个if let processedImage判断语句包裹在PhotosPicker中——可以将选中的图像或ContentUnavailableView作为PhotosPicker的标签。

具体代码如下:

swift
PhotosPicker(selection: $selectedItem) {
    if let processedImage {
        processedImage
            .resizable()
            .scaledToFit()
    } else {
        ContentUnavailableView("No Picture", systemImage: "photo.badge.plus", description: Text("Import a photo to get started"))
    }
}

提示: 这样会给ContentUnavailableView添加蓝色,以表明它是可交互的。如果不希望这样,可以给PhotosPicker添加.buttonStyle(.plain)修饰符来取消蓝色效果。

第三,我们需要一个方法,在用户选择图像后调用。

之前我已经向你展示过如何从PhotosPicker的选择结果中加载数据,也单独展示过如何将UIImage传入Core Image进行过滤。现在,我们需要将这两部分结合起来:不能直接加载简单的SwiftUI图像,因为它们无法传入Core Image,所以我们需要先加载纯Data对象,再将其转换为UIImage

现在,在ContentView中添加以下方法:

swift
func loadImage() {
    Task {
        guard let imageData = try await selectedItem?.loadTransferable(type: Data.self) else { return }
        guard let inputImage = UIImage(data: imageData) else { return }

        // 后续代码待添加
    }
}

然后,我们可以在selectedItem属性发生变化时调用该方法,只需在ContentView的某个位置添加onChange()修饰符即可——具体位置并不重要,但将其附加到PhotosPicker上似乎更为合理。

swift
.onChange(of: selectedItem, loadImage)

现在可以再次运行应用程序了——虽然目前选择图像后不会有太多操作,但至少可以调出照片选择界面并浏览图片。

使用Core Image进行基础图像过滤

作者:Paul Hudson 2023年12月12日

现在我们的项目已经能够让用户选择图像,下一步是允许用户对图像应用不同强度的Core Image滤镜。首先,我们先使用单一滤镜,之后会通过确认对话框扩展滤镜种类。

如果要在应用中使用Core Image,首先需要在ContentView.swift顶部导入两个框架:

swift
import CoreImage
import CoreImage.CIFilterBuiltins

接下来,我们需要一个上下文(context)和一个滤镜(filter)。Core Image上下文是一个负责将CIImage渲染为CGImage的对象,通俗地说,就是将图像的“生成配方”转换为我们可以使用的实际像素序列的对象。

创建上下文的成本较高,因此如果需要渲染大量图像,最好创建一个上下文并长期保留。至于滤镜,我们将默认使用CIFilter.sepiaTone()(棕褐色滤镜),但由于之后会扩展滤镜种类,我们会将滤镜设为@State属性,以便后续修改。

因此,在ContentView中添加以下两个属性:

swift
@State private var currentFilter = CIFilter.sepiaTone()
let context = CIContext()

有了这两个属性后,我们就可以编写一个处理导入图像的方法了——该方法会根据filterIntensity的值设置棕褐色滤镜的强度,从滤镜中读取输出图像,让CIContext渲染该图像,然后将结果放入processedImage属性中,以便在屏幕上显示。

swift
func applyProcessing() {
    currentFilter.intensity = Float(filterIntensity)

    guard let outputImage = currentFilter.outputImage else { return }
    guard let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { return }

    let uiImage = UIImage(cgImage: cgImage)
    processedImage = Image(uiImage: uiImage)
}

提示: 遗憾的是,棕褐色滤镜背后的Core Image代码要求强度值为Float类型,而非Double类型。这确实让Core Image显得有些老旧,但不用担心——我们很快就会解决这个问题!

接下来,需要修改loadImage()方法的工作方式。目前该方法的末尾有一个// 后续代码待添加注释,实际上,这里需要将用户选择的图像传入棕褐色滤镜,然后调用applyProcessing()方法来实现滤镜效果。

Core Image滤镜有一个专门的inputImage属性,用于传入待处理的CIImage,但这个属性常常存在问题,可能导致应用崩溃——更安全的做法是使用滤镜的setValue()方法,并传入键kCIInputImageKey

因此,将// 后续代码待添加注释替换为以下代码:

swift
let beginImage = CIImage(image: inputImage)
currentFilter.setValue(beginImage, forKey: kCIInputImageKey)
applyProcessing()

现在运行代码,你会发现应用的基础流程可以正常工作:选择图像后,就能看到应用了棕褐色效果的图像。但我们之前添加的强度滑块却没有任何作用,尽管它绑定的filterIntensity值正是滤镜读取的强度值。

出现这种情况其实并不奇怪:虽然滑块会改变filterIntensity的值,但改变这个属性并不会自动重新触发applyProcessing()方法。相反,我们需要通过onChange()告诉SwiftUI监听filterIntensity的变化,从而手动触发该方法。

同样,onChange()修饰符可以放在SwiftUI视图层级的任何位置,但由于滑块直接改变了该值,我会将其附加在滑块上:

swift
Slider(value: $filterIntensity)
    .onChange(of: filterIntensity, applyProcessing)

提示: 如果有多个视图修改同一个值,或者不确定具体是哪个视图修改了该值,那么可以将这个修饰符添加到视图的末尾。

现在你可以运行应用程序了,但需要注意:尽管Core Image在所有iPhone上的运行速度都非常快,但在模拟器中通常会非常慢。因此,你可以尝试运行应用以确保所有功能正常,但如果代码运行速度极慢,也不必感到惊讶。

本站使用 VitePress 制作