第89天 项目 17 第四部分
在我们继续项目实现的过程中,你将了解到如何添加计时器来激励用户快速思考,如何在用户及时完成或未及时完成任务时结束应用,以及如何对布局进行一些简单调整,确保应用能很好地服务于红绿色盲用户。
尽管我们已在专门的辅助功能技术项目中探讨过辅助功能,但本次我们将聚焦于一项特定的辅助功能需求。每12名男性中就有1人患有色盲,这是一种极为常见的辅助功能需求。正如辅助功能倡导者黛布拉·鲁所说:“辅助功能能让我们发掘每个人的潜力。”
不要让你的代码因用户天生的生理特征而限制他们的使用。
今天你需要完成三个任务,包括为手势添加颜色编码、使用计时器显示进度等。
- 滑动时为视图着色
- 使用计时器倒计时
- 通过allowsHitTesting()结束应用
滑动时为视图着色
作者:Paul Hudson 2024年2月21日
用户可以左右滑动卡片,标记自己是否猜对答案,但目前这两个方向没有视觉上的区别。借鉴Tinder等约会应用的交互方式,我们设定向右滑动代表正确(用户猜对了答案),向左滑动代表错误(用户猜错了)。
我们将通过两种方式解决这个视觉区分问题:对于默认设置的手机,卡片在消失前会分别变成绿色(正确)或红色(错误);如果用户开启了“无颜色区分”设置,卡片将保持白色,转而在背景上显示一些额外的界面元素来区分。
首先,我们先对卡片本身进行初步处理。目前,我们的卡片视图背景是这样创建的:
RoundedRectangle(cornerRadius: 25)
.fill(.white)
.shadow(radius: 10)我们将用更复杂的代码替换这段代码:根据手势移动方向,给卡片背景设置绿色或红色的圆角矩形,然后随着拖动距离的增加,让上面的白色填充逐渐淡出。
首先处理背景。在shadow()修饰符之前直接添加以下代码:
.background(
RoundedRectangle(cornerRadius: 25)
.fill(offset.width > 0 ? .green : .red)
)至于白色填充的透明度,这与我们之前添加的opacity()修饰符类似,但我们将使用“1减去手势宽度的1/50”,而不是“2减去手势宽度”。这样能产生很好的效果:之前使用“2减去”是为了让卡片至少移动50个点后才开始淡出,而对于卡片填充,我们使用“1减去”,这样卡片一开始移动就能呈现出颜色变化。
用以下代码替换现有的fill()修饰符:
.fill(
.white
.opacity(1 - Double(abs(offset.width / 50)))
)现在运行应用,你会看到卡片从白色逐渐过渡到绿色或红色,之后再开始淡出。非常棒!
然而,尽管我们的代码效果不错,但对于红绿色盲用户来说并不友好——他们能看到卡片亮度的变化,但无法清楚区分滑动方向所代表的含义(正确或错误)。
为了解决这个问题,我们将添加一个环境属性,用于跟踪是否应该使用颜色来区分滑动方向。当该属性为true时,就禁用红绿色区分效果。
首先,在CardView的现有属性之前添加这个新属性:
@Environment(\.accessibilityDifferentiateWithoutColor) var accessibilityDifferentiateWithoutColor现在,我们可以在RoundedRectangle的填充和背景中使用这个属性,确保白色能平滑淡出。重要的是要在这两处都使用它,因为当卡片淡出时,背景颜色会逐渐透过填充显现出来。
因此,用以下代码替换当前的RoundedRectangle代码:
RoundedRectangle(cornerRadius: 25)
.fill(
accessibilityDifferentiateWithoutColor
? .white
: .white
.opacity(1 - Double(abs(offset.width / 50)))
)
.background(
accessibilityDifferentiateWithoutColor
? nil
: RoundedRectangle(cornerRadius: 25)
.fill(offset.width > 0 ? .green : .red)
)
.shadow(radius: 10)这样,在默认设置下,卡片会逐渐过渡到绿色或红色;而当“无颜色区分”功能开启时,就不会使用这种颜色区分方式。相反,我们需要在ContentView中添加一些额外的界面元素,让用户清楚哪个方向代表正确,哪个方向代表错误。
之前我们在ContentView中构建了一个特定的栈结构:最外层是ZStack,里面是VStack,VStack里面又有一个ZStack。最外层的这个ZStack能让背景和卡片栈重叠显示,我们还将在这个栈中添加一些按钮,让用户知道哪个方向代表“正确”。
首先,在ContentView中添加这个属性:
@Environment(\.accessibilityDifferentiateWithoutColor) var accessibilityDifferentiateWithoutColor然后在VStack后面直接添加以下新视图:
if accessibilityDifferentiateWithoutColor {
VStack {
Spacer()
HStack {
Image(systemName: "xmark.circle")
.padding()
.background(.black.opacity(0.7))
.clipShape(.circle)
Spacer()
Image(systemName: "checkmark.circle")
.padding()
.background(.black.opacity(0.7))
.clipShape(.circle)
}
.foregroundStyle(.white)
.font(.largeTitle)
.padding()
}
}这段代码创建了另一个VStack,其中开头的Spacer会将栈内的图像推到屏幕底部。通过外层的条件判断,这些图像只会在“无颜色区分”功能开启时显示,因此大多数情况下界面会保持简洁。
所有这些额外的工作都很有意义:它能确保无论用户有怎样的辅助功能需求,都能获得良好的使用体验,而这正是我们始终追求的目标。
使用计时器倒计时
作者:Paul Hudson 2024年9月25日
通过结合Foundation、SwiftUI和Combine框架,我们可以为应用添加计时器,给用户带来一定的紧迫感。实现一个简单的计时器并不需要太多工作,但其中存在一个小漏洞,需要额外处理才能修复。
在计时器的初步实现中,我们将创建两个新属性:一个是计时器本身(每秒触发一次),另一个是timeRemaining属性(每次计时器触发时,该属性的值减1)。这样我们就能显示当前应用运行剩余的秒数,从而温和地激励用户加快速度。
首先,在ContentView中添加这两个新属性:
@State private var timeRemaining = 100
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()这会给用户初始设置100秒的时间,然后创建并启动一个计时器,该计时器在主线程上每秒触发一次。
每当计时器触发时,我们要将timeRemaining的值减1,实现倒计时效果。我们本可以通过存储开始时间,然后计算当前时间与开始时间的差值来实现计时,但正如你将看到的,完全没有必要这样做!
在ContentView最外层的ZStack上添加onReceive()修饰符:
.onReceive(timer) { time in
if timeRemaining > 0 {
timeRemaining -= 1
}
}提示: 这里添加了一个简单的条件判断,确保timeRemaining的值不会变成负数。
这段代码会让计时器从100开始倒计时到0,但我们还需要将剩余时间显示出来。这很简单,只需在布局中添加一个文本视图,并给它设置深色背景,确保清晰可见。
将以下代码放入包含卡片ZStack的VStack中:
Text("Time: \(timeRemaining)")
.font(.largeTitle)
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 5)
.background(.black.opacity(0.75))
.clipShape(.capsule)如果放置位置正确,你的布局代码应该如下所示:
ZStack {
Image(.background)
.resizable()
.ignoresSafeArea()
VStack {
Text("Time: \(timeRemaining)")
.font(.largeTitle)
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 5)
.background(.black.opacity(0.75))
.clipShape(.capsule)
ZStack {现在你可以运行应用并尝试使用——看起来运行得还不错,对吧?不过,这里存在一个小问题:
- 查看计时器当前显示的数值。
- 按下Cmd+Shift+H组合键返回主屏幕。
- 等待大约10秒。
- 点击应用图标返回应用。
- 此时计时器显示的数值是多少?
我发现,计时器显示的数值会比之前在应用中看到的数值低大约3秒——这是因为计时器在后台会继续运行几秒钟,然后暂停,直到应用重新回到前台。
我们可以做得更好:我们可以检测应用何时进入后台或前台,然后相应地暂停和重启计时器。
首先,添加两个属性来记录应用当前是否处于活跃状态:
@Environment(\.scenePhase) var scenePhase
@State private var isActive = true我们设置两个属性是因为,环境属性scenePhase能告诉我们应用在可见性方面是活跃还是非活跃状态;但当用户刷完所有闪卡后,即使从scenePhase的角度看应用处于活跃状态,我们也会将应用视为非活跃状态,不再让计时器继续计时。
现在,在现有的onReceive()修饰符下方添加onChange()修饰符:
.onChange(of: scenePhase) {
if scenePhase == .active {
isActive = true
} else {
isActive = false
}
}最后,修改onReceive(timer)函数,让它在isActive为false时立即退出,代码如下:
.onReceive(timer) { time in
guard isActive else { return }
if timeRemaining > 0 {
timeRemaining -= 1
}
}只需这一小处修改,当应用进入后台时,计时器就会自动暂停——我们再也不会遇到“莫名丢失几秒”的情况了。
通过allowsHitTesting()结束应用
作者:Paul Hudson 2024年5月1日
SwiftUI允许我们通过将allowsHitTesting()设置为false来禁用视图的交互功能。因此,在我们的项目中,我们可以通过检查timeRemaining的值,在时间耗尽时禁用卡片的滑动交互。
首先,在显示卡片栈的最内层ZStack上添加这个修饰符:
.allowsHitTesting(timeRemaining > 0)这样,当timeRemaining的值大于等于1时,启用点击测试(即允许交互);当timeRemaining的值为0时,禁用点击测试(即禁止交互),因为此时用户已经没有时间了。
另一种情况是,用户快速正确地刷完了所有卡片,最终没有卡片剩余。当最后一张卡片消失后,目前计时器会滑到屏幕中央,并继续计时。我们希望实现的效果是:计时器停止,让用户看到自己完成的速度,同时显示一个按钮,允许用户重置卡片并重新开始。
要实现这个效果需要稍加思考,因为仅仅将isActive设置为false是不够的——如果应用进入后台后又回到前台,即使没有卡片剩余,isActive也会重新被启用。
我们一步步来解决这个问题。首先,我们需要一个方法来重置应用,让用户可以重新开始。在ContentView中添加以下方法:
func resetCards() {
cards = Array<Card>(repeating: .example, count: 10)
timeRemaining = 100
isActive = true
}其次,我们需要一个按钮来触发这个重置方法,并且该按钮只在所有卡片都被移除后显示。将以下代码添加到最内层ZStack之后,也就是allowsHitTesting()修饰符的下方:
if cards.isEmpty {
Button("Start Again", action: resetCards)
.padding()
.background(.white)
.foregroundStyle(.black)
.clipShape(.capsule)
}现在我们已经有了重置卡片时重启计时器的代码,但还需要在最后一张卡片被移除时停止计时器,并且确保应用从后台返回时计时器保持停止状态。
要解决第一个问题(停止计时器),在removeCard(at:)方法的末尾添加以下代码:
if cards.isEmpty {
isActive = false
}至于第二个问题(确保从后台返回时计时器保持停止状态),我们只需更新scenePhase相关的代码,让它明确检查是否有卡片剩余:
.onChange(of: scenePhase) {
if scenePhase == .active {
if cards.isEmpty == false {
isActive = true
}
} else {
isActive = false
}
}这样就完成了!