Kana Quest

温馨提示:本文最后更新于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")

现在我们开始制作测试本身,这意味着我们需要添加四个非常重要的属性:

  1. 一个 KanaCharacter 数组追踪用户当前使用的所有假名。这个数组起始有 4 个符号,但是如果持续到最后将会包含每一个符号。
  2. 另一个KanaCharacter数组追踪我们展示的可能的答案。
  3. 一个整数,用于存储用户在 allKana 数组中的位置。随着时间的推移,我们会添加更多的符号,这个整数也会随之递增。
  4. 另一个整数,用于存储用户在当currentKana数组中的位置。每次用户选择了正确答案,这个整数就会递增。
@State private var currentKana = [KanaCharacter]()
@State private var answer = [KanaCharacter]()
@State private var maxPosition = 4
@State private var currentPosition = 0

随着时间的推移,currentKanaanswer的具体内容也会发生变化:前者会在用户成功完成当前字符时显示,后者会在用户正确提交答案时显示。

因此,我们可以将这些功能分为不同的方法,使代码更容易理解。首先,一个方法以随机顺序设置当前使用的所有假名:

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))

这样,我们的应用程序就能正常运行了

运行效果

标记进度

错误答案的两种反馈形式:

  1. 当他们答案错误时,我们会将假名符号做成动画–它会闪烁红光,并缩小尺寸。
  2. 我们还会用红色标记错误的按钮,以免用户再次点击。

这些都可以通过 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
        }
    }
}

运行效果,如下图所示

romaji 问题模式

连胜记录

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 中,为 BodyButton 添加 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: 答错结束
当他们答错时就结束测验。将 “连胜 “一词改为 “得分”。

参考

  1. 文章来自: Hacking With Swift Plus

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容