第66天 项目 13 第五部分
现在是为我们的应用程序进行最后润色的时候了,这款应用将允许用户尝试不同的Core Image滤镜,然后与其他应用程序分享处理结果。
今天的工作需要我们回顾之前遇到的一些Core Image问题,即Core Image中那种往好里说也只能算怪异的“字符串类型化”API。这里很容易出错,所以请慢慢操作,将你的代码与我的代码仔细核对,并记住莫舍软件工程定律:“如果程序运行不正常,别担心——要是一切都正常,你就该失业了。”
今天你需要完成两个主题的内容,在这些内容中,你将实践确认对话框、ShareLink等功能。
- 使用confirmationDialog()自定义滤镜
- 使用ShareLink分享图片
又一个应用程序完成了——别忘了向全世界分享你的进展!
使用confirmationDialog()自定义滤镜
作者:Paul Hudson 2023年12月12日
到目前为止,我们已经将SwiftUI和Core Image整合到了一起,但这款应用仍然不是特别实用——毕竟,棕褐色调效果并没有那么有趣。
为了让整个应用变得更好,我们打算让用户能够自定义想要应用的滤镜,而我们将通过确认对话框来实现这一点。在iPhone上,确认对话框是从屏幕底部滑上来的一系列按钮,你可以根据需要添加任意多个按钮——如果确实需要,它甚至可以滚动。
首先,我们需要一个属性来存储确认对话框是否应该显示,所以在ContentView中添加以下代码:
@State private var showingFilters = false现在,我们可以使用confirmationDialog()修饰符添加按钮了。它的工作方式与alert()完全相同:我们提供一个标题和一个要监听的条件,一旦条件变为true,确认对话框就会显示出来。
首先,在导航标题下方添加这个修饰符:
.confirmationDialog("选择滤镜", isPresented: $showingFilters) {
// 对话框内容放在这里
}接下来,用以下代码填充changeFilter()方法:
showingFilters = true至于在确认对话框中显示什么内容,我们可以创建一系列要显示的按钮以及一条可选消息。这些按钮的使用方式与alert()中的按钮类似:我们提供一个文本标题和一个点击按钮时要执行的操作。
在这款应用的确认对话框中,我们希望用户能从一系列不同的Core Image滤镜中进行选择,当他们选择某个滤镜后,该滤镜应立即被激活并应用。为了实现这一点,我们将编写一个方法,该方法会将currentFilter修改为用户选择的新滤镜,然后立即调用loadImage()。
不过,我们的计划中存在一个小问题,这是苹果为了让Core Image API更符合Swift风格而进行封装所导致的。要知道,底层的Core Image API完全是“字符串类型化”的——它使用字符串来设置值,而不是使用固定的属性——因此,苹果没有为我们创建全新的类,而是创建了一系列协议。
当我们将CIFilter.sepiaTone()赋值给一个属性时,会得到一个CIFilter类的对象,该对象恰好遵循名为CISepiaTone的协议。这个协议随后会暴露我们一直在使用的intensity参数,但在内部,它实际上只是将该参数映射为对setValue(_:forKey:)方法的调用。
这种灵活性实际上对我们有利,因为它意味着只要我们小心避免传入无效值,就能编写出适用于所有滤镜的代码。
那么,让我们开始解决这个问题吧。请将你的currentFilter属性修改为以下代码:
@State private var currentFilter: CIFilter = CIFilter.sepiaTone()再次说明,CIFilter.sepiaTone()会返回一个CIFilter对象,该对象遵循CISepiaTone协议。添加这个显式的类型注解意味着我们会丢失一些数据:我们指定该滤镜必须是CIFilter类型,但不再要求它必须遵循CISepiaTone协议。
由于这个修改,我们无法再访问intensity属性了,这就导致以下代码无法正常工作:
currentFilter.intensity = Float(filterIntensity)相反,我们需要用对setValue(_:forKey:)方法的调用来替换这行代码。实际上,之前的协议所做的工作本质上也是如此,但协议还提供了宝贵的额外类型安全性。
用以下代码替换那行无法正常工作的代码:
currentFilter.setValue(filterIntensity, forKey: kCIInputIntensityKey)kCIInputIntensityKey是另一个Core Image常量值,它的作用与设置棕褐色调滤镜的intensity参数相同。
完成这个修改后,我们可以回到确认对话框的实现上:我们希望能够将滤镜修改为其他类型,然后调用loadImage()来重置所有内容并应用初始处理。因此,在ContentView中添加以下方法:
func setFilter(_ filter: CIFilter) {
currentFilter = filter
loadImage()
}提示: 这意味着每次滤镜更改时都会触发图像加载。如果你想让这个过程运行得更快一些,可以将beginImage存储在另一个@State属性中,这样就不必每次更改滤镜时都重新加载图像了。
完成上述操作后,我们就可以用一系列按钮替换// 对话框内容放在这里这个注释了,这些按钮对应着各种不同的Core Image滤镜。
将以下代码放在注释所在的位置:
Button("结晶效果(Crystallize)") { setFilter(CIFilter.crystallize()) }
Button("边缘检测(Edges)") { setFilter(CIFilter.edges()) }
Button("高斯模糊(Gaussian Blur)") { setFilter(CIFilter.gaussianBlur()) }
Button("像素化(Pixellate)") { setFilter(CIFilter.pixellate()) }
Button("棕褐色调(Sepia Tone)") { setFilter(CIFilter.sepiaTone()) }
Button("锐化蒙版(Unsharp Mask)") { setFilter(CIFilter.unsharpMask()) }
Button("晕影(Vignette)") { setFilter(CIFilter.vignette()) }
Button("取消", role: .cancel) { }这些滤镜是我从众多Core Image滤镜中挑选出来的,不过你也可以尝试使用代码补全功能来尝试其他滤镜——输入CIFilter.,看看会出现哪些选项!
现在运行应用程序,选择一张图片,然后尝试将“棕褐色调”滤镜切换为“晕影”滤镜——这会在照片边缘周围产生变暗效果。(如果你使用的是模拟器,请记住给它一点时间,因为模拟器运行速度较慢!)
接下来尝试将滤镜切换为“高斯模糊”,理论上它应该会使图像模糊,但实际上却会导致应用程序崩溃。由于我们不再对滤镜施加CISepiaTone协议的限制,现在不得不使用setValue(_:forKey:)方法来传入值,而这种方式完全没有安全性可言。在这个例子中,高斯模糊滤镜没有“强度(intensity)”值,所以应用程序就直接崩溃了。
为了解决这个问题——同时也为了让我们的单个滑块能发挥更多作用——我们将添加一些额外的代码,这些代码会读取所有可用于setValue(_:forKey:)方法的有效键,并且只在当前滤镜支持“强度”键时才设置该键的值。通过这种方法,我们实际上可以查询任意多个键,并设置所有受支持的键。例如,对于棕褐色调滤镜,这会设置“强度(intensity)”值;而对于高斯模糊滤镜,这会设置“半径(radius,模糊程度)”值,依此类推。
这种条件判断的方法适用于你选择应用的任何滤镜,这意味着你可以安全地尝试其他滤镜。你只需要注意确保将filterIntensity乘以一个合理的数值——例如,1像素的模糊效果几乎是看不见的,所以我打算将它乘以200,使其效果更明显。
用以下代码替换这行代码:
currentFilter.setValue(filterIntensity, forKey: kCIInputIntensityKey)替换后的代码如下:
let inputKeys = currentFilter.inputKeys
if inputKeys.contains(kCIInputIntensityKey) { currentFilter.setValue(filterIntensity, forKey: kCIInputIntensityKey) }
if inputKeys.contains(kCIInputRadiusKey) { currentFilter.setValue(filterIntensity * 200, forKey: kCIInputRadiusKey) }
if inputKeys.contains(kCIInputScaleKey) { currentFilter.setValue(filterIntensity * 10, forKey: kCIInputScaleKey) }完成这个修改后,你现在就可以安全地运行应用程序了。导入一张你选择的图片,然后尝试使用所有不同的滤镜——现在应该不会再出现崩溃的情况了。尝试使用不同的滤镜和键,看看你能发现什么新效果!
使用ShareLink分享图片
作者:Paul Hudson 2023年12月12日
要完成这个项目,我们还需要添加两个重要功能:让用户能够使用SwiftUI的ShareLink视图分享他们的图片,然后在适当的时间过后,提示用户到App Store上为我们的应用程序评分。
这两个功能都不算复杂,所以让我们直接开始编写代码吧。
SwiftUI的ShareLink按钮能让我们只需一行代码就能分享文本、URL和图片等内容,它会自动处理系统标准的分享面板,让用户看到所有支持我们所分享数据的应用程序。
在我们的项目中,已经有一个// 分享图片的注释,但我们需要将其替换为一段检查代码:先检查是否有可分享的图片,如果有的话,就创建一个用于分享该图片的ShareLink按钮。
用以下代码替换该注释:
if let processedImage {
ShareLink(item: processedImage, preview: SharePreview("Instafilter图片", image: processedImage))
}这样,第一步就完成了。不过请记住,要在真实设备上进行测试,这样你才能看到各种真实应用程序对该图片的响应情况。
现在,只剩下最后一步了:请求用户为我们的应用程序评分。记住,最好只在用户真正感受到应用程序的价值之后再显示评分请求,因为如果请求过早,用户很可能会忽略它。
因此,我们不会“总是”显示评分请求,而是会等到用户至少更改过20次滤镜后再显示——这个次数足以让用户多次尝试所有选项,所以他们很可能会愿意帮忙评分。
首先,我们需要添加两个新属性:一个用于从SwiftUI的环境中获取评分请求器,另一个用于跟踪滤镜更改的次数。首先在ContentView.swift文件中再添加一个导入语句,导入StoreKit框架,然后在ContentView中添加以下两个属性:
@AppStorage("filterCount") var filterCount = 0
@Environment(\.requestReview) var requestReview接下来,我们需要在setFilter()方法的末尾添加一些代码,以便在每次滤镜更改时将filterCount加1,然后在滤镜更改次数至少达到20次时触发评分请求。将以下代码放在该方法的末尾:
filterCount += 1
if filterCount >= 20 {
requestReview()
}这会在Xcode中触发一个错误:评分请求必须在Swift的主actor(主线程相关的执行体)上进行,主actor是我们应用程序中能够处理用户界面操作的部分。虽然我们当前是在SwiftUI视图内部编写代码,但Swift无法保证这段代码会在主actor上运行,除非我们明确强制它这样做。
听起来好像很复杂,但实际上只需将该方法修改为以下形式即可:
@MainActor func setFilter(_ filter: CIFilter) {现在,Swift会确保这段代码始终在主actor上运行,编译错误也会随之消失。
提示: 为了测试方便,你或许可以将评分请求的条件从20次修改为5次左右,这样就能确保你的代码按预期工作了!
这最后一步完成后,我们的应用程序就全部开发完成了。现在再次运行应用程序,从头到尾尝试一遍——导入图片、应用滤镜,然后将图片分享到其他应用程序。做得好!