作業#54 — Apple Tree

練習讀取 txt 資料、documentsDirectory 存取資料、搜索欄UISearchController、tableView 快速導航索引 sectionIndexTitles。

作業來源:

Preview

Apple Tree 分為兩大部分:猜單字遊戲、以及瀏覽單字庫。

> 猜單字遊戲:

> 瀏覽單字庫:

Topics

I. 讀取 txt 資料

*此處練習將檔案存在 Project Navigator 下,路徑為 Bundle.main:

if let url = Bundle.main.url(forResource: "檔名"), withExtension: "副檔名"){
do {
let data = try String(contentsOf: url) //這裡將 url 連結到的檔案資料轉為 String 格式
} catch {
print(error)
}
}

II. documentsDirectory 存取資料

> 寫入資料:

let encoder = JSONEncoder()
let data = try? encoder.encode(要寫入的東西)
let url = URL.documentsDirectory.appending(path: "自定義寫入位置名稱")
try? data?.write(to: url)

> 讀取資料:

let url = URL.documentsDirectory.appendingPathComponent("自定義寫入位置名稱")
if let data = try? Data(contentsOf: url) {
print(String(data: data, encoding: .utf8)) //這邊可以先 print 出 String 檢查資料內容
let decoder = JSONDecoder()
let item = try? decoder.decode(型別.self, from: data)
}

III. 搜索欄 UISearchController

這次練習使用在 tableViewController 中加入 UISearchController:

//class 要加入 protocol UISearchResultsUpdating
class VocabTableViewController: UITableViewController, UISearchResultsUpdating {

let searchController = UISearchController() //生成 UISearchController
var originalData = [String]() // 原始數據源
var filteredData = [String]() // 過濾後的數據源

override func viewDidLoad() {
super.viewDidLoad()

//originalData 範例
originalData = ["Apple", "Banana", "Cherry", "Date", "Fig", "Grape", "Kiwi", "Lemon"]

// 設置搜索控制器
searchController.searchResultsUpdater = self
navigationItem.searchController = searchController
}

func updateSearchResults(for searchController: UISearchController) {
if let searchText = searchController.searchBar.text, !searchText.isEmpty {
// 過濾原始數據,將符合搜索內容的元素加入 filteredData 中
filteredData = originalData.filter { $0.localizedStandardContains(searchText) }
} else {
// 如果搜索欄沒有被輸入,則顯示全部數據
filteredData = originalData
}

// 刷新 tableView
tableView.reloadData()
}

//生成表格:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return filteredData.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = filteredData[indexPath.row]
return cell
}
}

IV. tableView 快速導航索引 sectionIndexTitles

override func sectionIndexTitles(for tableView: UITableView) -> [String]? {
return sectionTitlesArray
}

Steps

I. 設置單字結構

英文單字檔案為 txt 格式,直接匯入 xcode 的 Project navigator 中。每個字母單獨一個檔案,共有 26 個檔案:

每一組單字包含英文、中文、英文例句、中文例句等資料。不同的單字以 “換行(\n)” 方式隔開,單詞/例句等資料則是以 “tab (\t)” 方式隔開。在解析此 txt 檔案時,便要用到偵測 \n 及 \t 這兩種符號來將各個細項資料隔開分別儲存。

資料解析方式參考學長 Shien 的此篇作業:

單字 struct 中,除了資料名稱變數(wordEng, wordChi, sentenceEng, sentenceChi) 之外,也設置了幾種 functions:getData (從 txt 中讀取資料)、formattedString(整理資料的字串格式)、saveWords(收藏單字)、loadSavedWords(讀取收藏的單字列表)等:

import Foundation

struct Vocabulary: Codable,Equatable {
//依照單字資訊設置變數
var wordEng: String?
var wordChi: String?
var sentenceEng: String?
var sentenceChi: String?

//從 txt 中讀取資料
//設置參數 alphabetArray,在使用 func 時設置要讀取的是哪個字母 or 全部字母開頭的單詞。最後匯出所有單字 -> [Vocabulary] 陣列
func getData(alphabetArray: [String]) -> [Vocabulary] {
var allVocabulary: [Vocabulary] = []
//讀取以 alphabetArray 中的字母開頭的所有單詞
for i in 0..<alphabetArray.count{
//txt 檔案在 project navigation 下,故直接使用 Bundle.main。檔名就是字母本身。
if let url = Bundle.main.url(forResource: String(alphabetArray[i]), withExtension: "txt"){
do {
//將 url 連結到的檔案資料轉為 String 格式
let allData = try String(contentsOf: url)
//因為不同的單字是以 "換行" 方式隔開,故使用 "\n" 偵測
let singleLine = allData.components(separatedBy: "\n")
for line in singleLine {
var vocab = Vocabulary()
//因為不同的資訊(中文/英文/例句)是以 "tab" 方式隔開,故使用 "\t" 偵測
let item = line.components(separatedBy: "\t")
//因為檔案單詞例句的格式有點混亂,故為了好看設置了 formattedString() 來統一整理。為確保資料結構完整,需要item.count >= 4 四個資訊都有被填入再進行格式整理
if item.count >= 4 {
vocab.wordEng = formattedString(item[0],0)
vocab.wordChi = formattedString(item[1],1)
vocab.sentenceEng = formattedString(item[2],2)
vocab.sentenceChi = formattedString(item[3],3)
//將整理好的單字加入 allVocabulary 陣列
allVocabulary.append(vocab)
}
}
} catch {
print(error)
}
}
}
//匯出所有單字陣列
return allVocabulary
}


//整理資料的字串格式
//匯入兩個參數:要整理的字串、以及對應的整理方法(使用其在 array 的位置來區分)。最後會匯出整理好的字串。
func formattedString(_ string: String, _ caseInt: Int) -> String {
//資料中例句有的前後會加 quotation mark ""、有的沒有。為了統一這邊移除所有的 " (" 前面要加 \ 才能被讀取為字串符號)。另外檢查資料時發現還有資料顯示 \r,這裡也直接移除。
var formattedString = string.replacingOccurrences(of: "\"", with: "").replacingOccurrences(of: "\r", with: "")

//對應的資料整理方法(使用其在 array 的位置來區分)
switch caseInt {
//資料中例句有的句子結束有加句號、有的沒有。故需統一整理:
case 2:
if !formattedString.contains(".") {
formattedString += "."
}
case 3:
if !formattedString.contains("。"){
formattedString += "。"
}
default:
break
}

//匯出整理好的字串
return formattedString
}

//收藏單字
//輸入參數 vocab = 要收藏的單字列表
static func saveWords(_ vocab: [String]) {
//使用 JSONEncoder 來編碼
let encoder = JSONEncoder()
let data = try? encoder.encode(vocab)
//設置 documentsDirectory 中指定位置,取名為 "savedWords"。
let url = URL.documentsDirectory.appending(path: "savedWords")
//將資料儲存入此位置
try? data?.write(to: url)
}

//讀取收藏的單字列表
static func loadSavedWords() -> [String]{
//從 documentsDirectory 中 "savedWords" 位置讀取之前儲存的資料
let url = URL.documentsDirectory.appendingPathComponent("savedWords")
var savedList: [String]?
//如果該位置中有資料,則從此位置中抓取資料,並解碼成 [String]
if let data = try? Data(contentsOf: url) {
//print(String(data: data, encoding: .utf8))
let decoder = JSONDecoder()
savedList = try? decoder.decode([String].self, from: data)
}
//匯出儲存的單字陣列。如果抓取失敗(url 位置沒有儲存的資料),則返回空陣列。
return savedList ?? []
}

}

*static func V.S. func

  • func:實例函數(Instance Functions)是與結構的特定實例相關聯的函數,可以訪問並修改該實例的屬性。
  • stactic func:靜態函數(Static Functions)是與結構的類型本身相關聯的函數,而不是特定的實例,它們不能訪問或修改結構的實例屬性。

以這個 struct Vocabulary 為例:

func getData() 因為內部有使用到 struct 中的另一個 function formattedString(),故無法設置為 static func 。在其他地方要使用此 function 時,便無法直接使用 Vocabulary 結構類型本身進行呼叫,會出現錯誤提示如下:

非 static func 必須事先生成 Vocabulary 的實例(Instance),才能使用:

而 static func loadSavedWords() 則可以在其他地方直接以 Vocabulary 進行呼叫:

II. 單字列表 — VocabTableViewController

1.設置 Sections

每個字母單獨設一個 section,共 26 個。每個 Section 都會有 title 標註當前瀏覽的字首字母,tableView 最右側則有 A-Z 的 section 目錄,可以快速滑動到指定字母區塊。

  //因為此頁面有設置搜索欄 UISearchController,故每個設置都有針對搜索情況/一般情況下去設計
override func numberOfSections(in tableView: UITableView) -> Int {
//搜索時設置
if filteredList?.isEmpty == false {
return 1
//一般情況下設置
} else {
//alphabetArray 就是 26 個字母組成的陣列,alphabetArray.count 其實這裡也可以直接回傳 26 😆
return alphabetArray.count
}
}

override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
//搜索時設置:不需要 section title。
if filteredList?.isEmpty == false {
return nil
//一般情況下設置:字首字母。
} else {
return alphabetArray[section]
}

}

//設置可以快速滑動的 section 目錄(tableView 最右側的 A-Z)
override func sectionIndexTitles(for tableView: UITableView) -> [String]? {
//搜索時設置:不需要 section 目錄
if filteredList?.isEmpty == false {
return nil
//一般情況下設置:顯示 A-Z 列表
} else {
return alphabetArray.compactMap { $0.capitalized }
}
}

*使用 map & $0 快速將字母 String 轉成 Array:

let alphabetString = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
alphabetArray = alphabetString.map{String($0)}

.map 可以對集合中的每個元素執行相同的操作,並返回一個新的集合。而 $0 則是在 closure 中代表陣列自身的每一個元素。

2. 設置 rows


override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
//搜索時設置:
if let filteredList, filteredList.isEmpty == false {
return filteredList.count
//一般情況下設置:
} else {
//更新當前顯示字首的所有單字陣列,詳細程式碼見下方 fileprivate func updateSelectedAlphabetArray(_ section: Int)
updateSelectedAlphabetArray(section)
return vocabularyArray.count
}

}




override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "VocabTableViewCell", for: indexPath) as! VocabTableViewCell
//搜索時設置:
if let filteredList, filteredList.isEmpty == false {
cell.vocabLabel.text = filteredList[indexPath.row].wordEng
//一般情況下設置:
} else {
updateSelectedAlphabetArray(indexPath.section)
cell.vocabLabel.text = vocabularyArray[indexPath.row].wordEng
}

return cell
}

//依照字首更新單字陣列
fileprivate func updateSelectedAlphabetArray(_ section: Int) {
////輸入參數 Int 表目前字首在 26 個字母中的第幾個。
let selectedAlphabet = [alphabetArray[section]]
//並使用 Vocabulary 結構的 func .getData() 來獲取該字首 txt 檔案中的所有單字存入 vocabularyArray 陣列
vocabularyArray = vocab.getData(alphabetArray: selectedAlphabet)
}

3. 點擊查看單字詳情

設置 segue 進行傳值,使得下一頁可以找出要顯示的內容是位在第幾個字母 txt 檔中的第幾個單詞。

@IBSegueAction func showVocabDetail(_ coder: NSCoder) -> DetailViewController? {
let controller = DetailViewController(coder: coder)
if let selectedIndexPath = tableView.indexPathForSelectedRow {
//搜索時設置:
if filteredList?.isEmpty == false {
//將所選取的 cell 字首位在 26 字母中的位置傳遞到下一頁
controller?.alphabetIndex = filteredAlphabetIndex
//找出所選取的 cell 單字為在此單字表中的第幾個,並傳送到下一頁
let vocab = filteredList?[selectedIndexPath.row]
let index = vocabularyArray.firstIndex (where: {$0 == vocab})
controller?.vocabIndex = index

//一般情況下設置:
} else {
//將所選取的 cell 位在的 section 及 row 傳遞到下一頁
controller?.alphabetIndex = selectedIndexPath.section
controller?.vocabIndex = selectedIndexPath.row
}
}

return controller
}

4. 設置搜索欄

//在 vivewDidLoad 中設置 UISearchController 實例,並將 .searchResultsUpdater 設置為 self。
override func viewDidLoad() {
super.viewDidLoad()

let searchController = UISearchController()
navigationItem.searchController = searchController
searchController.searchResultsUpdater = self
//將搜索欄設置為滾動時不會隱藏
navigationItem.hidesSearchBarWhenScrolling = false
}

//.searchResultsUpdater 設置為 self 後,xcode 會出現錯誤提示要求更新 class protocol。自動更新 class protocol,並生出下方 func updateSearchResults:
class VocabTableViewController: UITableViewController, UISearchResultsUpdating {
var filteredList: [Vocabulary]?
var filteredAlphabetIndex: Int?

func updateSearchResults(for searchController: UISearchController) {
filteredList = []
alphabetArray = alphabetString.map{String($0)}

if let searchText = searchController.searchBar.text {
//如果搜索欄不為空
if searchText.isEmpty == false {
//找出字首字母
let alphabet = String(searchText.prefix(1))
//依據該字首字母為在 26 個字母中的位置,找出該字母單字檔案
filteredAlphabetIndex = alphabetArray.firstIndex(where: { $0 == alphabet})
alphabetArray = [alphabet]
let vocab = Vocabulary()
vocabularyArray = vocab.getData(alphabetArray: [alphabet])

//在該字首內單字檔案中使用 filter 進行檢索
filteredList = vocabularyArray.filter({ vocab in
if let word = vocab.wordEng {
//.localizedStandardContains 檢查單字是否包含搜索欄內的字串。此方法可以無視大小寫進行檢索。return true 的話則加入 filteredList 陣列。
return word.localizedStandardContains(searchText)
} else {
return false
}
})
//如果搜索欄為空,則返回所有單字列表
} else {
alphabetArray = alphabetString.map{String($0)}
}
tableView.reloadData()
}
}

III. 收藏單字列表 — SavedListTableViewController

1.先將收藏單字按字母排序

override func viewDidLoad() {
super.viewDidLoad()
//讀取收藏的單字列表
savedWords = Vocabulary.loadSavedWords()

//收藏的單字列表中每兩個單字兩兩相比,依照字母順序進行排序
savedWords = savedWords?.sorted(by: { word1, word2 in
return word1.localizedStandardCompare(word2) == .orderedAscending
})

//排序完後重新保存回 documentsDirectory
if let savedWords {
Vocabulary.saveWords(savedWords)
}

2. tableView 生成畫面

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
//如果有讀取到收藏的單字列表
if let savedWords {
return savedWords.count
//沒有儲存的單字列表
} else {
return 0
}

}


override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "VocabTableViewCell", for: indexPath) as! VocabTableViewCell
//將收藏的單字一一顯示在 row 上
if let savedWords {
cell.vocabLabel.text = savedWords[indexPath.row]
}
return cell
}

3. 移除收藏單字

//使用 "commit" 左滑刪除
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
//在收藏的單字列表中移除,並存回 documentsDirectory
savedWords?.remove(at: indexPath.row)
if let savedWords {
Vocabulary.saveWords(savedWords)
}
tableView.reloadData()

}

//一鍵清空列表
@IBAction func removeAll(_ sender: Any) {
//移除收藏的單字列表中所有元素,並存回 documentsDirectory
savedWords?.removeAll()
Vocabulary.saveWords([])
tableView.reloadData()

}

4. 點擊查看單字詳情

//使用 segue 將英文單字 string 傳到單字詳情頁 DetailViewController
@IBSegueAction func showSavedVocab(_ coder: NSCoder) -> DetailViewController? {
let controller = DetailViewController(coder: coder)

if let savedWords, let indexPath = tableView.indexPathForSelectedRow {
controller?.wordEng = savedWords[indexPath.row]
}

return controller
}

5. 每次顯示畫面都刷新收藏單字列表

//使用 viewSillAppear 讓刷新收藏單字列表這個動作於畫面出現前完成
override func viewWillAppear(_ animated: Bool) {
savedWords = Vocabulary.loadSavedWords()
tableView.reloadData()

}

IV. 單字詳情 — DetailViewController

1.生成畫面

從個 controller 使用 segue 連到此頁面時,會傳入兩種資料:

  • 從 VocabTableViewController 傳入 indexPath.section 及 indexPath.row,可以直接找出要顯示的單詞是位在第幾個字母 txt 檔中的第幾個單詞,並使用左右箭頭切換上一單詞、下一單詞。
  • 從 SavedListTableViewController 以及後面會講到的 GameViewController 傳入英文單詞 String。需要使用先找出該單詞 String 對應的整筆 Vocabulary 資料 (包含英文單詞、中文單詞、英文例句、中文例句。),在一一顯示於畫面上。
override func viewDidLoad() {
super.viewDidLoad()

//如果 vocabIndex 不為空值(表示是從 VocabTableViewController 傳入的)
if let vocabIndex {
//根據傳入的 indexPath.section 及 indexPath.row 找出單字。updateAlphabet(更新字首單字列表) 及 updateVocab (更新單字)詳細城市在下方。
updateAlphabet()
updateVocab(index: vocabIndex)
//顯示可以切換上一頁/下一頁的按鈕
for button in switchPageButtons {
button.isHidden = false
}
//如果 vocabIndex 為空值(表示是從 SavedListTableViewController/ GameViewController 傳入的)
} else {
//使用傳入的單字找出字首
let firstAlphabet = wordEng.prefix(1).uppercased()
//找出該字首的單字檔案,以及該單字所在的位置
if let alphabetIndex = alphabetArray.firstIndex(where: { $0 == firstAlphabet}) {
vocabularyArray = vocab.getData(alphabetArray: [alphabetArray[alphabetIndex]])
if let vocabIndex = vocabularyArray.firstIndex(where: { $0.wordEng == wordEng}) {
updateVocab(index: vocabIndex)
}
}
//隱藏可以切換上一頁/下一頁的按鈕
for button in switchPageButtons {
button.isHidden = true
}
}

}

//更新字首單字列表
fileprivate func updateAlphabet() {

if let alphabetIndex {
selectedAlphabet = [alphabetArray[alphabetIndex]]
}
vocabularyArray = vocab.getData(alphabetArray: selectedAlphabet)
}

//更新單字
fileprivate func updateVocab(index: Int) {

wordEngLabel.text = vocabularyArray[index].wordEng
wordChiLabel.text = vocabularyArray[index].wordChi
sentenceEngLabel.text = vocabularyArray[index].sentenceEng
sentenceChiLabel.text = vocabularyArray[index].sentenceChi

//如果該單字有在收藏的單字列表中,則收藏按鈕為實心、無則為空心。
wordEng = vocabularyArray[index].wordEng
savedWords = Vocabulary.loadSavedWords()
if let savedWords {
if !savedWords.contains(wordEng) {
savedButton.image = UIImage(systemName: "bookmark")
} else {
savedButton.image = UIImage(systemName: "bookmark.fill")
}
}
}

2. 發音

var speaker = AVSpeechSynthesizer()


@IBAction func pronounce(_ sender: UIButton) {

if let string = wordEngLabel.text {
let utternce = AVSpeechUtterance(string: string)
utternce.voice = AVSpeechSynthesisVoice(language: "en_US")
speaker.speak(utternce)
}


}

3. 切換上一單字、下一單字

@IBAction func switchVocabulary(_ sender: UIButton) {
//因為左右按鈕連動到同一個 IBAction func,故使用 tag 區分。tag = 0 上一頁、 tag = 1 下一頁。
switch sender.tag {

//上一單字
case 0:
//如果該單字不是位於字首單字列表中的第一個,則 vocabIndex -= 1,並更新單字
if vocabIndex != 0 {
vocabIndex! -= 1
updateVocab(index: vocabIndex!)
//若該單字位於字首單字列表中的第一個,則更新字首單字列表到前一個字母,並更新單字至該列表最後一個單字。
} else {
alphabetIndex! -= 1
updateAlphabet()
vocabIndex = vocabularyArray.count-1
updateVocab(index: vocabIndex!)
}

//下一單字,
case 1:
if vocabIndex != vocabularyArray.count - 1 {
vocabIndex! += 1
updateVocab(index: vocabIndex!)
} else {
alphabetIndex! += 1
updateAlphabet()
vocabIndex = 0
updateVocab(index: vocabIndex!)
}

default:
break
}
}

4. 收藏單字

@IBAction func saveVocab(_ sender: UIBarButtonItem) {
//刷新目前收藏的單字咧表
savedWords = Vocabulary.loadSavedWords()

//如果收藏的單詞列表不為空值(*因為會在大括號內部修改收藏的單詞列表本身,故無法使用 if let,需使用 if var = 才能修改
if var savedWords = savedWords {
//如果收藏的單詞列表不包含該單詞,則加入新單詞、存入 documentsDirectory、並更改按鈕圖示
if !savedWords.contains(wordEng) {
savedWords.append(wordEng)
Vocabulary.saveWords(savedWords)
//因為按鈕在 navigation bar 上,型別是 UIBarButtonItem,無法使用 .setImage(),直接更改 .image 屬性即可。
sender.image = UIImage(systemName: "bookmark.fill")
//如果收藏的單詞列表已包含該單詞,則移除該單詞、更新 documentsDirectory、並更改按鈕圖示
} else {
if let index = savedWords.firstIndex (where:{ $0 == wordEng }) {
savedWords.remove(at: index)
Vocabulary.saveWords(savedWords)
}
sender.image = UIImage(systemName: "bookmark")
}
}
}

V. 遊戲頁面 — GameViewController

  1. 生成題庫
var questionBank : [String] = []

func getQuestionBank() {

let alphabetString = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
let alphabetArray = alphabetString.map({String($0)})

let vocab = Vocabulary()
let vocabArray = vocab.getData(alphabetArray: alphabetArray)

//使用 for in 迴圈將所有的單詞加入 questionBank 陣列
for i in 0..<vocabArray.count {
if let word = vocabArray[i].wordEng {
questionBank.append(word)
}
}

//移除 questionBank 中所有包含 空格 及線段 的單詞,以便後續遊戲進行
questionBank = questionBank.filter { string in
!string.contains(" ") && !string.contains("-")
}

}

2. 隨機生成題目

var questionWord = String()
var questionArray : [String] = []

fileprivate func createNewQuestion() {
//隨機選擇單詞作為題目
questionWord = questionBank[Int.random(in: 0..<questionBank.count)]
//將該單詞字串轉為陣列。例:Apple -> ["A","p","p","l","e"]
questionArray = questionWord.map({String($0)})
}

3. 生成回答欄位

let answerStackView = UIStackView()
var labelArray: [UILabel] = []

//設置回答欄位的 stackView
func createAnswerBlank() {
//在畫面上加入 stackView 放置回答欄位
wordsView.addSubview(answerStackView)
//使用程式碼設置 autoLayout 記得先將.translatesAutoresizingMaskIntoConstraints 設置成 false 避免佈局衝突!!
answerStackView.translatesAutoresizingMaskIntoConstraints = false
//使用 .activat([]) 一次設置所有要啟用的 autoLayout ,就不用每個 constraint 後面都再後綴 .isActive = true 啦。
NSLayoutConstraint.activate([
answerStackView.centerXAnchor.constraint(equalTo: wordsView.centerXAnchor),
answerStackView.bottomAnchor.constraint(equalTo: wordsView.centerYAnchor, constant: -20)
])
//設置此 answerStackView 置中對齊、水平排列、內容元素彼此間距等。
answerStackView.alignment = .center
answerStackView.axis = .horizontal
answerStackView.spacing = 10

createAnswerLabel()

}

//依照題目長度生成對應的欄位數量
fileprivate func createAnswerLabel() {
for _ in 1...questionWord.count {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 24)
label.text = "_"
//將所有 label 加入 stackView
answerStackView.addArrangedSubview(label)
//將所有 label 也加入一個 array 以便後續管理
labelArray.append(label)
}
}

再重點提醒自己一次:使用程式碼設置 auto layout 記得將 .translatesAutoresizingMaskIntoConstraints 設置為 false !!不然 autoLayout 跟 AutoresizingMask 佈局可能會衝突,導致畫面超級跑樣。

4. 設置鍵盤

//在 interface builder 上先用 stackView + autoLayout 設置好按鈕位置
@IBOutlet var keyboardButtons: [UIButton]!

fileprivate func setUpKeyboard() {
//依照鍵盤上字母排列順序生成陣列
let keyboardString = "QWERTYUIOPASDFGHJKLZXCVBNM"
let keyboardArray = keyboardString.map { String($0) }
//將所有按鈕 title 一一對應
for (i,button) in keyboardButtons.enumerated() {
button.setTitle(keyboardArray[i], for: .normal)
//因按鈕多、每個按鈕尺寸較小,故將按鈕上文字與按鈕邊界的距離設置為 0,title 文字才能完整顯示,不會被 margin 遮擋。
button.configuration?.contentInsets = .zero
//將按鈕設置為圓型
button.layer.cornerRadius = button.frame.height/2
button.clipsToBounds = true

//設置按鈕動作。這一段的詳細解釋見下方 7.點擊鍵盤猜單字
button.addAction(UIAction(handler: { _ in
if let char = button.titleLabel?.text?.lowercased() {
self.answerArray.append(char)
self.checkAnswerChar()
self.dropApple()
}
button.isEnabled = false
}), for: .touchUpInside)
}
}

5. 生成蘋果

蘋果樹直接使用 interface builder 設置好 image、 auto layout 的大小及位置。蘋果則因需重複生成七次、且後續會變更各自的 auto layout 位置,故直接使用程式碼生成比較方便。

var appleArray: [UIImageView] = []
var oldYConstraint:[NSLayoutConstraint] = []

func createApple() {

//頻果的預設位置(原本使用 autoLayout ,與 parentView 的 centerX & centerY 相比,使用 mutiplier 定位)
var xMultiplier:[CGFloat] = [1.4, 1.06, 0.9, 0.46, 0.86, 0.54, 1.34]
var yMultiplier:[CGFloat] = [0.54, 1.12, 0.76, 0.6, 0.4, 0.96, 0.9, 0.6]

//生成七顆頻果 imageView
for i in 0...6 {
let appleImageView = UIImageView()
appleImageView.image = UIImage(named: "apple")
treeView.addSubview(appleImageView)
//將蘋果圖片加入同一陣列,方便後續管理
appleArray.append(appleImageView)

//使用預設的 multiplier 算出要設置 autoLayout 的 constant
let constantX = (0.5 - (0.5*xMultiplier[i])) * treeView.frame.width
let constantY = ((0.5*yMultiplier[i])-0.5) * treeView.frame.height

//記得先將 .translatesAutoresizingMaskIntoConstraints 設置為 false!!
appleImageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
appleImageView.widthAnchor.constraint(equalTo: appleImageView.heightAnchor),
appleImageView.heightAnchor.constraint(equalTo:treeView.heightAnchor, multiplier: 0.17),
appleImageView.centerXAnchor.constraint(equalTo: treeView.centerXAnchor, constant: constantX)
])
//蘋果的高度後續會有變化,故獨立出來存成變數,並存入陣列中,方便後續使用
let yConstraint = appleImageView.centerYAnchor.constraint(equalTo: treeView.centerYAnchor, constant: constantY)
yConstraint.isActive = true
oldYConstraint.append(yConstraint)
}
}

6. 讀取最佳紀錄並更新畫面

最佳紀錄只有單純一個數值,故簡單使用 UserDefaults 進行儲存與讀取。

func loadBestRecord() {
//如果 UserDefaults 中有存入最佳紀錄,則讀取該數值
if let record = UserDefaults.standard.value(forKey: "bestRecord") as? Int {
bestRecord = record
//如果 UserDefaults 中還沒有紀錄,則返回 0
} else {
bestRecord = 0
}
}

//將最佳紀錄更新到畫面上
fileprivate func updateBestRecordLabel() {
var recordString = ""
//有最佳紀錄,則生成對應的蘋果數量
if bestRecord! > 0 {
for _ in 1...bestRecord! {
recordString += "🍎"
}
//沒有的話則是一顆空空的樹
} else {
recordString += "🌳"
}
lastRecordLabel.text = recordString
}

7. 點擊鍵盤猜單字

var answerArray : [String] = []
var charIndexArray: [Int] = []
var wrongBool = true

//在 4. 設置鍵盤 一段中,有設置鍵盤按鈕的 UIAction 如下:
button.addAction(UIAction(handler: { _ in
//將鍵盤上的字母轉成小寫,方便後續比對
if let char = button.titleLabel?.text?.lowercased() {
//將選中的鍵盤字母加入 answerArray
self.answerArray.append(char)
//檢查該字母是否有猜對。詳細程式碼見下方。
self.checkAnswerChar()
}


//檢查該字母是否有猜對。
func checkAnswerChar() {
//設置一個 wrongBool 值為 true,表示還沒有猜對
wrongBool = true
//如果新猜的字母與題目字串一一比對
if let character = answerArray.last {
for (i,char) in questionArray.enumerated() {
//如果有相符的字母
if char == character {
//則將相符的字母位置存入 charIndexArray
charIndexArray.append(i)
//並將 wrongBool 設置為 flase,表示猜對了
wrongBool = false
}
}
}

//比對過後,如果wrongBool 值仍為 true,表示還沒有猜對,則 wrongCount 加一 (會觸發蘋果掉落。詳細程式碼見8. 答錯掉落蘋果)
if wrongBool == true {
wrongCount += 1
}

//將被存入 charIndexArray 位置的 label(猜對的部分)更新為新猜的字母
for index in charIndexArray {
labelArray[index].text = answerArray.last
}
//清空 charIndexArray 以便進行下一輪比對
charIndexArray.removeAll()

//檢查猜對的字母數
var matchCount = 0
for label in labelArray {
if label.text != "_" {
matchCount += 1
}
}
//如果猜對的字母數與題目的字母數相同,表示完全猜對,遊戲結束;或是如果猜錯次數達到七次,表示蘋果掉光了,遊戲也結束。
if matchCount == questionArray.count || wrongCount == 7 {
//遊戲結束的詳細程式碼見下方 9. 結束遊戲
closeRound()
}

}

8. 答錯掉落蘋果

//比對字母後如果沒有猜對,則 wrongCount 加一。wrongCount 一但變動,便會觸發 func dropApple()
var wrongCount = 0 {
didSet{
dropApple()
}
}


func dropApple() {
if wrongCount > 0 {
//將一顆蘋果圖片原本設置的 y constraints 關閉
oldYConstraint[wrongCount-1].isActive = false
//設置動畫
UIView.animate(withDuration: 0.6) {
//設置新的 y constraints,使得蘋果圖片底部距離背景圖片底部 -8~0 的距離。
let constant = CGFloat.random(in: -8...0)
let yConstraint = self.appleArray[self.wrongCount-1].bottomAnchor.constraint(equalTo: self.treeView.bottomAnchor, constant: constant)
yConstraint.isActive = true
//一樣將此 y constraints 存入陣列,方便後續統一管理
self.newYConstraint.append(yConstraint)
//畫面刷新自動佈局
self.view.layoutIfNeeded()
}

//待蘋果掉落動畫快結束時,更改蘋果圖片為爛掉的蘋果
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
self.appleArray[self.wrongCount-1].image = UIImage(named: "appleWithShadow")
}
}


}

9. 結束遊戲

結束遊戲的條件有兩種:

  1. 如果猜對的字母數與題目的字母數相同,表示完全猜對,遊戲結束。
  2. 如果猜錯次數達到七次,表示蘋果掉光了,遊戲也結束。
func closeRound() {
//將鍵盤按鈕全部停用
for button in keyboardButtons {
button.isEnabled = false
}
//遊戲完成後會出現 "See Definition" 及 "Play Again" 按鈕及遊戲成功/失敗的文字
buttonStackView.isHidden = false
resultLabel.isHidden = false
//將此文字提到最上層,避免被程式碼生成的蘋果圖片遮擋
treeView.bringSubviewToFront(resultLabel)

//如果猜錯七次,遊戲失敗。回答欄位顯示正確答案、文字顏色變紅、出現遊戲失敗的對應文字
if wrongCount == 7 {
for (i,label) in labelArray.enumerated() {
label.text = questionArray[i]
label.textColor = .red
resultLabel.text = "All the apples are rotten!"
}
//如果成功猜對單字,則出現遊戲成功的對應文字
} else {
resultLabel.text = "Both you and the tree\n are so fruitful!"
}

//檢查樹上剩下的蘋果數量。如果大於最佳紀錄,則刷新紀錄。
let remainingApple = 7 - wrongCount
if remainingApple > bestRecord! {
bestRecord = remainingApple
UserDefaults.standard.set(bestRecord, forKey: "bestRecord")
updateBestRecordLabel()
}
}

10. 設置遊戲完成後 “查看單字” 及 “再玩一次” 按鈕

let buttonStackView = UIStackView()

fileprivate func setUpButtons() {
//設置按鈕的 stackView 方便設置 autoLayout 定位
wordsView.addSubview(buttonStackView)
buttonStackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
buttonStackView.centerXAnchor.constraint(equalTo: wordsView.centerXAnchor),
buttonStackView.topAnchor.constraint(equalTo: wordsView.centerYAnchor, constant: 0)
])
buttonStackView.axis = .horizontal
buttonStackView.alignment = .center
buttonStackView.spacing = 40

//"查看單字"按鈕
let definitionButton = UIButton()
definitionButton.configuration = .filled()
definitionButton.setTitle("See Definition", for: .normal)
definitionButton.addAction(UIAction(handler: { _ in
//點擊按鈕後會跳轉 controller 至單字詳情頁,並將單字進行傳值
guard let controller = self.storyboard?.instantiateViewController(withIdentifier: "\(DetailViewController.self)") as? DetailViewController else {return}
controller.wordEng = self.questionWord
self.navigationController?.pushViewController(controller, animated: true)
}), for: .touchUpInside)
buttonStackView.addArrangedSubview(definitionButton)

//"再玩一次" 按鈕
let playAgainButton = UIButton()
playAgainButton.configuration = .filled()
playAgainButton.setTitle("Play Again", for: .normal)
playAgainButton.addAction(UIAction(handler: { _ in
//playAgain的詳細程式碼見下方 11.再玩一次
self.playAgain()
}), for: .touchUpInside)
buttonStackView.addArrangedSubview(playAgainButton)
}

11. 再玩一次

    func playAgain() {
//將回答欄位所有的 label 先從 stack View 中移除
for label in labelArray {
label.removeFromSuperview()
}
labelArray.removeAll()

//重新啟用鍵盤按鈕
for button in keyboardButtons {
button.isEnabled = true
}

//隱藏遊戲結束時出現的 "查看單字" & "再玩一次" 按鈕及文字
buttonStackView.isHidden = true
resultLabel.isHidden = true

//移除前一道題目及回答的字串陣列
questionArray.removeAll()
answerArray.removeAll()

//重新生成題目以及空白回答欄位
createNewQuestion()
createAnswerLabel()

//錯誤次數歸零
wrongCount = 0

//將蘋果圖片改回正常的蘋果,並恢復原位。
for apple in appleArray {
apple.image = UIImage(named: "apple")
}
for constraint in newYConstraint {
constraint.isActive = false
}
for constraint in oldYConstraint {
constraint.isActive = true
}
view.layoutIfNeeded()


}

--

--