#17_選擇題庫
小孩子才做選擇,我全都要?
此次作業來自
一看到這個作業立馬想到的是各種題庫,偏學術(比較無趣)的那種,想到小時候的夢魘,長大卻覺得很棒(至少有選擇XD)的選擇題,本來還在思考要不要做的有趣鮮豔一點,但又覺得題庫的本質好像就不是有趣,介面設計如何可愛應該都幫助不大(說服自己),於是就繼續按照原本的黑白風格刻下去,經過上一次計算機的 Autolayout ,現在對於 StackView 的 layout 算是有更熟悉一點。
初衷是做一個小一到小六會有的科目題庫,結果發現 GPT 對於題庫完全幫不上忙 😂,一是我覺得它無法辨別每個年級的範圍到哪,二是它無法顯示注音,三是很常離題,所以題目我只能上網找再複製丟給 GPT 請它照著 model 的形式出一份,只是還是會有錯誤需要逐個檢查,題庫都來自以下
目前只完成了國小三年級的國語題庫 30 題,後面除了在 swift 檔裡繼續生題目之外也會練習利用 CodableCSV 解析 csv 這個方式
為了避免做題時一直被 popup 的訊息打斷或是答錯分數被扣光,這次並沒有全部按照作業的要求做 alert 跟扣分,不過連續答對有額外加分還是有做的,雖然也想要做分數紀錄但這個好像還有點遠,也許之後會再回頭來做吧 XD
後記
補上之後練習 CSVDecoder 的部分
程式碼
// Model,題庫就只用一題表示其他省略
import Foundation
struct Question {
let grade : Grade
let subject : Subjects
let question : String
let options : [String]
let correctAnswer : Int
let answerDescription : String
}
enum Grade : String {
case one = "國小一年級"
case two = "國小二年級"
case three = "國小三年級"
case four = "國小四年級"
case five = "國小五年級"
case six = "國小六年級"
}
enum Subjects : String {
case Mandarin = "國語"
case English = "英語"
case Mathematics = "數學"
case Science = "自然科學"
case SocialStudies = "社會"
case ArtsAndHumanities = "藝術與人文"
}
func getAllQuestions() -> [Question] {
return [
Question(
grade: .three,
subject: .Mandarin,
question: "小船上要「ㄗㄞˋ」什麼呢?\n\n「 」 中的國字是哪一個?",
options: ["戴", "在", "載", "再"],
correctAnswer: 2,
answerDescription: "答案\n\n小船上要 「載」 什麼呢?"
),
除了結構,另外加了兩個列舉,列舉主要用來分類年級和科目,後面的頁面有稍微使用了列舉的 rawValue 做簡單的科目顯示,rawValue 可以參考以下
裡面有一個方法是用來輸出題目的,在經過兩個頁面的選擇到達問題的頁面時會有 年級 和 科目來做篩選這個後面會講到,但如果要寫在這個方法裡也是可以的如以下
func getAllQuestion2(grade:Grade, subject:Subjects) -> [Question] {
let questions = [ Question(
grade: .three,
subject: .Mandarin,
question: "小船上要「ㄗㄞˋ」什麼呢?\n\n「 」 中的國字是哪一個?",
options: ["戴", "在", "載", "再"],
correctAnswer: 2,
answerDescription: "答案\n\n小船上要 「載」 什麼呢?"
), ]
// 在這裡經由年級和科目篩選出問題
let exportQuestions = questions.filter { Question in
Question.grade == grade && Question.subject == subject
}
return exportQuestions
}
可以看一下目前 model 的解釋
Struct Question
:
- 這是一個結構體(struct),用於表示一個問題(Question)的相關資訊。
- 它包含以下屬性:
grade
:類型為Grade
,代表該問題針對的年級。subject
:類型為Subjects
,表示該問題的學科。question
:類型為String
,儲存問題的文本。options
:類型為[String]
(字符串數組),包含所有可選答案。correctAnswer
:類型為Int
,指出正確答案在options
數組中的索引。answerDescription
:類型為String
,提供答案的解釋或詳情。
Enum Grade
:
- 這是一個列舉(enum),用於定義不同的年級。
- 每個案例都有一個原始值(
String
),例如one = "國小一年級"
。 - 這讓你可以用更具描述性的方式表示年級,而不僅僅是數字。
Enum Subjects
:
- 另一個列舉,用於定義不同的學科。
- 例如,
Mandarin = "國語"
代表國語科目。 - 這同樣提供了一個清晰的方式來表示學科名稱。
函數定義:
func getAllQuestions() -> [Question]
定義了一個名為getAllQuestions
的函數,它不接受任何參數並返回一個Question
結構體的數組。
- 返回
Question
數組:
return [...]
語句返回一個Question
數組。這個數組是在函數內部直接構建的。
目前是把題庫都建立在這個方法裡,不知道這樣對於速度或是記憶體是否會有影響 🤨?
選擇年級頁面的程式碼
import UIKit
class GradeSelectViewController: UIViewController {
var grade : Grade?
@IBOutlet var gradeSelectButtonsOuelet: [UIButton]!
// MARK: - viewDidLoad Section
override func viewDidLoad() {
super.viewDidLoad()
}
// MARK: - IBACtion Section
@IBSegueAction func addGrade(_ coder: NSCoder, sender: Any?, segueIdentifier: String?) -> SubjectsSelectViewController? {
if let buttonGrade = segueIdentifier?.description {
switch buttonGrade {
case "one" :
grade = .one
case "two" :
grade = .two
case "three" :
grade = .three
case "four" :
grade = .four
case "five" :
grade = .five
case "six" :
grade = .six
default :
break
}
print(grade!.rawValue)
}
let controller = SubjectsSelectViewController(coder: coder)
controller?.grade = self.grade
return controller
}
}
畫面傳遞資料需要先在目前這個頁面的下一頁建立接受資料的屬性,比如說我現在這個頁面是要傳年級的列舉,那下一頁科目的頁面就要有一個接收年級列舉的屬性
這次傳資料的方式是用 IBSegueAction
,需要配合 storyboard 使用
簡短來說就是每一個按鈕的 segue 都有自己的 Identifier,比如一年級就是 “one” 以此類推,而所有的 segue 可以共用一個方法 ( Selector ),這個方法裡面是用 switch 去辨別按鈕的 Identifier 然後決定傳什麼資料到下一頁
接著看下一頁
import UIKit
class SubjectsSelectViewController: UIViewController {
var grade : Grade?
var subjects : Subjects?
// let grade2 : Grade
// init?(grade2: Grade, code: NSCoder) {
// self.grade2 = grade2
// super.init(coder: code)
// }
// required init?(coder: NSCoder) {
// fatalError("init(coder:) has not been implemented")
// }
// MARK: - viewDidLoad Section
override func viewDidLoad() {
super.viewDidLoad()
}
// MARK: - IBACtion Section
@IBSegueAction func addGradeAndSubject(_ coder: NSCoder, sender: Any?, segueIdentifier: String?) -> QuestionsViewController? {
if let buttonSubject = segueIdentifier?.description {
switch buttonSubject {
case "Mandarin" :
subjects = .Mandarin
case "English" :
subjects = .English
case "Mathematics" :
subjects = .Mathematics
case "Science" :
subjects = .Science
case "SocialStudies" :
subjects = .SocialStudies
case "ArtsAndHumanities" :
subjects = .ArtsAndHumanities
default :
break
}
}
let controller = QuestionsViewController(coder: coder)
controller?.grade = self.grade
controller?.subject = self.subjects
return controller
}
}
這一頁傳遞資料到下一頁的方式就跟上面一樣,只是下一頁會多一個接收科目列舉的屬性
另外還有一種接收資料更安全的寫法
let grade2 : Grade
init?(grade2: Grade, code: NSCoder) {
self.grade2 = grade2
super.init(coder: code)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
這樣子在年級頁面時最後的輸出會是這樣
//let controller = SubjectsSelectViewController(coder: coder)
//controller?.grade = self.grade
return SubjectsSelectViewController(grade2: grade!, code: coder)
}
出自 彼得潘的講義
定義 init,由參數設定 property
init 必須包含型別 NSCoder 的參數,並在 init 裡呼叫 super.init(coder: coder),因為我們的 controller 是在 storyboard 設計,在 storyboard 設計的 controller 畫面存在 coder 裡,它的初始必須經過 init(coder:)。
require init?
可以參考以下
最後是選擇題頁面的程式碼
import UIKit // 引入UIKit框架,用於iOS應用的用戶界面開發
// 定義一個繼承自UIViewController的QuestionsViewController類,用於顯示和管理問題
class QuestionsViewController: UIViewController {
// 以下使用@IBOutlet連接故事板(Storyboard)中的UI元件
@IBOutlet weak var navigationTitleOutlet: UINavigationItem! // 導航欄標題
@IBOutlet weak var questionTextLabel: UILabel! // 用於顯示問題文本的標籤
@IBOutlet var optionsButtonsOutlet: [UIButton]! // 一組選項按鈕
@IBOutlet weak var answerDescriptionLabel: UILabel! // 用於顯示答案描述的標籤
@IBOutlet weak var scoreLabel: UILabel! // 用於顯示分數的標籤
@IBOutlet weak var againButtonOutlet: UIButton! // “再玩一次”按鈕
// 定義用於跟蹤用戶選擇的年級和科目的變數
var grade: Grade?
var subject: Subjects?
// 定義並追踪用戶分數,使用property observer更新分數標籤
var score = 0 {
didSet {
scoreLabel.text = "分數:\(score)"
}
}
// 定義並追踪獎勵分數,達到一定條件時增加主分數
var bonusScore: Int = 0 {
didSet {
if bonusScore == 4 {
score += 30
bonusScore = 0
}
}
}
// 用於標記當前問題是否已被回答
var isAnswered = false
// 存儲當前問題集合的陣列
var currentQuestions: [Question] = []
// 紀錄當前顯示問題的索引
var currentQuestionIndex = 0
// viewDidLoad方法,在視圖控制器的視圖加載完成時調用
override func viewDidLoad() {
super.viewDidLoad()
setUpView() // 配置視圖的初始設置
loadQuestions() // 加載問題
displayCurrentQuestion() // 顯示當前問題
}
// MARK: - IBAction Section
// 處理選項按鈕點擊的動作
@IBAction func optionButtonTapped(_ sender: UIButton) {
if isAnswered {
// 如果當前問題已被回答,點擊按鈕將加載下一個問題
currentQuestionIndex += 1
displayCurrentQuestion()
isAnswered = false
return
}
// 獲取選中按鈕的索引
guard let selectedIndex = optionsButtonsOutlet.firstIndex(of: sender) else { return }
// 獲取當前問題
let question = currentQuestions[currentQuestionIndex]
// 檢查用戶選擇的答案是否正確,並採取相應行動
if question.correctAnswer == selectedIndex {
// 如果答案正確
bonusScore += 1 // 增加獎勵分數
score += 10 // 增加主分數
answerDescriptionLabel.text = question.answerDescription // 顯示答案描述
// 更新UI元件狀態以反映正確答案
for button in optionsButtonsOutlet {
button.isEnabled = false
button.backgroundColor = .systemGray
button.setTitleColor(.darkGray, for: .normal)
}
sender.setTitleColor(.black, for: .normal)
sender.backgroundColor = .systemGreen
sender.isEnabled = true
sender.setTitle("答對了!下一題", for: .normal)
// 檢查是否已經是最後一題
if currentQuestionIndex == 9 {
sender.setTitle("答對了!題目結束", for: .normal)
againButtonOutlet.isHidden = false
for optionButton in optionsButtonsOutlet {
optionButton.isEnabled = false
}
}
isAnswered = true
} else {
// 如果答案錯誤
bonusScore = 0 // 重置獎勵分數
answerDescriptionLabel.text = question.answerDescription // 顯示答案描述
// 更新UI元件狀態以反映錯誤答案
for button in optionsButtonsOutlet {
button.isEnabled = false
button.backgroundColor = .systemGray
button.setTitleColor(.darkGray, for: .normal)
}
sender.setTitleColor(.black, for: .normal)
sender.backgroundColor = .systemRed
sender.isEnabled = true
sender.setTitle("答錯了!下一題", for: .normal)
// 檢查是否已經是最後一題
if currentQuestionIndex == 9 {
sender.setTitle("答錯了!題目結束", for: .normal)
againButtonOutlet.isHidden = false
for optionButton in optionsButtonsOutlet {
optionButton.isEnabled = false
}
}
isAnswered = true
}
}
// 處理再玩一次按鈕的動作
@IBAction func newRound(_ sender: UIButton) {
setUpView() // 重新配置視圖
loadQuestions() // 重新加載問題
displayCurrentQuestion() // 顯示新一輪的第一個問題
}
// MARK: - function Section
// setUpView() 方法用於初始化或重置視圖的狀態
func setUpView() {
currentQuestionIndex = 0 // 將當前問題索引重置為0
score = 0 // 重置分數為0
bonusScore = 0 // 重置獎勵分數為0
isAnswered = false // 將是否已回答標記設為false
questionTextLabel.text = "" // 清空問題標籤的文本
// 遍歷所有選項按鈕,將它們的標題清空
for button in optionsButtonsOutlet {
button.setTitle("", for: .normal)
}
answerDescriptionLabel.text = "" // 清空答案描述標籤的文本
scoreLabel.text = "分數:\(score)" // 更新分數標籤的文本
againButtonOutlet.isHidden = true // 隱藏“再玩一次”按鈕
}
// loadQuestions() 方法用於加載並處理問題數據
func loadQuestions() {
let allQuestions = getAllQuestions() // 獲取所有問題
// 使用filter方法篩選出符合當前年級和科目的問題
currentQuestions = allQuestions.filter { $0.grade == grade && $0.subject == subject }
currentQuestions.shuffle() // 打亂問題順序
// 遍歷打亂後的問題,重新組合選項並確保正確答案索引的準確性
for (index, question) in currentQuestions.enumerated() {
var options = question.options
options.shuffle()
let newQuestion = Question(
grade: grade!,
subject: subject!,
question: question.question,
options: options,
correctAnswer: options.firstIndex(of: question.options[question.correctAnswer])!,
answerDescription: question.answerDescription
)
currentQuestions[index] = newQuestion
}
}
// displayCurrentQuestion() 方法用於在UI上顯示當前問題及其選項
func displayCurrentQuestion() {
if currentQuestions.isEmpty { return } // 如果沒有問題,直接返回
if currentQuestionIndex < 10 {
let question = currentQuestions[currentQuestionIndex] // 獲取當前問題
questionTextLabel.text = question.question // 設置問題標籤的文本
// 設置導航標題為當前科目和問題編號
navigationTitleOutlet.title = "\(subject!.rawValue)題目 \(currentQuestionIndex + 1) / 10"
answerDescriptionLabel.text = "" // 清空答案描述標籤的文本
// 設置每個選項按鈕的標題
for (index, optionButton) in optionsButtonsOutlet.enumerated() {
optionButton.setTitle(question.options[index], for: .normal)
}
// 重置選項按鈕的狀態
for button in optionsButtonsOutlet {
button.isEnabled = true
button.backgroundColor = .white
button.setTitleColor(.black, for: .normal)
}
} else {
// 如果已經達到問題數量上限,禁用所有選項按鈕
for optionButton in optionsButtonsOutlet {
optionButton.isEnabled = false
}
}
}
}
Property Observers(屬性觀察器)
// 定義並追踪用戶分數,使用property observer更新分數標籤
var score = 0 {
didSet {
scoreLabel.text = "分數:\(score)"
}
}
// 定義並追踪獎勵分數,達到一定條件時增加主分數
var bonusScore: Int = 0 {
didSet {
if bonusScore == 4 {
score += 30
bonusScore = 0
}
}
}
只要分數的值有變化就會顯示在該 Label 上,另外是額外加分的值有也是只要有如設定裡的變化就會執行,這種只要某個東西有變化就會要做什麼事的過程挺適合屬性觀察器
Property Observers(屬性觀察器)是一種功能,它允許你的代碼在某個屬性的值改變時得到通知。這對於你需要對值的變化做出響應時特別有用,例如:更新用戶界面、更改數據模型、觸發事件等。
Swift 提供了兩種屬性觀察器:
willSet
:在新的值被設置之前調用。didSet
:在新的值被設置之後立即調用。
基本概念
willSet
觀察器會在值存儲之前被調用。它帶有一個叫做newValue
的默認參數,代表即將存儲的新值。didSet
觀察器會在新的值被存儲後被調用。它帶有一個叫做oldValue
的默認參數,代表之前存儲的舊值。
注意點
- 屬性觀察器可以添加到自己定義的存儲屬性上,也可以添加到繼承的屬性(無論是存儲屬性還是計算屬性)上。
- 對於計算屬性,你不需要使用屬性觀察器,因為你可以直接在 setter 中觀察和響應值的變化。
- 屬性觀察器不會在初始化過程中被調用,以避免在屬性尚未完全初始化時就被觸發。
下一題按鈕
if isAnswered {
// 如果當前問題已被回答,點擊按鈕將加載下一個問題
currentQuestionIndex += 1
displayCurrentQuestion()
isAnswered = false
return
}
在按下選項按鈕之後可以再按一次到下一題
簡要來說就是在還未按下按鈕前它是 false,按下其中一個按鈕後會先簡單粗暴地把所有的按鈕都關掉,然後再把被點擊的按鈕打開,依據答對或答錯顯示不同的文字和顏色同時也將這個布林值轉為 true,就會進到上面那段程式碼按下之後進到下一題
filter
// 使用filter方法篩選出符合當前年級和科目的問題
currentQuestions = allQuestions.filter { $0.grade == grade && $0.subject == subject }
透過兩個列舉加上 filter 方法來找到對應的題目
currentQuestions = allQuestions.filter { $0.grade == grade && $0.subject == subject }
allQuestions
:這是一個包含Question
類型元素的集合(可能是陣列或其他類型的集合)。每個Question
元素包含多個屬性,如grade
和subject
。.filter { ... }
:這是一個高階函數,用於從allQuestions
中篩選出符合特定條件的元素。.filter
方法遍歷集合中的每一個元素,對每個元素應用提供的條件(即大括號{}
中的代碼),並返回一個新的集合,其中只包含符合這些條件的元素。$0
:這是Swift中的閉包語法的一部分,代表當前被.filter
方法遍歷的元素。在這個上下文中,$0
代表allQuestions
集合中的一個Question
對象。$0.grade == grade && $0.subject == subject
:這是過濾條件。這行代碼的含義是,只有當Question
對象的grade
屬性等於grade
變數的值,且Question
對象的subject
屬性等於subject
變數的值時,這個Question
對象才會被包含在返回的集合中。
結果,currentQuestions
將是一個新的集合,其中只包含grade
和subject
屬性與給定的grade
和subject
變數相匹配的Question
對象。
重新排列題目的四個選項
// 遍歷打亂後的問題,重新組合選項並確保正確答案索引的準確性
for (index, question) in currentQuestions.enumerated() {
var options = question.options
options.shuffle()
let newQuestion = Question(
grade: grade!,
subject: subject!,
question: question.question,
options: options,
correctAnswer: options.firstIndex(of: question.options[question.correctAnswer])!,
answerDescription: question.answerDescription
)
currentQuestions[index] = newQuestion
}
- 遍歷
currentQuestions
集合:
for (index, question) in currentQuestions.enumerated() {
for (index, question) in currentQuestions.enumerated()
是一個for循環,遍歷currentQuestions
集合。enumerated()
方法返回一個包含每個元素及其對應索引的序列。index
是當前元素在集合中的索引。question
是當前被遍歷的Question
對象。
處理每個問題的選項:
var options = question.options options.shuffle()
var options = question.options
:這裡創建了一個新變數options
,它是從當前question
的options
屬性(即問題的選項)複製而來的。options.shuffle()
:這一行將options
陣列中的元素進行隨機排序。這樣做是為了確保每次展示問題時選項的順序都是不同的,增加遊戲的挑戰性。
創建新的問題實例:
let newQuestion = Question( grade: grade!, subject: subject!, question: question.question, options: options, correctAnswer: options.firstIndex(of: question.options[question.correctAnswer])!, answerDescription: question.answerDescription )
- 這裡使用
Question
結構的初始化器創建了一個新的Question
實例newQuestion
。 grade: grade!
和subject: subject!
:這些是從當前視圖控制器的屬性中取得的年級和學科。question: question.question
:使用當前問題的文本。options: options
:這是前面已經隨機排序的選項列表。correctAnswer: options.firstIndex(of: question.options[question.correctAnswer])!
:這是計算新的正確答案索引。由於選項已經被隨機排序,原來的索引可能不再有效,因此需要在新的options
列表中找到原來正確答案的新位置。answerDescription: question.answerDescription
:使用當前問題的答案描述。
更新currentQuestions
集合:
currentQuestions[index] = newQuestion
- 這行代碼將剛剛創建的
newQuestion
賦值給currentQuestions
集合中對應索引的位置。這樣,原來位置上的問題被更新為新的、選項已隨機排序的問題。
總的來說,這段程式碼的目的是遍歷currentQuestions
集合中的每個問題,對每個問題的選項進行隨機排序,並更新每個問題的正確答案索引,最後將這些更新過的問題放回原來的集合中。