Hacking with iOS: SwiftUI Editio
使用 UINotificationFeedbackGenerator 使 iPhone 振动
iOS附带了许多用于生成触控反馈的选项,这些选项都可供我们在SwiftUI中使用。以其最简单的形式,它就像创建UIFeedbackGenerator
的一个子类的实例然后调用其play()
方法一样简单,但是要更精确地控制反馈,您应该首先调用其prepare()
方法,以提供震动控制引擎(Taptic Engine)热身的机会。
重要提示:预热震动控制引擎有助于减少我们调用play()
和实际发生的效果之间的等待时间,但是它也会对电池产生影响,因此,在您调用prepare()
之后,系统仅会准备一两秒钟。
我们可以使用UIFeedbackGenerator
的几个不同的子类,但是我们在这里使用的是UINotificationFeedbackGenerator
,因为它提供了iOS上常见的成功和失败触控。现在,我们可以将UINotificationFeedbackGenerator
的一个实例添加到每个ContentView
中,但这会导致一个问题:只要移除卡片,ContentView
就会得到通知,而拖动过程中则不会得到通知,这意味着我们没有有机会预热 Taptic Engine。
因此,相反,我们将为每个CardView
赋予其自己的UINotificationFeedbackGenerator
实例,以便他们可以根据需要进行准备和播放。系统会确保所有触控反馈都排列整齐,因此不会因某种原因而使它们混淆。
将此新属性添加到CardView
:
@State private var feedback = UINotificationFeedbackGenerator()
现在,在CardView
的拖动手势找到self.removal?()
行,并将整个闭包更改为:
if self.offset.width > 0 {
self.feedback.notificationOccurred(.success)
} else {
self.feedback.notificationOccurred(.error)
}
self.removal?()
仅凭这一点就足以在我们的应用程序中获得触控反馈,但是总会有因触控反馈引擎尚未准备好而导致触控被延迟的风险。在这种情况下,触控仍然可以播放,但可能要延迟半秒才能完成——足以感觉到与我们的用户界面有些脱节。
为了改善这一点,我们需要在调用play()
之前先在触觉上调用prepare()
。在play()
之前立即调用prepare()
是不够的:这样做没有给Taptic Engine足够的时间进行预热,因此您不会看到延迟减少。相反,您应该在知道可能需要触控反馈后立即调用prepare()
。
现在,您应该注意两个有用的实现细节。
首先,可以调用prepare()
然后再不调用play()
——系统将使Taptic Engine保持几秒钟的准备时间,然后再次关闭电源。如果您反复调用prepare()
而从不调用play()
,则系统可能会开始忽略您的prepare()
调用,直到至少发生一次play()
为止。
其次,完全可以在一次调用play()
之前多次调用prepare()
——Taptic Engine 预热时 prepare()
不会暂停您的应用程序的预热操作,并且当系统已经准备好了只后调用prepare()
也没有任何实际的性能成本了。
将这两者放在一起,我们将更新拖动手势,以便每当手势更改时都会调用prepare()
。这意味着可以在最终调用play()
之前调用它一百次,因为每次用户移动手指时都会触发它。
因此,将您的onChanged()
闭包修改为如下内容:
.onChanged { offset in
self.offset = offset.translation
self.feedback.prepare()
}
现在继续尝试该应用程序,你看到了什么——您应该能够根据滑动的方向感受到两种截然不同的触觉。
在我们介绍触控反馈之前,我想让您考虑一件事。几年前,百事可乐向商场购物者挑战“百事可乐挑战”:喝一小杯一种可乐,再喝一小杯另一种可乐,看看你喜欢哪一种。结果发现,尽管可口可乐的市场份额更大,但美国人却更喜欢百事可乐而不是可口可乐。但是,这里存在一个问题:人们在测试中选择百事可乐,似乎是因为百事可乐的味道更甜,虽然在小杯的情况下效果很好,但在罐装和瓶装的大小上效果却不太理想,人们实际上更喜欢可口可乐。
我之所以这样说,是因为我们在我们的应用中添加了两个触控反馈通知,这些通知将会发挥很多作用。而且,当您进行少量测试时,这些触控反馈可能会感觉很棒——您正在使手机嗡嗡作响,这确实非常令人愉快。但是,如果您是这个应用程序的真正用户,那么我们的触觉可能会遇到两个问题:
- 用户可能会发现它们很烦人,因为它们每两三秒就会发生一次,具体取决于它们的速度。
- 更糟糕的是,用户可能会对他们变得不敏感——他们失去了所有作用,无论是作为通知还是作为一种小小的火花。
因此,现在您已经为自己尝试过了,我希望您考虑如何使用它们。如果这是我的应用程序,则我可能会保留失败触控反馈,但我认为成功触觉可以不需要——因为可能是最常触发的,这意味着当失败触控反馈播放时,感觉会有些特殊。
修复 Bugs
到目前为止,我们的SwiftUI应用程序看起来不错:我们有一堆可以拖动的卡来控制该应用程序,还提供触控反馈和一些可访问性支持。但与此同时,它也充满了小故障,阻碍了它的发展——有大有小,但都值得解决。
首先,可以拖动不位于顶部的位置卡片。这会使用户感到困惑,因为他们可以抓住他们实际上看不到的卡片,这应该是不被允许的。
为了解决这个问题,我们将使用allowHitTesting()
,以便只能拖动最后一张卡片——最上面的一张卡片。在ContentView
中找到stacked()
修饰符并将其直接添加到下面:
.allowsHitTesting(index == self.cards.count - 1)
其次,与VoiceOver一起使用时,我们的UI有点混乱。如果您在启用了VoiceOver的真实设备上启动它,就会发现可以点击背景图片以读出“Background, image”,这毫无意义。但是,使情况变得更糟是:向右滑动一下,VoiceOver将遍历所有可访问性元素——它从我们所有卡中读取文本,甚至是看不到的文本。
要解决背景图片问题,我们应该使用装饰性图片,以免将其作为辅助功能布局的一部分读出。将背景图像修改为此:
Image(decorative: "background")
要修复卡片,我们需要使用可访问性(hidden:)
修饰符,其条件与我们在一分钟前添加的allowHitTesting()
修饰符相似。在这种情况下,索引位置小于顶部卡片的每张卡片都应从可访问性系统中隐藏,因为它实际上对卡片没有任何作用,因此请将其直接添加到allowHitTesting()
修饰符下方:
.accessibility(hidden: index < self.cards.count - 1)
我们的应用程序还有第三个可访问性问题,这是使用手势控制事物的直接结果。是的,手势在大多数时候都是非常有趣的,但是如果你有特定的可访问性需求,使用它们可能会非常困难。
在这个应用程序中,我们的手势引发了多个问题:对于VoiceOver用户来说,他们应该如何控制应用程序并不明显:
- 我们不是说卡片是可以点击的按钮。
- 当答案被揭示时,没有声音通知它是什么。
- 用户没有办法左右滑动。
解决这些问题只需要很少的工作,但回报是我们的应用程序对每个人来说都更容易访问。
首先,我们需要弄清楚我们的卡片是可点击的按钮。这很简单,只需使用.isButton
将可访问性(addTraits:)
添加到CardView
中的ZStack
。将其置于其opacity()
修饰符之后:
.accessibility(addTraits: .isButton)
现在系统将显示“Who played the 13th Doctor in Doctor Who? Button“——一个重要的提示,用户可以点击卡片。
其次,我们需要帮助系统读取卡片的答案以及问题。现在这是可能的,但只有当用户在屏幕上滑动时,这还远远不够明显。因此,为了解决这个问题,我们将检测用户是否在他们的设备上启用了辅助功能,如果启用了,会自动在显示提示和显示答案之间切换。也就是说,我们不会让答案出现在提示下面,而是将其调出并显示答案,这将使VoiceOver立即将其读出。
现在,SwiftUI没有告诉我们VoiceOver何时运行的环境属性,而是有一个名为\.accessibilityEnabled
的常规属性。当启用了Difference Without Color、Reduce Motion或Reduce Transparency之类的功能时,这不会触发,而且它是SwiftUI提供给我们的最接近“VoiceOver Running”的选项。
因此,将此新属性添加到CardView
:
@Environment(\.accessibilityEnabled) var accessibilityEnabled
现在,显示提示和答案的代码如下所示:
VStack {
Text(card.prompt)
.font(.largeTitle)
.foregroundColor(.black)
if isShowingAnswer {
Text(card.answer)
.font(.title)
.foregroundColor(.gray)
}
}
我们将改变这一点,使提示和答案显示在一个单独的文本视图中,由accessibilityEnabled
决定显示哪个布局。将您的代码修改为:
VStack {
if accessibilityEnabled {
Text(isShowingAnswer ? card.answer : card.prompt)
.font(.largeTitle)
.foregroundColor(.black)
} else {
Text(card.prompt)
.font(.largeTitle)
.foregroundColor(.black)
if isShowingAnswer {
Text(card.answer)
.font(.title)
.foregroundColor(.gray)
}
}
}
如果你试着用 VoiceOver 试一试,你会听到它的效果更好——只要双击卡片,答案就会被读出。
第三,我们需要让用户更容易地对卡片进行正确或错误的标记,因为现在我们的图像无法描述卡片。它们不仅可以阻止用户使用轻触手势与我们的应用程序进行交互,而且还可以被读出他们的SF符号名称——“对号、圆圈、图像”,而不是任何有用的东西。
为了解决这个问题,我们需要用按钮来替换图像,这些按钮实际上可以移除卡片。实际上,如果用户是对是错,我们不会做任何不同的事情——我需要为你的挑战留下一些东西!——但我们至少可以从牌堆中取出顶部的卡片。同时,我们将提供可访问性标签和提示,以便用户更好地了解按钮的功能。
所以,用这些新代码替换当前的HStack
:
HStack {
Button(action: {
withAnimation {
self.removeCard(at: self.cards.count - 1)
}
}) {
Image(systemName: "xmark.circle")
.padding()
.background(Color.black.opacity(0.7))
.clipShape(Circle())
}
.accessibility(label: Text("Wrong"))
.accessibility(hint: Text("Mark your answer as being incorrect."))
Spacer()
Button(action: {
withAnimation {
self.removeCard(at: self.cards.count - 1)
}
}) {
Image(systemName: "checkmark.circle")
.padding()
.background(Color.black.opacity(0.7))
.clipShape(Circle())
}
.accessibility(label: Text("Correct"))
.accessibility(hint: Text("Mark your answer as being correct."))
}
因为这些按钮即使在最后一张卡被移除后仍会显示在屏幕上,所以我们需要在removeCard
的开头添加一个保护检查(at:)
,以确保我们不会试图移除不存在的卡。因此,在该方法的开头添加一行新代码:
guard index >= 0 else { return }
最后,我们可以使这些按钮在启用区分颜色或启用画外音时可见。这意味着向ContentView
添加另一个accessibilityEnabled
属性:
@Environment(\.accessibilityEnabled) var accessibilityEnabled
然后将if differenceWithoutColor{
条件修改为:
if differentiateWithoutColor || accessibilityEnabled {
有了这些易访问性的改变,我们的应用程序对每个人来说都更好——干得好!
我想在换完一个之前再加一个。现在,如果你拖动一个图像,然后放开它,我们将它的偏移量设置回零,这会使它跳回屏幕中心。如果我们在卡片上附加一个弹簧动画,它会滑到中间,我想这会更清楚地告诉用户实际发生了什么。
要实现这一点,请将animation()
修饰符添加到CardView
中ZStack
的末尾,在ontapGeasure()
之后:
.animation(.spring())
好多了!
小贴士:如果你仔细看,你可能会注意到卡闪烁红色,如果你稍微向右拖动,然后释放。以后再说吧!
添加和删除卡片
到目前为止,我们所做的一切都使用了一组固定的示例卡,但是这个应用程序只有在用户能够定制他们看到的卡片列表时才变得有用。这意味着添加一个新的视图,该视图列出了所有现有的卡片,并允许用户添加一个新的卡片,这是您以前见过的所有内容。然而,这次有一个有趣的问题,需要一些新的东西来修正,所以这是值得的。
首先,我们需要一些状态来控制编辑屏幕是否可见。因此,将此添加到ContentView
中:
@State private var showingEditScreen = false
接下来,我们需要添加一个按钮来在点击时翻转布尔值,因此找到if differenceWithOutColor | | accessibilityEnabled
条件,并将其放在前面:
VStack {
HStack {
Spacer()
Button(action: {
self.showingEditScreen = true
}) {
Image(systemName: "plus.circle")
.padding()
.background(Color.black.opacity(0.7))
.clipShape(Circle())
}
}
Spacer()
}
.foregroundColor(.white)
.font(.largeTitle)
.padding()
我们将设计一个新的EditCards
视图,将一个Card
数组编码和解码为UserDefaults
,但是在我们这样做之前,我希望您使Card
结构体遵守Codable
,如下所示:
struct Card: Codable {
现在创建一个名为“EditCards”的新SwiftUI视图。这需要:
- 有自己的
Card
数组。 - 包装在一个
NavigationView
中,这样我们就可以添加一个“完成”按钮来关闭视图。 - 列出所有现有的卡片。
- 为那些卡添加删除的滑动手势。
- 在列表的顶部有一个section,以便用户可以添加新卡。
- 有方法从
UserDefaults
加载和保存数据。
我们之前已经写过了所有的代码,所以这里不再解释了。我希望你能停下来欣赏这意味着你已经走了多远!
将模板EditCards
结构体替换为:
struct EditCards: View {
@Environment(\.presentationMode) var presentationMode
@State private var cards = [Card]()
@State private var newPrompt = ""
@State private var newAnswer = ""
var body: some View {
NavigationView {
List {
Section(header: Text("Add new card")) {
TextField("Prompt", text: $newPrompt)
TextField("Answer", text: $newAnswer)
Button("Add card", action: addCard)
}
Section {
ForEach(0..<cards.count, id: \.self) { index in
VStack(alignment: .leading) {
Text(self.cards[index].prompt)
.font(.headline)
Text(self.cards[index].answer)
.foregroundColor(.secondary)
}
}
.onDelete(perform: removeCards)
}
}
.navigationBarTitle("Edit Cards")
.navigationBarItems(trailing: Button("Done", action: dismiss))
.listStyle(GroupedListStyle())
.onAppear(perform: loadData)
}
}
func dismiss() {
presentationMode.wrappedValue.dismiss()
}
func loadData() {
if let data = UserDefaults.standard.data(forKey: "Cards") {
if let decoded = try? JSONDecoder().decode([Card].self, from: data) {
self.cards = decoded
}
}
}
func saveData() {
if let data = try? JSONEncoder().encode(cards) {
UserDefaults.standard.set(data, forKey: "Cards")
}
}
func addCard() {
let trimmedPrompt = newPrompt.trimmingCharacters(in: .whitespaces)
let trimmedAnswer = newAnswer.trimmingCharacters(in: .whitespaces)
guard trimmedPrompt.isEmpty == false && trimmedAnswer.isEmpty == false else { return }
let card = Card(prompt: trimmedPrompt, answer: trimmedAnswer)
cards.insert(card, at: 0)
saveData()
}
func removeCards(at offsets: IndexSet) {
cards.remove(atOffsets: offsets)
saveData()
}
}
几乎所有的EditCards
都完成了,但是在使用它之前,我们需要向ContentView
添加更多代码,以便它按需显示工作表,并在关闭时调用resetCards()
。
将此sheet()
修饰符添加到ContentView
中最外层ZStack
的末尾:
.sheet(isPresented: $showingEditScreen, onDismiss: resetCards) {
EditCards()
}
除了在关闭工作表时调用resetCards()
之外,我们还希望在视图第一次出现时调用它,因此请在前一个修改器下面添加此修饰符:
.onAppear(perform: resetCards)
因此,当视图第一次显示时,将调用resetCards()
,而在EditCards
隐藏后显示时,也将调用·resetCards()`。这意味着我们可以丢弃示例卡数据,而是使其成为在运行时填充的空数组。
因此,将ContentView
的cards
属性更改为:
@State private var cards = [Card]()
为了完成ContentView
,我们需要让它按需加载cards
属性。这从我们刚刚在EditCard
中添加的代码开始,所以现在将此方法放入ContentView
中:
func loadData() {
if let data = UserDefaults.standard.data(forKey: "Cards") {
if let decoded = try? JSONDecoder().decode([Card].self, from: data) {
self.cards = decoded
}
}
}
现在,我们可以在resetCards()
中添加对loadData()
的调用,以便在应用程序启动或用户编辑卡片时用所有保存的卡片填充cards
属性:
func resetCards() {
timeRemaining = 100
isActive = true
loadData()
}
现在继续运行应用程序。我们已经删除了默认示例,因此您需要按+图标添加一些自己的示例。
即使我们所有的代码都是正确的,结果也不太可能是您所期望的:当您按+时,您会看到一个新的屏幕滑入,而它完全是空白的。我们设计了一个包含两个部分的不错的列表,一个导航视图,一个导航栏项,等等,但是我们得到的只是一个空白屏幕。
事实上,从我们的第一个项目开始,这里发生的事情就一直在发生,但有可能你没有注意到:当你将导航视图旋转到横向时,你会得到一个空白视图。这不是一个bug,事实上,它是SwiftUI试图变得更有用。
你看,在一些iphone上以横向模式运行时,iOS允许两个视图并排放置,左侧视图决定右侧视图显示的内容。当在纵向和横向之间移动时,我们可以自定义这两个视图的工作方式,但SwiftUI的默认设置是只显示右侧视图(详细视图),而在我们的示例中,我们实际上没有一个视图,这就是为什么我们会看到一个空白屏幕。
要解决这个问题,我们需要告诉NavigationView
一次只显示一个视图,这意味着它不会尝试显示不存在的详细视图。将此修饰符添加到EditCard
中的导航视图:
.navigationViewStyle(StackNavigationViewStyle())
现在,当你运行应用程序时,一切都应该按预期工作。老实说,这应该是默认设置,因为当前的默认设置非常混乱。不管怎样,我们的应用程序完成了——God Job!
译自
Making iPhones vibrate with UINotificationFeedbackGenerator
Fixing the bugs
Adding and deleting cards