Skip to content

第89天 项目 17 第四部分

在我们继续项目实现的过程中,你将了解到如何添加计时器来激励用户快速思考,如何在用户及时完成或未及时完成任务时结束应用,以及如何对布局进行一些简单调整,确保应用能很好地服务于红绿色盲用户。

尽管我们已在专门的辅助功能技术项目中探讨过辅助功能,但本次我们将聚焦于一项特定的辅助功能需求。每12名男性中就有1人患有色盲,这是一种极为常见的辅助功能需求。正如辅助功能倡导者黛布拉·鲁所说:“辅助功能能让我们发掘每个人的潜力。”

不要让你的代码因用户天生的生理特征而限制他们的使用。

今天你需要完成三个任务,包括为手势添加颜色编码、使用计时器显示进度等。

  • 滑动时为视图着色
  • 使用计时器倒计时
  • 通过allowsHitTesting()结束应用

滑动时为视图着色

作者:Paul Hudson 2024年2月21日

用户可以左右滑动卡片,标记自己是否猜对答案,但目前这两个方向没有视觉上的区别。借鉴Tinder等约会应用的交互方式,我们设定向右滑动代表正确(用户猜对了答案),向左滑动代表错误(用户猜错了)。

我们将通过两种方式解决这个视觉区分问题:对于默认设置的手机,卡片在消失前会分别变成绿色(正确)或红色(错误);如果用户开启了“无颜色区分”设置,卡片将保持白色,转而在背景上显示一些额外的界面元素来区分。

首先,我们先对卡片本身进行初步处理。目前,我们的卡片视图背景是这样创建的:

swift
RoundedRectangle(cornerRadius: 25)
    .fill(.white)
    .shadow(radius: 10)

我们将用更复杂的代码替换这段代码:根据手势移动方向,给卡片背景设置绿色或红色的圆角矩形,然后随着拖动距离的增加,让上面的白色填充逐渐淡出。

首先处理背景。在shadow()修饰符之前直接添加以下代码:

swift
.background(
    RoundedRectangle(cornerRadius: 25)
        .fill(offset.width > 0 ? .green : .red)
)

至于白色填充的透明度,这与我们之前添加的opacity()修饰符类似,但我们将使用“1减去手势宽度的1/50”,而不是“2减去手势宽度”。这样能产生很好的效果:之前使用“2减去”是为了让卡片至少移动50个点后才开始淡出,而对于卡片填充,我们使用“1减去”,这样卡片一开始移动就能呈现出颜色变化。

用以下代码替换现有的fill()修饰符:

swift
.fill(
    .white
        .opacity(1 - Double(abs(offset.width / 50)))
)

现在运行应用,你会看到卡片从白色逐渐过渡到绿色或红色,之后再开始淡出。非常棒!

然而,尽管我们的代码效果不错,但对于红绿色盲用户来说并不友好——他们能看到卡片亮度的变化,但无法清楚区分滑动方向所代表的含义(正确或错误)。

为了解决这个问题,我们将添加一个环境属性,用于跟踪是否应该使用颜色来区分滑动方向。当该属性为true时,就禁用红绿色区分效果。

首先,在CardView的现有属性之前添加这个新属性:

swift
@Environment(\.accessibilityDifferentiateWithoutColor) var accessibilityDifferentiateWithoutColor

现在,我们可以在RoundedRectangle的填充和背景中使用这个属性,确保白色能平滑淡出。重要的是要在这两处都使用它,因为当卡片淡出时,背景颜色会逐渐透过填充显现出来。

因此,用以下代码替换当前的RoundedRectangle代码:

swift
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,里面是VStackVStack里面又有一个ZStack。最外层的这个ZStack能让背景和卡片栈重叠显示,我们还将在这个栈中添加一些按钮,让用户知道哪个方向代表“正确”。

首先,在ContentView中添加这个属性:

swift
@Environment(\.accessibilityDifferentiateWithoutColor) var accessibilityDifferentiateWithoutColor

然后在VStack后面直接添加以下新视图:

swift
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中添加这两个新属性:

swift
@State private var timeRemaining = 100
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

这会给用户初始设置100秒的时间,然后创建并启动一个计时器,该计时器在主线程上每秒触发一次。

每当计时器触发时,我们要将timeRemaining的值减1,实现倒计时效果。我们本可以通过存储开始时间,然后计算当前时间与开始时间的差值来实现计时,但正如你将看到的,完全没有必要这样做!

ContentView最外层的ZStack上添加onReceive()修饰符:

swift
.onReceive(timer) { time in
    if timeRemaining > 0 {
        timeRemaining -= 1
    }
}

提示: 这里添加了一个简单的条件判断,确保timeRemaining的值不会变成负数。

这段代码会让计时器从100开始倒计时到0,但我们还需要将剩余时间显示出来。这很简单,只需在布局中添加一个文本视图,并给它设置深色背景,确保清晰可见。

将以下代码放入包含卡片ZStackVStack中:

swift
Text("Time: \(timeRemaining)")
    .font(.largeTitle)
    .foregroundStyle(.white)
    .padding(.horizontal, 20)
    .padding(.vertical, 5)
    .background(.black.opacity(0.75))
    .clipShape(.capsule)

如果放置位置正确,你的布局代码应该如下所示:

swift
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 {

现在你可以运行应用并尝试使用——看起来运行得还不错,对吧?不过,这里存在一个小问题:

  1. 查看计时器当前显示的数值。
  2. 按下Cmd+Shift+H组合键返回主屏幕。
  3. 等待大约10秒。
  4. 点击应用图标返回应用。
  5. 此时计时器显示的数值是多少?

我发现,计时器显示的数值会比之前在应用中看到的数值低大约3秒——这是因为计时器在后台会继续运行几秒钟,然后暂停,直到应用重新回到前台。

我们可以做得更好:我们可以检测应用何时进入后台或前台,然后相应地暂停和重启计时器。

首先,添加两个属性来记录应用当前是否处于活跃状态:

swift
@Environment(\.scenePhase) var scenePhase
@State private var isActive = true

我们设置两个属性是因为,环境属性scenePhase能告诉我们应用在可见性方面是活跃还是非活跃状态;但当用户刷完所有闪卡后,即使从scenePhase的角度看应用处于活跃状态,我们也会将应用视为非活跃状态,不再让计时器继续计时。

现在,在现有的onReceive()修饰符下方添加onChange()修饰符:

swift
.onChange(of: scenePhase) {
    if scenePhase == .active {
        isActive = true
    } else {
        isActive = false
    }
}

最后,修改onReceive(timer)函数,让它在isActive为false时立即退出,代码如下:

swift
.onReceive(timer) { time in
    guard isActive else { return }

    if timeRemaining > 0 {
        timeRemaining -= 1
    }
}

只需这一小处修改,当应用进入后台时,计时器就会自动暂停——我们再也不会遇到“莫名丢失几秒”的情况了。

通过allowsHitTesting()结束应用

作者:Paul Hudson 2024年5月1日

SwiftUI允许我们通过将allowsHitTesting()设置为false来禁用视图的交互功能。因此,在我们的项目中,我们可以通过检查timeRemaining的值,在时间耗尽时禁用卡片的滑动交互。

首先,在显示卡片栈的最内层ZStack上添加这个修饰符:

swift
.allowsHitTesting(timeRemaining > 0)

这样,当timeRemaining的值大于等于1时,启用点击测试(即允许交互);当timeRemaining的值为0时,禁用点击测试(即禁止交互),因为此时用户已经没有时间了。

另一种情况是,用户快速正确地刷完了所有卡片,最终没有卡片剩余。当最后一张卡片消失后,目前计时器会滑到屏幕中央,并继续计时。我们希望实现的效果是:计时器停止,让用户看到自己完成的速度,同时显示一个按钮,允许用户重置卡片并重新开始。

要实现这个效果需要稍加思考,因为仅仅将isActive设置为false是不够的——如果应用进入后台后又回到前台,即使没有卡片剩余,isActive也会重新被启用。

我们一步步来解决这个问题。首先,我们需要一个方法来重置应用,让用户可以重新开始。在ContentView中添加以下方法:

swift
func resetCards() {
    cards = Array<Card>(repeating: .example, count: 10)
    timeRemaining = 100
    isActive = true
}

其次,我们需要一个按钮来触发这个重置方法,并且该按钮只在所有卡片都被移除后显示。将以下代码添加到最内层ZStack之后,也就是allowsHitTesting()修饰符的下方:

swift
if cards.isEmpty {
    Button("Start Again", action: resetCards)
        .padding()
        .background(.white)
        .foregroundStyle(.black)
        .clipShape(.capsule)
}

现在我们已经有了重置卡片时重启计时器的代码,但还需要在最后一张卡片被移除时停止计时器,并且确保应用从后台返回时计时器保持停止状态

要解决第一个问题(停止计时器),在removeCard(at:)方法的末尾添加以下代码:

swift
if cards.isEmpty {
    isActive = false
}

至于第二个问题(确保从后台返回时计时器保持停止状态),我们只需更新scenePhase相关的代码,让它明确检查是否有卡片剩余:

swift
.onChange(of: scenePhase) {
    if scenePhase == .active {
        if cards.isEmpty == false {
            isActive = true
        }
    } else {
        isActive = false
    }
}

这样就完成了!

本站使用 VitePress 制作