给滑动的视图着色
用户可以向左或向右滑动我们的卡片,以将其标记为正确猜对与否,但这两个方向之间没有视觉上的区别。从 探探 等约会应用中借用控件,我们将向右滑动(他们正确猜出了答案),向左滑动(猜错误)。
我们将通过两种方式解决此问题:对于具有默认设置的手机,我们将在褪色之前使卡片变成绿色或红色,但是如果用户启用了无色区分设置(辅助功能内),我们将卡片保留为白色和白色。而是在我们的背景上显示一些额外的UI。
让我们从卡片本身的开始。现在,我们的卡片视图就是在这种背景下创建的:
RoundedRectangle(cornerRadius: 25, style: .continuous)
.fill(Color.white)
.shadow(radius: 10)
我们将用一些更高级的代码替换它:我们将为它提供一个具有相同圆角矩形的背景,除了绿色或红色(取决于手势移动)外,然后使上方的白色填充在拖动运动时变大淡出。
首先是背景。将其直接添加到shadow()
修饰符之前:
.background(
RoundedRectangle(cornerRadius: 25, style: .continuous)
.fill(offset.width > 0 ? Color.green : Color.red)
)
至于白色填充的不透明度,这将与我们之前添加的opacity()
修饰符相似,除了我们将使用1减去手势宽度的1/50而不是2减去手势宽度。这会产生一个非常不错的效果:我们之前使用了2减,因为这意味着该卡在褪色之前必须至少移动50个点,但是对于卡填充,我们将使用1减,以便它开始立即变为彩色。
以此替换现有的fill()
修饰符:
.fill(
Color.white
.opacity(1 - Double(abs(offset.width / 50)))
)
如果现在运行该应用程序,您会看到卡片从白色融合为红色或绿色,然后逐渐消失。太棒了!
但是,尽管我们的代码很好,但对于有红/绿盲的人来说,效果并不好——他们会看到卡的亮度发生变化,但不清楚是哪面。
为了解决这个问题,我们将添加一个环境属性以跟踪是否为此目的使用颜色,然后在该属性为 true 时禁用红色/绿色效果。
首先,在现有属性之前,将这个新属性添加到CardView
中:
@Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor
现在,我们可以将其用于RoundedRectangle
的填充和背景,以确保我们平滑地淡化白色。两者都必须使用,这很重要,因为随着卡片淡出,背景色将开始从填充中扩散。
因此,用以下代码替换当前的RoundedRectangle
代码:
RoundedRectangle(cornerRadius: 25, style: .continuous)
.fill(
differentiateWithoutColor
? Color.white
: Color.white
.opacity(1 - Double(abs(offset.width / 50)))
)
.background(
differentiateWithoutColor
? nil
: RoundedRectangle(cornerRadius: 25, style: .continuous)
.fill(offset.width > 0 ? Color.green : Color.red)
)
.shadow(radius: 10)
因此,在默认配置下,我们的卡片会逐渐变为绿色或红色,但是在启用“无色差异”后,将不会使用。取而代之的是,我们需要在ContentView
中提供一些额外的UI,以明确哪一方是正确的,哪一方是错误的。
之前,我们在ContentView
中制作了一个非常特殊的堆栈结构:我们有一个ZStack
,然后是VStack
,然后是另一个ZStack
。第一个ZStack
是最外层的ZStack
,它使我们的背景和卡片叠层重叠,并且我们还将在该叠层中放置一些按钮,以便用户可以看到哪一侧是“好”的。
首先,将此属性添加到ContentView
:
@Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor
现在,在VStack
之后直接添加以下新视图:
if differentiateWithoutColor {
VStack {
Spacer()
HStack {
Image(systemName: "xmark.circle")
.padding()
.background(Color.black.opacity(0.7))
.clipShape(Circle())
Spacer()
Image(systemName: "checkmark.circle")
.padding()
.background(Color.black.opacity(0.7))
.clipShape(Circle())
}
.foregroundColor(.white)
.font(.largeTitle)
.padding()
}
}
这将创建另一个VStack
,这次是从一个间隔符开始的,以便将堆栈中的图像推到屏幕底部。围绕着这些条件,它们只有在启用“无色差异”时才会显示,因此大多数时候我们的用户界面保持清晰。
所有这些额外的工作都很重要:无论用户的可访问性需求如何,它都可以确保用户获得出色的体验,而这正是我们应一直追求的目标。
用 Timer 倒计时
如果我们将 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,以便倒计时。我们可以在这里尝试一些日期数学,方法是存储开始日期并显示该日期与当前日期之间的差额,但是您确实会发现确实没有必要!
将此onReceive()
修饰符添加到ContentView
中最外面的ZStack
中:
.onReceive(timer) { time in
if self.timeRemaining > 0 {
self.timeRemaining -= 1
}
}
提示:这增加了一个很小的条件,以确保我们永远不会录入负数。
该代码使我们的计时器从100开始,使其倒数至0,但实际上需要显示它。这就像在我们的布局中添加另一个文本视图一样简单,这次使用深色背景色来确保它清晰可见。
将其放入和包含卡片的ZStack
的同一个VStack
中:
Text("Time: \(timeRemaining)")
.font(.largeTitle)
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 5)
.background(
Capsule()
.fill(Color.black)
.opacity(0.75)
)
如果你代码写对了,那么应该是这样的预览效果:
您应该可以立即运行该应用并尝试一下——它运行良好,对吗?嗯,有一个小问题:
- 看一下计时器中的当前值。
- 按Cmd + H返回主屏幕。
- 等待大约十秒钟。
- 现在点击您应用的图标以返回到该应用。
- 计时器显示什么时间?
我发现计时器显示的值比以前在应用程序中时的值低约三秒钟——计时器在后台运行几秒钟,然后暂停直到应用程序返回。
我们可以做得更好:我们可以检测到应用何时移至后台或前台,然后暂停并适当地重启计时器。
首先,添加此属性以存储该应用程序当前是否处于活动状态:
@State private var isActive = true
接下来,我们需要在前一个onReceive()
修饰符下方添加两个onReceive()
修饰符,以在应用程序往返于后台时操纵isActive
。对于这些,我们可以捕获UIApplication.willResignActiveNotification
和UIApplication.willEnterForegroundNotification
通知,如下所示:
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in
self.isActive = false
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
self.isActive = true
}
最后,修改onReceive(timer)
函数,以便isActive
为 false 时立即退出,如下所示:
.onReceive(timer) { time in
guard self.isActive else { return }
if self.timeRemaining > 0 {
self.timeRemaining -= 1
}
}
有了很小的改动,计时器就会在应用程序移至后台时自动暂停——我们不再失去任何神秘的秒数。
思考:这样做真的好吗?
以allowHitTesting()
结束应用
SwiftUI通过将allowHitTesting()
设置为false来禁用视图的交互性,因此在我们的项目中,当时间用完时,我们可以使用它通过检查timeRemaining
的值来禁用任何卡上的滑动操作。
首先将此修饰符添加到最里面的ZStack
——显示我们的卡片堆栈的那个:
.allowsHitTesting(timeRemaining > 0)
当timeRemaining
为1或更大时,这将启用点击处理,但是如果用户没有时间,则将其设置为 false。
另一个结果是,用户正确地划过所有卡,最后一无所有。当最后一张卡消失时,现在我们的计时器向下滑动到屏幕中央,并继续滴答作响。我们想要发生的是使计时器停止运行,以便用户可以看到自己的运行速度,并显示一个按钮,允许他们重置卡并重试。
这需要一些思考,因为仅将isActive
设置为 false 是不够的——如果应用程序移至后台并返回,即使没有剩余卡,isActive
也将重新变成 true。
让我们逐步解决它。首先,我们需要一种方法来重置应用程序,以便用户可以重试,因此请将其添加到ContentView
中:
func resetCards() {
cards = [Card](repeating: Card.example, count: 10)
timeRemaining = 100
isActive = true
}
其次,我们需要一个按钮来触发它,仅在所有卡都被移除后才显示。将其放在最里面的ZStack
之后,在allowHitTesting()
修饰符下面:
if cards.isEmpty {
Button("Start Again", action: resetCards)
.padding()
.background(Color.white)
.foregroundColor(.black)
.clipShape(Capsule())
}
现在我们有了代码来重置卡时重新启动计时器,但是现在我们需要在移除最后一张卡时停止计时器——并确保回到前台时它保持停止状态。
我们可以通过将这段代码添加到removeCard(at:)
方法的末尾来解决第一个问题:
if cards.isEmpty {
isActive = false
}
至于第二个问题——确保isActive
从后台返回时保持 false —— 我们应该只更新附加到willEnterForegroundNotification
的函数,以便它显式检查卡片数量:
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
if self.cards.isEmpty == false {
self.isActive = true
}
}
运行试一下吧!
译自
Coloring views as we swipe
Counting down with a Timer
Ending the app with allowsHitTesting()
网友评论