Preview

此次練習同時設置了選擇題及問答題,還有會評分的測驗題:

選擇題/測驗題/問答題

Topics

I. struct

結構(Structures)是一種用於定義和組織相關數據的重要工具。以下是一些有關 Swift 結構的基本用法和特點:

  1. 定義結構:使用 struct 關鍵字可以定義一個結構。結構可以包含屬性(properties)、方法(methods)和初始化器(initializers)。例如:
struct Person {
var name: String
var age: Int

func greet() {
print("Hello, my name is \(name) and I'm \(age) years old.")
}
}

2. 創建實例:可以使用結構的初始化器來創建結構的實例。例如:

let person = Person(name: "John", age: 30)

3. 屬性訪問:可以使用點運算符來訪問結構的屬性。

print(person.name)  // 輸出:"John"
print(person.age) // 輸出:30

4. 結構方法:結構可以定義屬於自身的方法。可以使用實例上的點運算符來調用結構方法。

person.greet()  // 輸出:"Hello, my name is John and I'm 30 years old."

5. 值類型:結構是值類型(Value Types),這意味著當你將一個結構賦值給另一個變量或常數,或者將結構傳遞給函數時,它們會被複製。每個複製的實例是獨立的,對一個實例的更改不會影響其他實例。

var person1 = Person(name: "John", age: 30)
var person2 = person1

person2.name = "Alice"
print(person1.name) // 輸出:"John"
print(person2.name) // 輸出:"Alice"

II. Codable csv

要匯入大量資料庫時,一個一個宣告會比較複雜、程式碼也會變得很長很長。這是可以直接將資料表格以 .csv 檔輸出後,在 xcode 中使用 CodableCSV 解析。匯入方式參考彼得潘的教學文章:

III. array.filter()

filter() 是 Swift 中陣列(Array)的一個常用方法,用於根據指定的條件篩選出符合條件的元素,並返回一個新的陣列。

假設我們有一個存儲了一組整數的陣列,我們可以使用 filter() 方法來過濾出大於 5 的元素:

let numbers = [2, 5, 8, 10, 4, 7]

let filteredNumbers = numbers.filter { $0 > 5 }
print(filteredNumbers) // 輸出:[8, 10, 7]

在上述範例中,我們定義了一個閉包 { $0 > 5 },該閉包用於判斷元素是否大於 5。filter() 方法將應用該閉包來遍歷陣列中的每個元素,並返回一個新的陣列 filteredNumbers,其中僅包含大於 5 的元素。

我們也可以使用更複雜的判斷條件。例如,假設我們有一個存儲了一組字串的陣列,我們可以使用 filter() 方法過濾出長度大於 3 且以特定字母開頭的字串:

let words = ["apple", "banana", "cat", "dog", "elephant"]

let filteredWords = words.filter { word in
word.count > 3 && word.hasPrefix("a")
}
print(filteredWords) // 輸出:["apple"]

如果是 arrays of struct,篩選方式如下:

let person1 = Person(name: "John", age: 30)
people.append(person1)
let person2 = Person(name: “Mary”, age: 22)
people.append(person2)
let person3 = Person(name: “Lily, age: 12)
people.append(person3)

let filteredPeople = people.filter { $0.age > 25}

print(filteredPeople) // 輸出:[Person(name: "John", age: 30)]

*在 Swift 的閉包表達式中,$0 是一種特殊的語法,用於代表閉包中的第一個參數。當使用 filtermapreduce 等高階函式時,您可以使用 $0 來引用閉包的第一個參數。

Steps

1. struct 建立資料格式、匯入資料庫

先建立一個新的 swift file,用以創建 struct 結構定義。

struct Poems :Codable { //要記得加上 "Codable" !!
let title: String //詩名
let poet: String //作者
let organization: String //詩體
let content: String //內文
let question: String //問題
let answer: String //答案
}

將資料以表格整理好,確認每個表格的 title 符合 struct 中自定義的property name 後,以 .csv 檔案格式匯出並加入 xcode Assets 內。

extension Poems {
static var data: [Self] {
var array = [Self]()
if let data = NSDataAsset(name: "poem300")?.data {
let decoder = CSVDecoder {
$0.headerStrategy = .firstLine
}
do {
array = try decoder.decode([Self].self, from: data)
} catch {
print(error)
}
}
return array
}
}

NSDataAsset(name: "")? 中改為 csv 檔案名稱。不過不知為何我最後一欄匯入一直有問題 (Xcode 顯示 “didn’t match any CSV header.”) ,乾脆多加了一欄比較沒有意義的欄位一起匯入,但不建立它的 property,就成功讀取到所有需要的資料啦。

最後在 view controller 中加上 let array名稱 = struct名稱.data 就完成啦。後續使用方法就跟 array 一樣了。

class ViewController: UIViewController {
let poemsDataSet = Poems.data
......
}

2. 作答模式

使用 Segmented Control 建立三種模式 — — 選擇、測驗、問答。

a. 選擇題:四選一,每次隨機出題( index = Int.random(in: 0...poemsDataSet.count-1) ,可以無限玩下去。

b. 測驗題:也是選擇題,每題答對得十分,完成後會出現分數。共出十題,題目不重複,故 index 設置如下:

if index < 9 { 
index += 1
} else {
showTestResult()
}

c. 問答題:不會出現選項,而只有一個 “顯示答案” 按鈕讓使用者可以查看答案。這裡也是隨機出題( index = Int.random(in: 0...poemsDataSet.count-1)

3. 選擇題

a. 問題

直接從 [Poems] 中隨機 index 出題。

fileprivate func setTheQuestion(dataSet: [Poems] ) {
//問題
questionLabel.text = dataSet[index].question
answerLabel.text = dataSet[index].answer
......
}

b. 選項

目標為:四個選項隨機排列、且其中之一為正確答案。

故先將正確答案從 [Poems] array 中移除後(preAnswerSelection),篩選同樣字數的 [Poems] 形成新的 array (semiAnswerSelection),重新隨機排列後取前三組資料建立成最終的 array (finalAnswerSelection),再將正確答案加入後再次重新隨機排列,即可得出。

最後再使用 for (index, buttons) in choiceButtons.enumerated() { ... } 將答案依次放到選項按鈕上。

//選項
fileprivate func setTheQuestion(dataSet: [Poems] ) {
......
var preAnswerSelection = poemsDataSet
if let correctAnswerIndex = preAnswerSelection.firstIndex(where: {$0.answer == dataSet[index].answer}) {
preAnswerSelection.remove(at: correctAnswerIndex)
}
var semiAnswerSelection = preAnswerSelection.filter {
$0.lens == dataSet[index].lens
}
var finalAnswerSelection = semiAnswerSelection.shuffled().prefix(3)
finalAnswerSelection.append(dataSet[index])
finalAnswerSelection.shuffle()

for (index, buttons) in choiceButtons.enumerated() {
buttons.setTitle(finalAnswerSelection[index], .normal)
}
......
}

當使用者選擇按鈕後,畫面會出現答題結果 — — 選擇正確為 ✓,選擇錯誤則為 ✗,並呈現出正確答案。

@IBAction func selectAnswer(_ sender: UIButton) {

//顯示答題結果
answerLabel.isHidden = false
correctnessLabel.isHidden = false

//讓 ✓ or ✗ 出現在選項的對應位置
let buttonX = sender.frame.origin.x
let buttonY = sender.frame.origin.y
correctnessLabel.frame = CGRect(x: buttonX+120, y: buttonY-35, width: 240, height: 130)

// 選擇正確為 ✓,選擇錯誤則為 ✗。
if sender.titleLabel!.text == answerLabel.text {
correctnessLabel.text = correctnessSymbol[0]
} else {
correctnessLabel.text = correctnessSymbol[1]
}

//將其他選項設為不可選
for buttons in choiceButtons {
buttons.isEnabled = false
}
sender.isEnabled = true

}

c. 下一題

點選下一題,則再次重新隨機出題。

@IBAction func goToNextQuestion(_ sender: UIButton) {

if practiceSegmentedControl.selectedSegmentIndex == 0 {
index = Int.random(in: 0...poemsDataSet.count-1)
setTheQuestion(dataSet: poemsDataSet)
}
}

4. 測驗題

a. 問題

做測驗題時,需要從 [Poems] 中選擇不重複的 10 組資料,故使用 shuffled() & preflix() 來建立新的 array。要留意 preflix 之後回傳的型別只是 ArraySlice,若要將其更改回 Array 型別,則可在前面 Array() 轉換,不然會顯示錯誤提示。

ChatGPT 的解釋:

ArraySlice 是一種與 Array 類似的型別,用於表示對現有 Array 片段的引用。當您對 Array 使用像是 prefixsuffix 或者切片操作符 [] 時,會返回一個 ArraySlice,而不是新的 Array

ArraySlice 的行為與 Array 相似,可以進行迭代、修改元素等操作,但它僅表示原始 Array 片段的引用,並不擁有自己的內存空間。

fileprivate func testMode() {
index = 0
score = 0

tenQuestionSelection = Array(poemsDataSet.shuffled().prefix(10))
setTheQuestion(dataSet: tenQuestionSelection)

......
}

此外,共出十題,故有設置進度條 Progress View。Progress View 的 value 位於 0~1 之間,型別為 Float。故我們需要將 index ( Int ) 先轉匯為 Float 並除以 10.0,以在 progress view 上顯示:

fileprivate func testMode() {
......
testProgressView.progress = Float(index) / 10.0
questionNumber.text = "第 \(index+1) 題"

}

b. 選項

選項設置方式與選擇題如出一徹,除了多一個 score 變數,每次答對會加 10 分:

// 選擇正確為 ✓,選擇錯誤則為 ✗。
if sender.titleLabel!.text == answerLabel.text {
correctnessLabel.text = correctnessSymbol[0]
score += 10
} else {
correctnessLabel.text = correctnessSymbol[1]
}

c. 下一題

總共有十題,在前九題點選 “下一題” 選項時, index 會增加,並顯示下一組題目。 Progress View 的進度條也會隨之更新。

if index < 9 {          
index += 1
setTheQuestion(dataSet: tenQuestionSelection)

questionNumber.text = "第 \(index+1) 題"
testProgressView.progress = Float(index)/10.0
}

當答完最後一題後,“下一題” 按鈕會消失、取而代之的是 “再玩一次” 按鈕。 此外,也會顯示答題結果以及最終得分:

else {
//隱藏 "下一題"、出現 "再玩一次"
sender.isHidden = true
playAgainButton.isHidden = false
// progress view = 100%
testProgressView.progress = 1
//顯示測驗結果
testResultView.isHidden = false
scoreLabel.text = String(score)

}
}

d. 再玩一次

套用前面出題的公式再次出題即可。

@IBAction func playAgain(_ sender: UIButton) {
sender.isHidden = true
testMode()
}

5. 問答題

a. 問題

同選擇題出題方式。

b. 顯示答案

點擊 “顯示答案” 按鈕後,顯示出 answerLabel 即可。

@IBAction func showAnswer(_ sender: Any) {
answerLabel.isHidden = false
}

c. 下一題

類選擇題按鈕動作。

@IBAction func goToNextQuestion(_ sender: UIButton) {

if practiceSegmentedControl.selectedSegmentIndex == 2 {
index = Int.random(in: 0...poemsDataSet.count-1)
setTheQuestion(dataSet: poemsDataSet)
}
}

6. 查看全文、語音朗讀

回答問題後,畫面上會出現 “查看全文” 按鈕,點擊後會顯示出詩句的原文、詩名及作者。

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
//全詩
titleTextView.text = dataSet[index].title
poetLabel.text = dataSet[index].poet
contentTextView.text = dataSet[index].content
fullPoemView.isHidden = true //等點擊 "查看全文" 按鈕才會顯示
}

@IBAction func showFullPoem(_ sender: UIButton) {
fullPoemView.isHidden = false

}
@IBAction func hideFullPoem(_ sender: Any) {
fullPoemView.isHidden = true
speaker.stopSpeaking(at: .immediate)

}

此外,也有設置一個小喇叭按鈕,點擊後將文本轉換為語音朗讀詩詞:

import AVFAudio

class ViewController: UIViewController {
let speaker = AVSpeechSynthesizer()
override func viewDidLoad() {
super.viewDidLoad()
}
func reader (string: String) {

let poemReader = AVSpeechUtterance(string: string)
poemReader.voice = AVSpeechSynthesisVoice(language: "zh-TW")
speaker.speak(poemReader)
}

@IBAction func readPoem(_ sender: Any) {

reader(string: titleTextView.text!)
reader(string: poetLabel.text!)
reader(string: contentTextView.text)
}

畫面效果:

appetize.io

來玩玩看唐詩三百首還記得多少吧~ ( 不過這個模擬器好像沒辦法播出聲音 :/ )

*appetize.io 免費帳戶一個月只有 50 分鐘,如果出現 “Streaming for this account has been temporarily disabled” 就是這個月的額度被用完啦😆 要等下個月一號才會再重新開始計算。

其他參考資料

> 背景雲紋:
<a href=”https://zh.lovepik.com/images/png-1234797.html">Moire Png vectors by Lovepik.com</a>

> 按鈕修改字體:
https://stackoverflow.com/questions/48487305/how-to-change-uibutton-attributed-text-colour-programatically

完整程式碼

//
// ViewController.swift
// Poem 300
//
// Created by 陳佩琪 on 2023/5/17.
//

import UIKit
import AVFAudio



class ViewController: UIViewController {

let themeBlue = UIColor(red: 36/255, green: 60/255, blue: 112/255, alpha: 1)
let themeYellow = UIColor(red: 227/255, green: 172/255, blue: 60/255, alpha: 1)
let font:UIFont? = UIFont(name: "LXGWWenKai-Regular", size: CGFloat(20))

let speaker = AVSpeechSynthesizer()

let poemsDataSet = Poems.data
var tenQuestionSelection = [Poems]()
var index = 0


@IBOutlet var practiceSegmentedControl: UISegmentedControl!


@IBOutlet var questionLabel: UILabel!
@IBOutlet var answerLabel: UILabel!

@IBOutlet var choiceButtons: [UIButton]!
@IBOutlet var functionButtons: [UIButton]!
let functionButtonText = ["下一題 ▸","查看全詩 ▸","◂ 返回","再玩一次 ▸"]

@IBOutlet var showFullPoemButton: UIButton!
@IBOutlet var nextButton: UIButton!

@IBOutlet var playAgainButton: UIButton!


@IBOutlet var correctnessLabel: UILabel!
let correctnessSymbol = ["✓","✗"]

@IBOutlet var testStatusView: UIView!
@IBOutlet var testProgressView: UIProgressView!
@IBOutlet var questionNumber: UILabel!

@IBOutlet var testResultView: UIView!
@IBOutlet var scoreLabel: UILabel!
var score = 0



//full poem
@IBOutlet var fullPoemView: UIView!
@IBOutlet var titleTextView: UITextView!
@IBOutlet var poetLabel: UILabel!
@IBOutlet var contentTextView: UITextView!


@IBOutlet var cloudPattern: UIImageView!
@IBOutlet var cloudPatternOnResult: UIImageView!
@IBOutlet var cloudPatternOnFullPoem: UIImageView!

//QA view

@IBOutlet var questionAnswerView: UIView!




fileprivate func setTheQuestion(dataSet: [Poems] ) {
//問答
questionLabel.text = dataSet[index].question
answerLabel.text = dataSet[index].answer
answerLabel.isHidden = true
correctnessLabel.isHidden = true
testResultView.isHidden = true
showFullPoemButton.isHidden = true
nextButton.isHidden = false
playAgainButton.isHidden = true
questionAnswerView.isHidden = true


for buttons in choiceButtons {
buttons.isEnabled = true
}

var preAnswerSelection = poemsDataSet
if let correctAnswerIndex = preAnswerSelection.firstIndex(where: {$0.answer == dataSet[index].answer}) {
preAnswerSelection.remove(at: correctAnswerIndex)
}
var semiAnswerSelection = preAnswerSelection.filter {
$0.lens == dataSet[index].lens
}
var finalAnswerSelection = semiAnswerSelection.shuffled().prefix(3)
finalAnswerSelection.append(dataSet[index])
finalAnswerSelection.shuffle()

for (index, buttons) in choiceButtons.enumerated() {

let attString:NSMutableAttributedString = NSMutableAttributedString(string: finalAnswerSelection[index].answer, attributes: [.font:font!])
buttons.setAttributedTitle(attString, for: .normal)
}

//全詩
titleTextView.text = dataSet[index].title
poetLabel.text = dataSet[index].poet
contentTextView.text = dataSet[index].content
fullPoemView.isHidden = true
}



override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.


//設置 segmented control 字型
let normalAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: themeBlue,
.font: UIFont(name: "LXGWWenKai-Regular", size: 16)!
]
practiceSegmentedControl.setTitleTextAttributes(normalAttributes, for: .normal)

testStatusView.isHidden = true


index = Int.random(in: 0...poemsDataSet.count-1)
setTheQuestion(dataSet: poemsDataSet)


for (index, buttons) in functionButtons.enumerated() {

let attString:NSMutableAttributedString = NSMutableAttributedString(string: functionButtonText[index], attributes: [.font:font!])
buttons.setAttributedTitle(attString, for: .normal)
}


cloudPattern.alpha = 0.08
cloudPatternOnResult.alpha = 0.08
cloudPatternOnFullPoem.alpha = 0.08


}



fileprivate func testMode() {
index = 0
score = 0

tenQuestionSelection = Array(poemsDataSet.shuffled().prefix(10))
setTheQuestion(dataSet: tenQuestionSelection)

testStatusView.isHidden = false
testProgressView.progress = Float(index)/10.0
questionNumber.text = "第 \(index+1) 題"


}


@IBAction func selectSegmentedControl(_ sender: Any) {
if practiceSegmentedControl.selectedSegmentIndex == 0 {
index = Int.random(in: 0...poemsDataSet.count-1)
setTheQuestion(dataSet: poemsDataSet)

testStatusView.isHidden = true
questionAnswerView.isHidden = true

} else if practiceSegmentedControl.selectedSegmentIndex == 1 {
questionAnswerView.isHidden = true
testMode()

} else {

index = Int.random(in: 0...poemsDataSet.count-1)
setTheQuestion(dataSet: poemsDataSet)

testStatusView.isHidden = true
questionAnswerView.isHidden = false
}

}




@IBAction func selectAnswer(_ sender: UIButton) {

answerLabel.isHidden = false
correctnessLabel.isHidden = false
showFullPoemButton.isHidden = false

let buttonX = sender.frame.origin.x
let buttonY = sender.frame.origin.y

correctnessLabel.frame = CGRect(x: buttonX+120, y: buttonY-35, width: 240, height: 130)

if sender.titleLabel!.text == answerLabel.text {
correctnessLabel.text = correctnessSymbol[0]
score += 10
} else {
correctnessLabel.text = correctnessSymbol[1]
}

for buttons in choiceButtons {
buttons.isEnabled = false
}
sender.isEnabled = true

}




@IBAction func showFullPoem(_ sender: UIButton) {
fullPoemView.isHidden = false

}

@IBAction func hideFullPoem(_ sender: Any) {
fullPoemView.isHidden = true
speaker.stopSpeaking(at: .immediate)

}


@IBAction func goToNextQuestion(_ sender: UIButton) {

if practiceSegmentedControl.selectedSegmentIndex == 0 {
index = Int.random(in: 0...poemsDataSet.count-1)
setTheQuestion(dataSet: poemsDataSet)
} else if practiceSegmentedControl.selectedSegmentIndex == 1 {
if index < 9 {

index += 1
setTheQuestion(dataSet: tenQuestionSelection)

questionNumber.text = "第 \(index+1) 題"
testProgressView.progress = Float(index)/10.0

print(index, tenQuestionSelection[index].question)

} else {
//顯示測驗結果
sender.isHidden = true
showFullPoemButton.isHidden = true
playAgainButton.isHidden = false
testProgressView.progress = 1
correctnessLabel.isHidden = true
testResultView.isHidden = false
scoreLabel.text = String(score)

}
} else if practiceSegmentedControl.selectedSegmentIndex == 2 {
index = Int.random(in: 0...poemsDataSet.count-1)
setTheQuestion(dataSet: poemsDataSet)
questionAnswerView.isHidden = false
}
}


@IBAction func playAgain(_ sender: UIButton) {
sender.isHidden = true
testMode()
}


@IBAction func showAnswer(_ sender: Any) {
answerLabel.isHidden = false
showFullPoemButton.isHidden = false
correctnessLabel.isHidden = true
}




func reader (string: String) {

let poemReader = AVSpeechUtterance(string: string)
poemReader.voice = AVSpeechSynthesisVoice(language: "zh-TW")
speaker.speak(poemReader)
}

@IBAction func readPoem(_ sender: Any) {

reader(string: titleTextView.text!)
reader(string: poetLabel.text!)
reader(string: contentTextView.text)
}



}

--

--