#17_選擇題庫

Howewu
彼得潘的 Swift iOS / Flutter App 開發教室
29 min readNov 14, 2023

小孩子才做選擇,我全都要?

此次作業來自

一看到這個作業立馬想到的是各種題庫,偏學術(比較無趣)的那種,想到小時候的夢魘,長大卻覺得很棒(至少有選擇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結構體的數組。
  1. 返回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 提供了兩種屬性觀察器:

  1. willSet:在新的值被設置之前調用。
  2. didSet:在新的值被設置之後立即調用。

基本概念

  • willSet 觀察器會在值存儲之前被調用。它帶有一個叫做 newValue 的默認參數,代表即將存儲的新值。
  • didSet 觀察器會在新的值被存儲後被調用。它帶有一個叫做 oldValue 的默認參數,代表之前存儲的舊值。

注意點

  • 屬性觀察器可以添加到自己定義的存儲屬性上,也可以添加到繼承的屬性(無論是存儲屬性還是計算屬性)上。
  • 對於計算屬性,你不需要使用屬性觀察器,因為你可以直接在 setter 中觀察和響應值的變化。
  • 屬性觀察器不會在初始化過程中被調用,以避免在屬性尚未完全初始化時就被觸發。

下一題按鈕

if isAnswered {
// 如果當前問題已被回答,點擊按鈕將加載下一個問題
currentQuestionIndex += 1
displayCurrentQuestion()
isAnswered = false
return
}

在按下選項按鈕之後可以再按一次到下一題

1→2→3

簡要來說就是在還未按下按鈕前它是 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元素包含多個屬性,如gradesubject
  • .filter { ... }:這是一個高階函數,用於從allQuestions中篩選出符合特定條件的元素。.filter方法遍歷集合中的每一個元素,對每個元素應用提供的條件(即大括號{}中的代碼),並返回一個新的集合,其中只包含符合這些條件的元素。
  • $0:這是Swift中的閉包語法的一部分,代表當前被.filter方法遍歷的元素。在這個上下文中,$0代表allQuestions集合中的一個Question對象。
  • $0.grade == grade && $0.subject == subject:這是過濾條件。這行代碼的含義是,只有當Question對象的grade屬性等於grade變數的值,且Question對象的subject屬性等於subject變數的值時,這個Question對象才會被包含在返回的集合中。

結果,currentQuestions將是一個新的集合,其中只包含gradesubject屬性與給定的gradesubject變數相匹配的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
}
  1. 遍歷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,它是從當前questionoptions屬性(即問題的選項)複製而來的。
  • 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集合中的每個問題,對每個問題的選項進行隨機排序,並更新每個問題的正確答案索引,最後將這些更新過的問題放回原來的集合中。

--

--