SwiftUI学习100天(Day90 - 项目 17,第五部分)

原创链接:https://www.hackingwithswift.com/100/swiftui
以下内容仅供学习参考:

今天我们通过添加一些最终功能并修复大量错误来结束我们的程序。是的,我们的程序有错误,我将向你介绍其中的一些错误,并向你展示如何修复它们。
当你学习编程时,发现代码中的错误可能会令人沮丧,因为感觉就像你搞砸了一样。但正如传奇的荷兰计算机科学家 Edsger Dijkstra 曾经说过的那样,“如果调试是消除错误的过程,那么编程一定是将它们放入的过程。”
换句话说,当你在开发软件时,修复错误是理所当然的,因为我们并不完美。你对创建错误、查找错误和修复错误越自在,你就会成为更好的开发人员。
今天你要完成三个主题,其中你将添加触觉反馈,修复我们应用程序中的许多错误,然后添加一个新屏幕来编辑卡片。

使用 UINotificationFeedbackGenerator 让 iPhone 振动
iOS 带有许多用于生成触觉反馈的选项,它们都可供我们在 SwiftUI 中使用。在最简单的形式中,这就像创建UIFeedbackGenerator
其中一个子类的实例然后在
你准备好时触发它一样简单,但是为了更精确地控制反馈,你应该首先调用它的prepare()
方法让 Taptic Engine 有机会预热.
重要提示:预热 Taptic Engine 有助于减少我们播放效果和实际发生之间的延迟,但它也会对电池产生影响,因此系统只会在你调用prepare()
后准备一两秒钟
。
我们可以使用几个不同的UIFeedbackGenerator
子类
,但我们在这里要使用的是UINotificationFeedbackGenerator
,
因为它提供了在 iOS 中常见的成功和失败触觉。现在,我们可以向ContentView
添加一个
UINotificationFeedbackGenerator
的中心
实例,但这会导致一个问题:ContentView
在
每当一张卡片被移除时都会收到通知,但在拖动过程中不会收到通知,这意味着我们没有机会预热启动 Taptic Engine。
因此,我们将为每个CardView
提供
自己的UINotificationFeedbackGenerator
实例,
以便他们可以根据需要准备和播放它们。系统将负责确保触觉都整齐排列,因此它们不会以某种方式混淆。
将此新属性添加到CardView
:
现在找到CardView
拖动手势中的
,并将整个闭包更改为:removal?()
行
仅此一项就足以在我们的应用程序中获得触觉,但始终存在触觉延迟的风险,因为 Taptic Engine 尚未准备就绪。在这种情况下,触觉仍会播放,但可能会延迟半秒——足以让人感觉与我们的用户界面有一点点脱节。
为了改善这一点,我们需要在触发触觉之前稍微调用一下prepare()
。在激活之前立即调用prepare()是不够的
:这样做不会给Taptic Engine足够的预热时间,因此你不会看到延迟有任何减少。相反,你应该在知道可能需要触觉时立即调用
prepare()
。
现在,你应该了解两个有用的实施细节。
首先,可以调用prepare()
然后永远不会触发效果 - 系统将使 Taptic Engine 准备好几秒钟,然后再次将其关闭。如果你反复调用prepare()
并且从未触发它,系统可能会开始忽略你的prepare()
调用,直到至少发生一种效果。
其次,完全允许在触发一次之前调用prepare()
多次—— prepare()
在 Taptic Engine 预热时不会暂停你的应用程序,并且在系统已经准备好时也不会产生任何实际性能成本。
将这两个放在一起,我们将更新我们的拖动手势,以便在手势发生变化时调用prepare()
。这意味着在最终触发触觉之前它可能会被调用一百次,因为每次用户移动手指时它都会被触发。
因此,将你的onChanged()
闭包修改为:
现在继续尝试该应用程序,看看你的想法 - 根据你滑动的方向,你应该能够感受到两种截然不同的触觉。
在我们结束触觉之前,我希望你考虑一件事。多年前,百事公司向商场购物者发起“百事可乐挑战赛”:先喝一口可乐,再喝一口另一种,看看你更喜欢哪一种。结果发现,与可口可乐相比,更多美国人更喜欢百事可乐,尽管可口可乐的市场份额要大得多。然而,有一个问题:人们似乎在测试中选择了百事可乐,因为百事可乐的味道更甜,虽然它在小口量中效果很好,但在罐装和瓶装中效果不佳,而人们实际上更喜欢可口可乐。
我这么说的原因是因为我们在我们的应用程序中添加了两个触觉通知,这些通知会经常播放。当你进行小剂量测试时,这些触觉可能感觉很棒 – 你让手机嗡嗡作响,这真的很令人愉快。但是,如果你是这个应用程序的忠实用户,那么我们的触觉可能会遇到两个问题:
用户可能会觉得它们很烦人,因为它们每两到三秒就会发生一次,具体取决于它们的速度。
更糟糕的是,用户可能对它们变得不敏感——它们失去了所有有用性,无论是作为通知还是作为一点点喜悦的火花。
所以,既然你已经亲自尝试过了,我希望你考虑一下应该如何使用它们。如果这是我的应用程序,我可能会保留失败的触觉,但我认为成功的触觉可能会消失——那个可能最常被触发,这意味着当失败的触觉播放时感觉会更特别一些。



修复错误
到目前为止,我们的 SwiftUI 应用程序看起来不错:我们有一堆可以拖动来控制应用程序的卡片,还有触觉反馈和一些辅助功能支持。但与此同时,它也充满了阻碍它发展的故障——有些大,有些小,但都值得解决。
首先,可以在卡片不在顶部时四处拖动卡片。这让用户感到困惑,因为他们可以抓取一张他们实际上看不到的卡片,所以这需要永远不可能。
为了解决这个问题,我们将使用allowsHitTesting()
这样只有最后一张卡片——最上面的那张——可以被拖来拖去。找到在ContentView
中的
stacked()
修改器并直接在下面添加:
其次,我们的 UI 在与 VoiceOver 一起使用时有点乱。如果你在启用了 VoiceOver 的真实设备上启动它,你会发现你可以点击背景图像来读出“背景,图像”,这是毫无意义的。然而,事情变得更糟了:向右轻扫一下,VoiceOver 就会在所有辅助元素之间移动——它会读出我们所有卡片中的文本,即使是那些不可见的卡片。
要解决背景图像问题,我们应该让它使用装饰图像,这样它就不会作为辅助功能布局的一部分被读出。将背景图片修改为:
要修复卡片,我们需要使用与我们一分钟前添加的accessibilityHidden()
修改器具有相似条件的修改器allowsHitTesting()
。在这种情况下,索引小于顶部卡片的每张卡片都应该从可访问性系统中隐藏,因为它对卡片没有任何用处,因此将其直接添加到allowsHitTesting()
修饰符下方
:
我们的应用程序存在第三个可访问性问题,这是使用手势控制事物的直接结果。是的,大多数时候使用手势非常有趣,但如果你有特定的辅助功能需求,则可能很难使用它们。
在此应用程序中,我们的手势导致了多个问题:VoiceOver 用户不清楚他们应该如何控制该应用程序:
我们不会说卡片是可以点击的按钮。
当答案被揭示时,没有声音通知它是什么。
用户无法通过向左或向右滑动来浏览卡片。
解决这些问题只需要很少的工作,但回报是我们的应用程序更容易为每个人所用。
首先,我们需要明确我们的卡片是可点击的按钮。这就像在.isButton
中添加
accessibilityAddTraits()
到CardView
的
。把这个放在它的ZStack
一样简单opacity()
修饰符之后:
现在系统将显示“谁在神秘博士中扮演第 13 位医生?按钮”——向用户提示卡片可以被点击的重要提示。
其次,我们需要帮助系统读取卡片的答案以及问题。现在这是可能的,但前提是用户在屏幕上四处滑动——这远非显而易见。因此,为了解决这个问题,我们将检测用户是否在他们的设备上启用了辅助功能,如果启用,则在显示提示和显示答案之间自动切换。也就是说,我们不会将答案显示在提示下方,而是将其关闭并只显示答案,这将使 VoiceOver 立即读出它。
SwiftUI 提供了一个特定的环境属性来告诉我们 VoiceOver 何时运行,称为accessibilityVoiceOverEnabled
. 因此,将此新属性添加到CardView
:
现在我们显示提示和答案的代码如下所示:
我们将对其进行更改,以便将提示和答案显示在单个文本视图中,并由accessibilityEnabled
决定显示哪种布局。将你的代码修改为:
如果你使用 VoiceOver 尝试一下,你会发现它的效果要好得多——只要双击名片,答案就会被读出。
第三,我们需要让用户更容易将卡片标记为正确或错误,因为现在我们的图像还不能切割它。它们不仅会阻止用户使用点击手势与我们的应用程序进行交互,还会被读作他们的 SF Symbols 名称——“复选标记、圆圈、图像”——而不是任何有用的东西。
为了解决这个问题,我们需要用实际移除卡片的按钮替换图像。如果用户是正确的或错误的,我们实际上并没有做任何不同的事情——我需要为你的挑战留下一些东西!– 但我们至少可以从牌组中取出最上面的牌。同时,我们将提供可访问性标签和提示,以便用户更好地了解按钮的功能。
所以,用这个新代码用这些图像替换你当前的HStack
:
因为即使最后一张卡片已被移除,这些按钮仍保留在屏幕上,所以我们需要在
removeCard(at:)
的开头添加一个
guard
检查
以确保我们不会尝试移除不存在的卡片。因此,将这一行新代码放在该方法的开头:
最后,我们可以在启用differentiateWithoutColor
或启用 VoiceOver 时使这些按钮可见。这意味着将另一个accessibilityVoiceOverEnabled
属性添加到ContentView
:
然后将if differentiateWithoutColor {
条件修改为:
通过这些可访问性更改,我们的应用程序对每个人都更好地工作——干得好!
在我们完成之前,我想添加一个小的额外更改。现在,如果你稍微拖动一个图像然后放手,我们将它的偏移量设置回零,这会导致它跳回到屏幕的中心。如果我们将弹簧动画附加到我们的卡片上,它将滑入中心,我认为这可以更清楚地向我们的用户指示实际发生的事情。
要做到这一点,请在CardView
的
ZStack
末尾添加一个
animation()
修饰符
紧跟在onTapGesture()
后面:
好多了!
提示:如果仔细观察,你可能会注意到,如果将卡片向右拖动一点然后松开,卡片会闪烁红色。稍后会详细介绍!



添加和删除卡片
到目前为止,我们所做的一切都使用了一组固定的示例卡片,但当然,只有当用户可以真正自定义他们看到的卡片列表时,这个应用程序才有用。这意味着添加一个列出所有现有卡片的新视图,并允许用户添加一个新的视图,这是你以前见过的所有内容。然而,这次有一个有趣的问题需要一些新的东西来修复,所以值得解决这个问题。
首先我们需要一些状态来控制我们的编辑屏幕是否可见。因此,将其添加到ContentView
:
接下来我们需要添加一个按钮以在点击时翻转该布尔值,因此找到if differentiateWithoutColor || accessibilityEnabled
条件并将其放在它之前:
我们将设计一个新EditCards
视图,来将Card
数组编码和解码
为UserDefaults
,但在我们这样做之前,我希望你使Card
结构符合Codable
如下所示:
现在创建一个名为“EditCards”的新 SwiftUI 视图。这需要:
有自己的
Card
数组。包裹在
NavigationView
中
,这样我们就可以添加一个完成按钮来关闭视图。有一个显示所有现有卡片的列表。
添加滑动以删除这些卡片。
在列表顶部有一个部分,以便用户可以添加新卡。
具有从
UserDefaults
.
我们之前已经看过所有这些代码,所以我不打算在这里再次解释。我希望你能停下来欣赏这意味着你已经走了多远!
用这个替换模板EditCards
结构:
这几乎全部EditCards
完成,但在我们可以使用它之前,我们需要添加更多代码到ContentView
,
以便它显示按需显示工作表并在关闭时调用resetCards()
。
我们之前使用过工作表,但我希望你向你展示一项额外的技术:你可以将一个功能附加到你的工作表,该功能将在工作表关闭时自动运行。这对你需要从工作表传回数据的时间没有帮助,但在这里我们只是要调用resetCards()
所以它是完美的。
在ContentView的最外层ZStack的末尾添加这个sheet()修饰符:
这行得通,但既然你在 SwiftUI 中获得了更多经验,我想向你展示另一种获得相同结果的方法。
当我们使用修饰符sheet()
时,
我们需要为 SwiftUI 提供一个它可以运行的函数,该函数返回要在工作表中显示的视图。对于上面的我们来说,这是一个带有EditCards()
内部的闭包——它创建并返回一个新视图,这就是工作表想要的。
当我们编写EditCards()
时
,我们依赖于语法糖——我们将我们的视图结构视为一个函数,因为 Swift 默默地将其视为对视图初始化程序的调用。所以,实际上我们实际上是在写EditCards.init()
,只是用一种更短的方式。
这一切都很重要,因为我们实际上可以将初始化程序EditCards
直接传递给工作表,而不是创建一个调用初始化程序的闭包EditCards
,如下所示:
这意味着“当你想读取工作表的内容时,调用EditCards
初始化程序,它会把视图发回给你使用。”
重要提示:这种方法之所以有效,是因为EditCards
有一个不接受任何参数的初始化程序。如果你需要传入特定值,则需要改用基于闭包的方法。
不管怎样,除了resetCards()
在工作表关闭时调用,我们还想在视图首次出现时调用它,所以在前一个修饰符下面添加这个修饰符:
因此,当视图首次显示时resetCards()
被调用,当它EditCards
被关闭后显示时resetCards()
也被调用。这意味着我们可以放弃我们的示例cards
数据,而是将其设为一个在运行时填充的空数组。
因此,将的cards
属性更改ContentView
为:
最后,ContentView
,
我们需要让cards
按需加载该属性。这从我们刚刚添加的相同代码开始EditCard
,所以现在把这个方法放到ContentView
:
现在我们可以添加对 resetCards()
中对
,以便loadData()
的调用cards
在应用启动或用户编辑卡片时用所有保存的卡片重新填充该属性:
现在继续运行应用程序。我们删除了默认示例,因此你需要按 + 图标添加一些你自己的示例。
完成最后的更改后,我们的应用程序就完成了——干得好!


