第30天 项目 5 第二部分
既然你已经理解了完成这个项目所需的技术,今天我们就来实现我们的游戏。是的,这里会有不少实践内容,希望这个项目对你来说会比较简单。但这并不妨碍你满怀热情地去完成它——全力以赴吧!
不妨请记住美国作家兼讲师戴尔·卡耐基(Dale Carnegie)的一句名言值得你铭记:
“不要害怕把最好的自己投入到看似微小的工作中。每攻克一个小任务,你就会变得更强——如果你能把小事做好,大事自然也能迎刃而解。”
如今,处理列表、数组、文本框等内容对你来说肯定都是小事了,但这门课程的目标之一,就是让你在这些基础知识上打下真正坚实的基础,同时了解到更大的可能性。
未来,我希望你看到一个应用想法的草图时,甚至在写一行代码之前,就能确切知道该如何构建它,因为归根结底,任何复杂的需求都可以拆解成一系列小任务。
如果你觉得这仍然太简单,别着急:明天就是挑战日!
今天你需要完成三个主题的学习,你将把学到的关于 List、UITextChecker 等所有知识都付诸实践。
- 向单词列表中添加内容
- 在应用启动时执行代码
- 使用 UITextChecker 验证单词
向单词列表中添加内容
作者:Paul Hudson 2024年5月21日
这个应用的用户界面由三个主要的 SwiftUI 视图构成:一个显示用户需要从中拼写单词的 NavigationStack、一个供用户输入答案的 TextField,以及一个显示用户之前输入过的所有单词的 List。
目前,每当用户在文本框中输入一个单词,我们会自动将其添加到已使用单词的列表中。不过之后,我们会添加一些验证逻辑,确保这个单词之前没有被使用过、确实可以从给定的基础单词中拆分出来,并且是一个真实存在的单词,而不是随意组合的字母。
我们先从基础开始:我们需要一个存储已使用单词的数组、一个供用户从中拼写其他单词的基础单词,以及一个可绑定到文本框的字符串。现在,在 ContentView 中添加这三个属性:
@State private var usedWords = [String]()
@State private var rootWord = ""
@State private var newWord = ""至于视图的主体部分,我们先从最简单的结构入手:一个 NavigationStack(标题为 rootWord 的值),然后在列表中包含两个分区:
var body: some View {
NavigationStack {
List {
Section {
TextField("输入你的单词", text: $newWord)
}
Section {
ForEach(usedWords, id: \.self) { word in
Text(word)
}
}
}
.navigationTitle(rootWord)
}
}注意: 如果 usedWords 中有很多重复项,使用 id: \.self 会出现问题,但很快我们就会禁止重复单词,所以目前这不是问题。
现在,我们的文本视图存在一个问题:虽然可以在文本框中输入内容,但无法提交——没有办法将输入的内容添加到已使用单词的列表中。
要解决这个问题,我们需要编写一个新的方法 addNewWord(),它将实现以下功能:
- 将
newWord转换为小写并去除所有空格 - 检查它至少包含 1 个字符,否则退出方法
- 将该单词插入到
usedWords数组的第 0 位(即开头) - 将
newWord重置为空字符串
之后,我们会在步骤 2 和 3 之间添加额外的验证逻辑,确保单词符合要求,但目前这个方法很简单:
func addNewWord() {
// 将单词转换为小写并修剪,确保不会因大小写差异添加重复单词
let answer = newWord.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
// 如果剩余字符串为空,则退出方法
guard answer.count > 0 else { return }
// 后续将添加额外验证
usedWords.insert(answer, at: 0)
newWord = ""
}我们希望在用户按下键盘上的回车键时调用 addNewWord(),在 SwiftUI 中,可以通过在视图层级中的某个位置添加 onSubmit() 修饰符来实现这一点——它可以直接添加在文本框上,也可以添加在视图的其他任何位置,因为只要有文本提交,它就会被触发。
onSubmit() 需要接收一个无参数、无返回值的函数,这正好与我们刚刚编写的 addNewWord() 方法匹配。因此,我们可以直接将该方法传入,在 navigationTitle() 下方添加这个修饰符:
.onSubmit(addNewWord)现在运行应用,你会发现事情开始有了进展:可以在文本框中输入单词,按下回车键后,单词就会显示在列表中。
在 addNewWord() 中,我们使用 usedWords.insert(answer, at: 0) 是有原因的:如果使用 append(answer),新单词会添加到列表的末尾,很可能不在屏幕可见范围内;而将单词插入到数组的开头,它们会自动显示在列表顶部——这样体验要好得多。
在导航栏中显示标题之前,我要对布局做两个小修改。
首先,当我们调用 addNewWord() 时,会将用户输入的单词转换为小写,这很有用,因为这样用户就无法添加“car”“Car”和“CAR”这些重复项了。但实际显示时会显得有些奇怪:文本框会自动将用户输入的第一个字母大写,所以当用户提交“Car”时,列表中显示的却是“car”。
要解决这个问题,我们可以通过另一个修饰符 textInputAutocapitalization() 禁用文本框的首字母大写功能。现在将这个修饰符添加到文本框上:
.textInputAutocapitalization(.never)第二个修改纯粹是为了优化体验:我们可以使用苹果的 SF Symbols 图标,在文本旁边显示每个单词的长度。SF Symbols 提供了从 0 到 50 的圆形数字图标,命名格式为“x.circle.fill”——例如 1.circle.fill、20.circle.fill。
在这个程序中,我们会向用户展示 8 个字母的单词,所以用户重新排列这些字母组成新单词时,最长也只有 8 个字母。因此,我们完全可以使用这些 SF Symbols 数字图标——我们知道所有可能的单词长度都在覆盖范围内。
所以,我们可以将单词文本包装在 HStack 中,并使用 Image(systemName:) 在旁边添加一个 SF Symbols 图标,代码如下:
ForEach(usedWords, id: \.self) { word in
HStack {
Image(systemName: "\(word.count).circle")
Text(word)
}
}现在运行应用,你会发现可以在文本框中输入单词,按下回车键后,单词会显示在列表中,旁边还带有显示其长度的图标。很不错!
另外,我们还可以在这里添加一个巧妙的小优化。目前,当我们提交文本框中的内容时,文本会立即显示在列表中,但我们可以通过修改 addNewWord() 中的 insert() 调用,为这个过程添加动画效果:
withAnimation {
usedWords.insert(answer, at: 0)
}我们还没有深入学习动画相关知识,之后会详细讲解,但仅仅这一个修改,就能让新单词以更流畅的滑动效果出现——我认为这是一个很大的改进!
在应用启动时执行代码
作者:Paul Hudson 2023年10月20日
当 Xcode 构建 iOS 项目时,会将编译后的程序、资源目录以及其他所有资源放入一个名为“bundle”(包)的单个目录中,然后将这个包命名为“YourAppName.app”。这种“.app”扩展名会被 iOS 和苹果的其他平台自动识别,这就是为什么在 macOS 上双击“Notes.app”(备忘录应用)时,系统知道要启动包中的程序。
在我们的游戏中,我们会包含一个名为“start.txt”的文件,其中包含超过 10000 个 8 字母单词,程序会随机选择其中一个单词供玩家使用。这个文件应该已经包含在你从 GitHub 下载的项目文件中,请现在将 start.txt 拖入你的项目中。
我们已经定义了一个名为 rootWord 的属性,它将存储供玩家从中拼写单词的基础单词。现在我们需要编写一个新的方法 startGame(),它将实现以下功能:
- 在应用包中找到 start.txt 文件
- 将文件内容加载为字符串
- 将该字符串拆分为字符串数组,每个元素对应一个单词
- 从数组中随机选择一个单词赋值给
rootWord,如果数组为空,则使用一个合理的默认值
这四个任务每个都对应一行代码,但有一个需要注意的点:如果我们无法在应用包中找到 start.txt,或者找到了文件但无法加载,该怎么办?这种情况下,应用会出现严重问题——要么是我们忘记将文件包含在项目中(这样游戏无法运行),要么是包含了文件但 iOS 出于某种原因不允许我们读取(这样游戏也无法运行,应用会处于故障状态)。
无论原因是什么,这种情况都不应该发生,而 Swift 提供了一个名为 fatalError() 的函数,可以让我们清晰地应对这种无法解决的问题。调用 fatalError() 时,应用会无条件地立即崩溃。就是直接停止运行,没有“可能崩溃”或“也许崩溃”的情况,而是一定会立即终止。
我知道这听起来不太好,但它的作用很重要:对于这类问题(比如忘记将文件包含在项目中),让应用在故障状态下勉强运行是没有意义的。立即终止运行并清晰地说明问题所在,能让我们更快地修正错误,这正是 fatalError() 的作用。
好了,让我们来看代码——我已经添加了与上面四个步骤对应的注释:
func startGame() {
// 1. 在应用包中找到 start.txt 的 URL
if let startWordsURL = Bundle.main.url(forResource: "start", withExtension: "txt") {
// 2. 将 start.txt 加载为字符串
if let startWords = try? String(contentsOf: startWordsURL) {
// 3. 将字符串按换行符拆分为字符串数组
let allWords = startWords.components(separatedBy: "\n")
// 4. 随机选择一个单词,若数组为空则使用“silkworm”作为默认值
rootWord = allWords.randomElement() ?? "silkworm"
// 若执行到这里,说明所有操作都成功了,可以退出方法
return
}
}
// 若执行到这里,说明出现了问题——触发崩溃并报告错误
fatalError("无法从应用包中加载 start.txt 文件。")
}现在我们有了一个用于加载游戏所需资源的方法,接下来需要在视图显示时调用这个方法。SwiftUI 提供了一个专门的视图修饰符,用于在视图显示时执行闭包,我们可以使用它来调用 startGame() 以启动游戏——在 onSubmit() 之后添加这个修饰符:
.onAppear(perform: startGame)现在运行游戏,你应该会在导航视图的顶部看到一个随机的 8 字母单词。不过目前它还没有实际意义,因为玩家仍然可以输入任何他们想输入的单词。接下来我们就来解决这个问题……
使用 UITextChecker 验证单词
作者:Paul Hudson 2023年10月20日
现在我们的游戏已经基本搭建完成,这个项目的最后一步是确保用户无法输入无效的单词。我们将通过四个小方法来实现这一功能,每个方法负责一项检查:单词是否原创(之前未被使用过)、单词是否可行(不能从“silkworm”中拼出“car”)、单词是否真实(是一个真正的英语单词)。
如果你仔细看,会发现上面只提到了三个方法——第四个方法是为了让错误提示的显示更简单。
首先,我们来实现第一个方法:它接收一个字符串作为唯一参数,根据该单词之前是否被使用过,返回 true 或 false。我们已经有了 usedWords 数组,所以可以将单词传入该数组的 contains() 方法,并返回结果,代码如下:
func isOriginal(word: String) -> Bool {
!usedWords.contains(word)
}第一个方法完成了!
第二个方法稍微复杂一些:如何检查一个随机单词是否可以由另一个随机单词的字母组成?
有几种方法可以解决这个问题,但最简单的方法是:创建一个基础单词的可变副本,然后遍历用户输入单词的每个字母,检查该字母是否存在于副本中。如果存在,就从副本中移除该字母(避免重复使用),然后继续遍历。如果成功遍历完用户输入的所有字母,说明这个单词是有效的;否则就存在问题,返回 false。
以下是第二个方法的代码:
func isPossible(word: String) -> Bool {
var tempWord = rootWord
for letter in word {
if let pos = tempWord.firstIndex(of: letter) {
tempWord.remove(at: pos)
} else {
return false
}
}
return true
}第三个方法难度更大,因为我们需要使用 UIKit 中的 UITextChecker。为了安全地将 Swift 字符串桥接到 Objective-C 字符串,我们需要使用 Swift 字符串的 UTF-16 计数来创建 NSRange 实例。我知道这不太直观,但在苹果优化这些 API 之前,这是不可避免的。
所以,第三个方法会创建一个 UITextChecker 实例(用于检查字符串中的拼写错误)。然后我们会创建一个覆盖整个字符串长度的 NSRange,接着调用文本检查器的 rangeOfMisspelledWord() 方法来查找拼写错误的单词。方法执行完成后,我们会得到另一个 NSRange,它指示拼写错误的单词所在的位置;如果单词拼写正确,该范围的 location 属性会是一个特殊值 NSNotFound。
以下是第三个方法的代码:
func isReal(word: String) -> Bool {
let checker = UITextChecker()
let range = NSRange(location: 0, length: word.utf16.count)
let misspelledRange = checker.rangeOfMisspelledWord(in: word, range: range, startingAt: 0, wrap: false, language: "en")
return misspelledRange.location == NSNotFound
}在使用这三个方法之前,我们先添加一些代码,让错误提示的显示更简单。首先,我们需要一些用于控制弹窗的属性:
@State private var errorTitle = ""
@State private var errorMessage = ""
@State private var showingError = false然后我们可以添加一个方法,根据传入的参数设置标题和消息,然后将 showingError 布尔值设为 true:
func wordError(title: String, message: String) {
errorTitle = title
errorMessage = message
showingError = true
}之后,我们可以通过在 .onAppear() 下方添加 alert() 修饰符,将这些属性直接传递给 SwiftUI:
.alert(errorTitle, isPresented: $showingError) {
Button("确定") { }
} message: {
Text(errorMessage)
}我们已经做过好几次这样的操作了,希望这已经成为你的本能反应!事实上,既然你已经熟悉了这种基础弹窗,这里有一个专业技巧要告诉你:如果不在代码中添加任何按钮,系统会自动添加一个简单的“确定”按钮来关闭弹窗。
所以,你也可以这样写:
.alert(errorTitle, isPresented: $showingError) { } message: {
Text(errorMessage)
}是的,这样看起来有点奇怪——你可以选择自己喜欢的写法。
终于到了完成游戏的最后一步:将 addNewWord() 中 // 后续将添加额外验证 的注释替换为以下代码:
guard isOriginal(word: answer) else {
wordError(title: "单词已被使用", message: "换个新单词吧")
return
}
guard isPossible(word: answer) else {
wordError(title: "单词无法拼写", message: "你不能从 '\(rootWord)' 中拼出这个单词!")
return
}
guard isReal(word: answer) else {
wordError(title: "单词不存在", message: "别自己编造单词呀!")
return
}现在运行应用,你会发现如果单词不符合我们的检查要求,就无法添加到列表中——重复的单词不行、无法从基础单词中拼出的单词不行、无意义的乱码单词也不行。
又一个应用完成了——干得好!