2024-10-23 17:18:20
,某些文章具有时效性,若有错误或已失效,请在下方留言。这个 app 用来学习日语中的平假名(hiragana
)与片假名(katakana
)。
基本用户界面
这个项目的 assets.zip 压缩文件,包含两个重要的文件:
kana.json
文件包含我们要使用的所有日语字符。Bundle-Decodable.swift
文件,可以轻松地将 JSON 文件加载到 Swift 结构体中。
将这些文件添加到项目中。
首先,我们将创建一个小结构体,用于保存从 JSON 中加载的单个字符。创建一个名为 KanaCharacter.swift
的文件,然后编写以下代码:
struct KanaCharacter: Decodable, Hashable, Identifiable {
var id: String { kana }
var script: String
var type: String
var romaji: String
var kana: String
}
这里有四个重要的属性:
script
,决定这个字符是平假名还是片假名。type
,决定这个字符属于平假名还是片假名的子集。这一点很重要,因为这两个脚本都添加了额外的符号,以创建更多声音。kana
是我们应该显示的实际日语符号。romaji
是日语符号的拉丁拼音版本。
当 ContentView
创建的时候,我们想要一次加载全部的Kana
。
let allKana: [KanaCharacter] = Bundle.main.decode("kana.json")
现在我们开始制作测试本身,这意味着我们需要添加四个非常重要的属性:
- 一个
KanaCharacter
数组追踪用户当前使用的所有假名。这个数组起始有 4 个符号,但是如果持续到最后将会包含每一个符号。 - 另一个
KanaCharacter
数组追踪我们展示的可能的答案。 - 一个整数,用于存储用户在
allKana
数组中的位置。随着时间的推移,我们会添加更多的符号,这个整数也会随之递增。 - 另一个整数,用于存储用户在当
currentKana
数组中的位置。每次用户选择了正确答案,这个整数就会递增。
@State private var currentKana = [KanaCharacter]()
@State private var answer = [KanaCharacter]()
@State private var maxPosition = 4
@State private var currentPosition = 0
随着时间的推移,currentKana
和answer
的具体内容也会发生变化:前者会在用户成功完成当前字符时显示,后者会在用户正确提交答案时显示。
因此,我们可以将这些功能分为不同的方法,使代码更容易理解。首先,一个方法以随机顺序设置当前使用的所有假名:
func selectKana() {
// prefix 可以获取子序列
currentKana = allKana.prefix(maxPosition).shuffled()
}
现在要选择四个答案进行展示,这需要从他们使用的集合中显示四个字符。其中一个必须为答案。现在从根方法开始:
func selectAnswers() {
}
我们将一步一步填充,从读取他们正在使用的所有字符开始,并删除答案。
var allAnswers = Set(currentKana)
allAnswers.remove(currentKana[currentPosition])
为什么删除答案?我们知道最终的结果肯定包含答案,但如果我们随机选择四个结果,我们就无法确定—答案可能在其中,也可能不在。去掉答案后,我们就知道有一大堆字符肯定不包含答案,这意味着我们可以手工添加答案,确保答案始终存在。
现在我们有一组不包含实际正确答案的所有可能答案,我们可以加将它们洗牌,选出三个:
answers = Array(allAnswers.shuffled().prefix(3))
现在我们有三个可能的答案,但肯定都不正确,所以我们可以把正确答案加进去,然后再次洗牌,让答案每次都出现在随机的位置上:
answers.append(currentKana[currentPosition])
answers.shuffle()
这就是我们加载并准备就绪的所有数据,因此我们可以放入一个简单的用户界面,这样你就可以大致看到各部分是如何组合在一起的:
VStack {
if currentKana.isEmpty {
Button("Start") {
selectKana()
selectAnswers()
}
} else {
Text("Kana")
.font(.system(size: 120))
Grid {
GridRow {
Button("A", action: {})
Button("B", action: {})
}
GridRow {
Button("C", action: {})
Button("D", action: {})
}
}
}
}

引入实时数据
下一步是将数据和用户界面结合在一起。
我们可以通过自定的 SwiftUI 视图将所有的样式集中在一个地方,所以需要创建一个视图,并将其命名为 AnswerButton
。
这需要两个属性:
- 一个用于跟踪按钮上要显示的文本的字符串。
- 点击按钮时运行的闭包。这将传回文本字符串,以便我们的
ContentView
可以跟踪答案是否正确。
现在添加这两个属性:
var text: String
var submit: (String) -> Void
至于视图的 body, 现在只是一个带有少量样式的按钮,当按下的时候会调用 submit()
函数:
Button {
submit(text)
} label: {
Text(text)
.frame(width: 100, height: 100)
.font(.largeTitle)
}
.buttonStyle(.borderedProminent)
修改预览中的代码,使其生效
#Preview {
AnswerButton(text: "x") { _ in
}
}
在决定在上面显示什么内容时,我们将它放到 ContentView
中自己的方法,这样问题显示假名,答案显示罗马字符。
func text(for character: KanaCharacter, isQuestion: Bool = false) -> String {
if isQuestion {
character.kana
} else {
character.romaji
}
}
如您所见,它接受一个假名字符实例,并根据是问题文本还是答案按钮返回两个字符串中的一个。
我们现在有了一个可以显示单个字符的简单按钮,但当我们点击它时应该如何响应呢?
如果它们是正确的,那么答案就是多方面的:
- 我们应该递增
currentPosition
,以便在当前假名子集中继续下一个假名。 - 如果我们到达了假名子集的末尾,就应该递增
maxPosition
,将另一个假名添加到假名子集中,然后回到起点,再调用selectKana()
更新当前正在测试的字符。 - 无论是否已到达子集的末尾,我们都需要调用
selectAnswers()
来选择新的按钮标题。 - 如果他们错了……好吧,现在我们什么也不做,但我们以后会再来讨论这个问题!
现在就将此方法添加到ContentView
中:
func checkAnswer(_ answer: String) {
if answer == text(for: currentKana[currentPosition]) {
withAnimation {
currentPosition += 1
if currentPosition >= maxPosition {
currentPosition = 0
maxPosition += 1
selectKana()
}
selectAnswers()
}
} else {
// answer was wrong
}
}
现在是在屏幕上显示这些新按钮的时候了。为了让代码更简单,我们将创建一个名为button(index:)
的辅助方法,该方法将为特定答案创建一个新的AnswerButton
:
func button(index: Int) -> some View {
let text = text(for: answers[index])
return AnswerButton(
text: text,
submit: checkAnswer
)
}
修改 Grid
中的调用
Grid {
GridRow {
button(index: 0)
button(index: 1)
}
GridRow {
button(index: 2)
button(index: 3)
}
}
最后,我们可以用实际的假名符号替换固定的问题文本:
Text(text(for: currentKana[currentPosition], isQuestion: true))
.font(.system(size: 120))
这样,我们的应用程序就能正常运行了

标记进度
错误答案的两种反馈形式:
- 当他们答案错误时,我们会将假名符号做成动画–它会闪烁红光,并缩小尺寸。
- 我们还会用红色标记错误的按钮,以免用户再次点击。
这些都可以通过 contentView 中的两个新属性来控制:
@State private var isWrong = false
@State private var wrongAnswers = Set<String>()
在AnswerButton
中,我们将添加另一个属性来跟踪某个特定按钮是否被错误使用,这样我们就可以更改它的颜色。
var isWrong: Bool
然后再 buttonStyle()
下方,添加 tint()
修饰符
.tint(isWrong ? .red : nil)
AnswerButton
希望在创建的时候,传递一个 isWrong
值。因此我们需要更新 button(index:)
,通过读取 wrongAnswers
集合来提供。
return AnswerButton(
text: text,
isWrong: wrongAnswers.contains(text),
submit: checkAnswer
)
修改AnswerButton
的预览
#Preview {
AnswerButton(text: "x", isWrong: false) { _ in
}
}
还想让问题提示本身有一点动画效果–我们可以通过添加scaleEffect()
和foregroundStyle()
修饰符使其缩放和着色。
Text(text(for: currentKana[currentPosition], isQuestion: true))
.font(.system(size: 120))
.scaleEffect(isWrong ? 1.5 : 1)
.foregroundStyle(isWrong ? .red : .primary)
现在只需更新checkAnswer()
方法。该方法需要切换isWrong
以触发缩放和闪光效果,还需要将错误答案插入wrongAnswers
中,以便正确着色。
将此代码替换 // answer was wrong
的注释
isWrong = true
wrongAnswers.insert(answer)
withAnimation {
isWrong = false
}
同时,我们还需要在他们最终点选正确答案时清除 wrongAnswers 集合:
wrongAnswers.removeAll()
现在就运行该应用程序,看看运行效果。

增加一个额外的小动画:我们可以让问题提示文本过渡,让用户更清楚地看到他们已经移动到下一个符号。
在 foregroundStyle()
下面添加新的transition()
和id()
修饰符,这样 SwiftUI 就能理解如何在视图发生变化时进行过渡:
.transition(.push(from: .trailing))
.id(currentPosition)
提示:使用currentPosition
作为该视图的标识符,意味着每次currentPosition
发生变化时,它都会被视为不同的视图–每次都会触发我们的转换,这非常好!

增加一些挑战
问题模式
为了让这款应用程序更加完美,我们将让用户在假名和罗马字之间切换,从而增加一点挑战性。
由于代码结构的原因,需要四处小的改动,首先是一个新的枚举跟踪两种可能的问题模式。
enum QuestionMode: CaseIterable {
case kana, romaji
}
如果需要,您可以将其放到自己的 QuestionMode.swift
文件中,或者将其嵌套到ContentView
中。
其次,我们需要添加ContentView
属性来存储当前的问题模式:
@State private var questionMode = QuestionMode.kana
第三,我们需要在 “Start” 按钮旁边添加一个选择器,以便用户从两种可能的模式中进行选择:
Picker("Question Mode", selection: $questionMode) {
ForEach(QuestionMode.allCases, id: \.self) {
Text(String(describing: $0).capitalized)
}
}
.pickerStyle(.segmented)
.padding()
最后,我们需要修改text(for:isQuestion:)
方法,使其在questionMode
设置为.romaji
时反向工作。
func text(for character: KanaCharacter, isQuestion: Bool = false) -> String {
if questionMode == .kana {
if isQuestion {
character.kana
} else {
character.romaji
}
} else {
if isQuestion {
character.romaji
} else {
character.kana
}
}
}
运行效果,如下图所示

连胜记录
在 ContentView
中添加新属性:
@State private var streak = 0
当选择正确,可以在 checkAnswer()
中添加一个:
streak += 1
…或者他们出错时,将其重置为 0:
streak = 0
最重要的是,我们需要向用户显示连胜记录,这样他们就能清楚地看到自己的操作情况–在ContentView
中的 Grid
下方添加这一点:
Text("Streak: \(streak)")
.contentTransition(.numericText())
.font(.title.monospacedDigit())
提示:使用单倍行距(monospaced
)字体可以防止文字在streak
变化时进行微调。
在问题提示之前和 streak 之后添加一个 Spacer()
,然后在 Grid 和 streak 之间添加以下内容:
Spacer()
.frame(maxHeight: 50)
最后,在最后的 Spacer() 之后, 添加一个按钮,这样用户可以随时结束。
Button(role: .destructive) {
currentKana.removeAll()
} label: {
Text("End")
.frame(width: 100)
}
.buttonStyle(.borderedProminent)
我们就大功告成了,运行后的效果如下图所示。

挑战
[admonition title=”挑战1: 避免按钮多次点击” color=”indigo”]在用户按错按钮时禁用按钮,避免按钮被多次按下。[/admonition]
避免按钮多次点击
在用户按错按钮时禁用按钮,避免按钮被多次按下。

在 AnswerButton
中,为 Body
中 Button
添加 disable()
修饰符,位于 tint()
修饰符下方:
.disabled(isWrong)

同组答案,非随机答案
挑战2: 同组答案,非随机答案
平假名和片假名都会对符号进行分组:ka、ki、ku、ke 和 ko 是一组,ma、mi、mu、me 和 mo 是另一组,na、ni、nu、ne 和 no 是第三组。:与其随机选择答案选项,可以尝试选择与正确答案同一组的选项–例如,如果答案是 “ko”,他们就会看到 ka、ke 和 ku。
第三种模式
挑战3: 添加第三种模式
可以添加第三种问题模式,在.kana和.romaji 之间随机切换。这意味着在一次运行中,他们有时可能会在问题中看到假名,有时则会看到罗马字–这是随机的。
答错结束
挑战4: 答错结束
当他们答错时就结束测验。将 “连胜 “一词改为 “得分”。
参考
- 文章来自: Hacking With Swift Plus ↩
暂无评论内容