2024-10-23 16:19:47
,某些文章具有时效性,若有错误或已失效,请在下方留言。我们将制作一个 macOS 应用程序,帮助你记住圆周率的数字。请创建一个新的 macOS 项目,并命名为 SliceOfPi
。
足够的数据
记忆圆周率最简单的方法是从字母表中为每个数字指定一个或者多个字母,然后根据这些字母为自己编写一个故事。
- 数字 3 可以说用字母 A、B 或 C 开头的单词来表示。
- 1 可以用字母 D、E 或 F 开头的单词表示。
- 2 可以是 G、H 或 I。
- 3 可以是 J、K、L
- 4 可以是 M、N 或 O
以此类推。因此,3.141 可以表示为 “Leopards Eat Many Fish”。据我们所知圆周率中的数字都是以 10 为基数平均表示的,但当然英语中的字母频率是变化的-你应该把 Q、Z 和 X 这样的字母放到较大的组中,而 E、T、A、O、I 和 N 这样的字母应该放在较小的、分散的组中。
因此,这个 app 将要求用户为每个数字分配一组字母,然后为π的每个数字分配单词。它会自动检查每个字母是否飞陪给一个数位,并根据字母与数位的映射关系检查用户使用的每个单词是否有效。
总之,为了启动这个应用程序,我们先要创建一个数据模型,使代码能够正常工作,然后再创建一个用户界面来匹配数据模型,最后再回到数据模型的实际工作上来。
首先将 assets
目录中的文件,拷贝到项目中
pi5000.txt
包含小数点后 5000 位的 pi 值Bundle-Decodable.swift
常用的 Swift 扩展,主要是从app bundle
中加载文件。
创建一个名为 ViewModel.swift
的 swift 文件,将 Foundation
改为 SwiftUI
,然后给出以下代码
import SwiftUI
@Observable
class ViewModel {
let pi = Array(Bundle.main.decode("pi5000.txt", as: String.self))
var keys: [String]
var answers: [String]
init() {
keys = Array(repeating: "", count: 10)
answers = Array(repeating: "", count: pi.count)
}
}
提示: 这样可以将 pi
存储为字符数组,从而保持准确性。请勿使用 Double
。
通过在 ContentView
中添加添加一个新属性,我们可以立即投入使用。
@State private var viewModel = ViewModel()
现在我们可以开始用户界面了。这部分分为两部分:
- 顶部的区域要求用户为 0 到 9 之间的每个数字填写字母
- 底部的滚动区域要求用户为圆周率的数字指定单词
所以,我们可以从这里开始:
VStack {
Text("0 through 9")
Divider()
.padding(.vertical)
Text("Digits in pi")
}
.padding()
.navigationTitle("Slice of Pi")
效果如下所示
首先,顶部区域需要列出从 0 到 9 的所有数字,下面的文本框可以让用户为每个数字指定字母。
因此,我们将在视图模型中循环遍历整个keys
数组,并显示每个数字及其值。将第一个 Text 视图替换为
HStack {
ForEach(0..<viewModel.keys.count, id: \.self) { i in
VStack {
Text(String(i))
TextField(String(i), text: $viewModel.keys[i])
}
.font(.title2)
}
}
效果如下所示
第二个文本视图将替换为一个滚动视图,其中包含我们加载的所有圆周率数字。是的,全部 5000 个数字都没问题–我们将使其成为一个LazyVStack
,以避免一次性加载所有内容。
我们将 pi 存储为字符数组,以保持完全准确,这意味着当我们循环查看每个字母时,我们会得到 “3”、”.”、”1 “等值,所有这些都是字符。我们需要在keys数组中查找这些字符,以确定应该使用哪些字母,因此我们要将每个字符转换为与其匹配的整数:3 “就变成了 3。
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(0..<viewModel.pi.count, id: \.self) { i in
HStack {
let character = String(viewModel.pi[i])
let digit = Int(character) ?? 0
// more code to come
}
.font(.title3)
}
}
}
代码运行时,character
将以字符串的形式包含我们正在读取的当前 pi 数,digit
将以整数的形式包含相同的值。
我们需要这两个代码来代替 // more code to come
注释。
Text(character)
.monospacedDigit()
如果字符不是.
,我想用户可以自己记住这一点!- 我们将在后面添加一个文本字段,并将其绑定到 answers
数组中的相应值。将此代码放在前面的代码之后:
if character != "." {
TextField("Enter a word", text: $viewModel.answers[i])
.textFieldStyle(.roundedBorder)
}
这样做是可行的,但我们可以做得更好:我们知道这一行代表什么整数值,因为它存储在我们创建的digit
常量中。如果我们在视图模型的键数组中查找该值,就可以将其用于文本字段提示,这样用户就能准确地看到当前单词应使用哪些字母:
TextField(viewModel.keys[digit], text: $viewModel.answers[i])
效果如下所示
添加一些色彩
我们需要在用户添加的记忆单词与分配给每个数字的字母不匹配时,让用户知道。
我们将通过两个新方法来实现这一目标,因此我们可以先在ViewModel
中添加两个方法签名。
func color(forKey index: Int) -> Color {
}
func color(forAnswer index: Int) -> Color {
}
因此在开始填充方法之前,我想在视图模型中添加两种颜色,以表示输入的好坏:
let goodColor = Color.primary
let badColor = Color.red
好了,让我们开始填写color(forKey:)
。如果按键框中的字母已被分配给其他数字,则返回badColor
,这样就可以防止用户将 “a “同时分配给 3 和 5。
这需要花点心思,因为每个数字可以有多个字母。因此,这种方法的第一步就是阅读目前分配给该数字的文字,确保文字小写,以避免出现问题:
let currentValue = keys[index].lowercased()
提示:使用lowercased()
可避免字母大小写方面的意外。
既然我们已经知道用户将字母 “PQB “分配给了这个数字,接下来的工作就是循环查看该字符串中的每个字符,并检查它是否存在于其他键中。一种简单的方法是计算所有包含该字母的键,并确保结果不大于 1。
因此,请在前面的代码下面添加以下内容:
for letter in currentValue {
let matches = keys.filter {
$0.lowercased().contains(letter)
}
if matches.count > 1 {
return badColor
}
}
如果数组结束时没有返回任何内容,就说明这个键没有问题,因此我们可以像这样发回goodColor
:
return goodColor
这是该方法的一个良好开端,但我想做一个改进,确保用户在这里只输入字母。
这意味着要在ViewModel
中添加一个新属性,以跟踪我们允许使用的字母:
let alphabet = "abcdefghijklmnopqrstuvwxyz"
然后更新color(forKey:)
循环,以便在运行keys.filter()
方法之前检查每个字母是否在字母表中:
guard alphabet.contains(letter) else {
return badColor
}
第二个方法是color(forAnswer:)
,它的任务是验证一个 answer
字符串是否以用户在屏幕顶部按键中设置的相应字母开头。
让我们从两行简单的代码开始,一步一步地完成这项工作:我们需要为他们输入的任何答案行读取 pi 的正确数字,并将其转换为整数,就像我们在 SwiftUI 视图代码中所做的那样。
方法以这两行开始
let character = String(pi[index])
let digit = Int(character) ?? 0
接下来,我们要读取用户为当前数字指定的所有起始字母。因此,用户可能会说数字 4 可以用字母 B、C 和 K 表示:
let validStartLetters = keys[digit].lowercased()
我们还想读出他们为当前答案指定的任何单词:
let currentAnswer = answers[index].lowercased()
现在是重要的部分:我们知道这个单词应以哪些字母开头,也知道他们输入的单词,因此我们的工作就是循环查看每个有效的开头字母,并检查他们输入的单词是否使用了其中的一个字母。如果是,那么这个单词就是好的,因此我们可以返回goodColor
:
for letter in validStartLetters {
if currentAnswer.starts(with: String(letter)) {
return goodColor
}
}
果循环结束后没有返回,则表示他们的单词使用了无效的起始字母,因此我们可以通过返回badColor
来结束该方法:
return badColor
这样,这两个方法就完成了,我们可以直接在 SwiftUI 代码中使用它们。首先,修改文本字段的键值:
TextField(String(i), text: $viewModel.keys[i])
.foregroundStyle(viewModel.color(forKey: i))
然后修改 answer 文本字段:
TextField(viewModel.keys[digit], text: $viewModel.answers[i])
.textFieldStyle(.roundedBorder)
.foregroundStyle(viewModel.color(forAnswer: i))
-你现在可以为每个数字指定一个或多个字母,然后为圆周率的数字填写单词。如果你弄错了, 应用程序会用红色标记出你的错误,所以很容易纠正。
加载、保存以及验证
首先,我们的代码并不能保存和加载他们所做的所有工作,所以即使他们把东西记得很熟,下次启动应用程序时也需要重新输入所有值。
为了巧妙地解决这个问题,我们可以将用户的keys
和answers
转换成 JSON 格式,并将其存储在应用程序的文档目录中,从而使视图模型自动加载和保存用户所做的每次更改。
我们可以先在ViewModel
中添加两个新属性,以存储我们要读写的 URL:
let keysURL = URL.documentsDirectory.appending(path: "keys.json")
let answersURL = URL.documentsDirectory.appending(path: "answers.json")
然后,我们可以更新初始化程序,使其在使用默认值之前总是尝试加载任何已保存的数据:
init() {
do {
let keysData = try Data(contentsOf: keysURL)
let answersData = try Data(contentsOf: answersURL)
keys = try JSONDecoder().decode([String].self, from: keysData)
answers = try JSONDecoder().decode([String].self, from: answersData)
} catch {
keys = Array(repeating: "", count: 10)
answers = Array(repeating: "", count: pi.count)
}
}
最后,我们可以添加一个save()
方法,将更改写回这些文件:
func save() {
do {
let encodedKeys = try JSONEncoder().encode(keys)
let encodedAnswers = try JSONEncoder().encode(answers)
try encodedKeys.write(to: keysURL)
try encodedAnswers.write(to: answersURL)
} catch {
print(error.localizedDescription)
}
}
们需要在keys
或answers
数组发生变化时调用save()
,这就像添加didSet
属性观察者一样简单:
var keys: [String] { didSet { save() } }
var answers: [String] { didSet { save() } }
提示:如果担心性能问题,可以在调用save() 之前增加 1 秒延迟。
我想解决的第二件事是用户可以在TextField
中输入什么内容。这个程序的性质决定了用户只能输入字母。
这需要两个步骤,首先是自定义解析策略。解析策略(ParseStrategy
)是Foundation
框架中的一个协议,它可以使用你想要的任何逻辑将一种类型的数据转换成另一种类型的数据。
因此,创建一个名为 AlphabetOnly.swift
的新文件,并为其添加以下代码
struct AlphabetOnlyStrategy: ParseStrategy {
var allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',.?! "
// more code to come
}
其中列出了我们希望允许使用的所有字母:大写和小写字母,以及用户可能希望使用的一些标点符号。
ParseStrategy
协议希望实现一个方法,该方法的任务是将某种输入值解析为某种输出值。在我们的解析器中,我们希望接受用户输入的字符串,但同时也希望返回一个字符串–我们将发送回输入值,只是去掉了所有无效字符。
最简单的写法是创建一个包含结果的空字符串,然后循环处理每个输入字母,将所有有效字母添加到结果字符串中,然后将结果发送回来:
func parse(_ value: String) -> String {
var result = ""
for letter in value {
if allowed.contains(letter) {
result.append(letter)
}
}
return result
}
不过,这种过滤字符串的过程以filter()
方法的形式直接内置在 Swift 标准库中:如果我们对输入字符串调用filter()
,Swift 会一次输入一个字母,我们可以通过allowed.contains()
将其传递出去,就像这样:
func parse(_ value: String) -> String {
value.filter { allowed.contains($0) }
}
filter()
方法希望得到一个函数,它能接受字符串中的一个字母,如果该字母应包含在输出中,则返回 true
。这正是allowed.contains()
所做的事情–它从我们的值中接受一个字母,如果该字母应该包含在内,则返回 true
。
value.filter(allowed.contains)
同样,解析策略的任务是将某种格式化数据转换为原始数据类型,例如将日期字符串转换为日期实例。
SwiftUI 还喜欢反其道而行之:将日期实例格式化为用户可以读取的格式。这是由另一个名为FormatStyle
的协议处理的,它负责将原始数据类型转换为用户可读的字符串。不过,还有第二个协议叫做ParseableFormatStyle
,它继承于FormatStyle
,也包含一个ParseStrategy
,因此可以读写格式化的值。
这个更大的协议就是我们要实施的协议,它有两个要求:
- 解析策略,用于将格式化文本转换为原始数据。
format()
方法,用于将原始数据转换为格式化文本。
实际上,我们的代码中没有进行任何转换,因此我们可以使用自定义ParseStrategy
实现这两种转换。
struct AlphabetOnlyFormatStyle: ParseableFormatStyle {
var parseStrategy = AlphabetOnlyStrategy()
func format(_ value: String) -> String {
parseStrategy.parse(value)
}
}
这就完成了解析策略和格式样式,但我还想补充一点:一点辅助代码,使自定义格式更容易访问。
extension FormatStyle where Self == AlphabetOnlyFormatStyle {
static var alphabetOnly: AlphabetOnlyFormatStyle {
AlphabetOnlyFormatStyle()
}
}
这样,我们就可以在每次需要使用新格式器时写入.alphabetOnly
而不是AlphabetOnlyFormatStyle
。
让我们现在就开始使用它,将新的格式器附加到ContentView
中的两个文本字段。这意味着要使用稍有不同的初始化器:我们必须指定 value
,然后附加格式器,而不是使用文本。
因此,对于关键文本字段,我们可以得到这样的结果:
TextField(String(i), value: $viewModel.keys[i], format: .alphabetOnly)
.foregroundStyle(viewModel.color(forKey: i))
然后,在答案文本框中,我们会得到以下结果:
TextField(viewModel.keys[digit],value: $viewModel.answers[i], format: .alphabetOnly)
这并不能阻止用户在文本字段中输入无效字符,但这意味着一旦用户按下回车键或离开文本字段,他们的输入就会被合理化。
升级完成后,现在就可以从color(forKey:) 中移除这个检查了,因为它不会再起任何作用:
guard alphabet.contains(letter) else {
return badColor
}
效果如下所示
增加一些改进
在应用程序完成之前,我们还将增加三个功能:
- 让他们把完成的记忆文字复制到剪贴板上,这样他们就可以把它带到任何想去的地方。
- 让他们以不同的方式共享数据,如使用 “信息 “或 “便笺”。
- 让他们暂时隐藏自己的答案,以便在应用程序中进行自我测试。
这些都不难,但为了让它们变得更简单,我们可以在ViewModel
中添加一个额外的属性,它可以删除所有空字符串,并将其余字符串折叠成一个字符串:
var outputString: String {
answers.filter { $0.isEmpty == false }.joined(separator: " ")
}
有了这些,让我们开始实现其余功能。首先,我们需要在ContentView
中添加一个新方法,用于将用户的所有答案放入剪贴板:
func copyToClipboard() {
NSPasteboard.general.prepareForNewContents()
NSPasteboard.general.setString(viewModel.outputString, forType: .string)
}
其次,我们需要一些状态来跟踪当前是否显示了用户的答案。这可以在视图模型中,也可以直接在视图中,但我认为在视图中最合理:
@State private var showingAnswers = true
第三,当showingAnswers
为 false
时,我们可以将用户的答案文本字段的不透明度设置为 0,从而隐藏它们:
.opacity(showingAnswers ? 1 : 0)
重要信息:这比使用hidden()
更好,因为在切换showingAnswers
时不会导致布局调整。
最后,我们可以在视图主体中的主VStack上添加一个工具栏,其中包含我们需要的三个按钮:一个用于切换答案,一个用于将文本复制到剪贴板,还有一个带有ShareLink按钮,这样用户就可以将他们的文字发送到 “信息 “和类似内容中:
.toolbar {
Button("Toggle Answers", systemImage: "eye") {
showingAnswers.toggle()
}
.symbolVariant(showingAnswers ? .none : .slash)
Button("Copy", systemImage: "doc.on.doc", action: copyToClipboard)
ShareLink(item: viewModel.outputString, preview: SharePreview("Pi data"))
}
至此,这个应用程序就完成了。
暂无评论内容