App Development with Swift 程式範例重點整理

自訂的類別繼承 iOS SDK 內建類別時,類別名常以父類別名稱結尾。

class QuestionViewController: UIViewController
class ResultsViewController: UIViewController
class AddEditEmojiTableViewController: UITableViewController

表單 controller 的命名

使用 form,add,edit 等關鍵字。

class BookFormTableViewController: UITableViewController
class AddEditEmojiTableViewController: UITableViewController
class AthleteFormViewController: UIViewController
class AddRegistrationTableViewController: UITableViewController

detail 頁面的 controller 名稱

東西名加 Detail。

class FurnitureDetailViewController: UIViewController

畫面上的 UI 元件,變數名稱結尾和型別有關。

@IBOutlet weak var questionLabel: UILabel!
@IBOutlet weak var multipleStackView: UIStackView!
@IBOutlet weak var rangedSlider: UISlider!
@IBOutlet weak var questionProgressView: UIProgressView!

array 型別的變數名加 s。

var questions: [Question]
var answersChosen: [Answer]
var responses: [Answer]!
var meals: [Meal]

UI 元件事件觸發的 function 名和事件有關。

@IBAction func singleAnswerButtonPressed(_ sender: UIButton)
@IBAction func multipleAnswerButtonPressed()
@IBAction func rangedAnswerButtonPressed()
@IBAction func sliderChanged(_ sender: UISlider)

button 按下的 function 常以 Pressed 或 Tapped 結尾。

@IBAction func rangedAnswerButtonPressed()
@IBAction func editButtonTapped(_ sender: UIBarButtonItem)

將設定畫面內容的程式定義成 update 開頭的 function

controller 一般會有一段設定畫面內容的程式,而且會在多個時候設定,比方 viewDidLoad & viewWillAppear,或是抓到網路上的資料後。所以你可將設定畫面內容的程式另外定義成 update 開頭的 function,如此需要設定畫面內容時,只要呼叫此 function 即可。

UpdateUI: 設定 controller 的畫面。

override func viewDidLoad() {
   super.viewDidLoad()
   updateUI()
}
func nextQuestion() {
   questionIndex += 1
   if questionIndex < questions.count {
      updateUI()
   } else {
      performSegue(withIdentifier: "ResultsSegue", sender: nil)
   }
}

updateSingleStack: 設定 stack view 的內容。

func updateSingleStack(using answers: [Answer]) {
   singleStackView.isHidden = false
   singleButton1.setTitle(answers[0].text, for: .normal)
   singleButton2.setTitle(answers[1].text, for: .normal)
   singleButton3.setTitle(answers[2].text, for: .normal)
   singleButton4.setTitle(answers[3].text, for: .normal)
}

updateView: 設定 controller 的畫面。

func updateView() {
   didFinishLaunchingLabel.text = "The app has launched \(launchCount) times"
   willResignActiveLabel.text = "applicationWillResignActive has been called \(resignActiveCount) times"
}
override func viewDidLoad() {
   super.viewDidLoad()
   updateView()
}

另外像設定 cell 內容的程式也常寫成 update 開頭的 function,詳情請看待會的說明。

將設定 cell 顯示內容的程式定義成 function

方法1: 在自訂 cell 裡定義名稱以 update 開頭的 function

為了減少 tableView(_:cellForRowAt:) 的程式碼,我們可在自訂的 cell 類別裡定義設定內容的 function,名稱以 update 開頭,參數為要顯示的 model 資料。

4.6 Intermediate table views

class EmojiTableViewCell

將外部參數名取名為 with,讓程式更好懂。

   func update(with emoji: Emoji) {
      symbolLabel.text = emoji.symbol
      nameLabel.text = emoji.name
      descriptionLabel.text = emoji.detailDescription
   }

class EmojiTableViewController

   override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell = tableView.dequeueReusableCell(withIdentifier: "EmojiCell", for: indexPath) as! EmojiTableViewCell
      let emoji = emojis[indexPath.row]
      cell.update(with: emoji)
      cell.showsReorderControl = true
      return cell
   }

其它例子: 4.6 Lab — Favorite books

class BookTableViewCell: UITableViewCell {
   func update(with book: Book) {
   }
}

方法2: 在 controller 裡定義設定 cell 內容的 function,如以下例子裡的 configure(cell:forItemAt:)。

Unit 5 Guided Project Restaurant

class CategoryTableViewController

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

代表第幾個的變數名以 Index 結尾。

比方 array 裡儲存問題,利用 questionIndex 儲存使用者目前回答到第幾個問題。

var questionIndex = 0

常以 struct 定義資料(model)型別。

struct Question {
   var text: String
   var type: ResponseType
   var answers: [Answer]
}

大量使用 stack view

Apple 書本裡大量使用 stack view,因為大部分的 App 畫面都是單純地水平或垂直排列,很適合以 stack view 實作,大幅減少需要手動設定 auto layout 條件。

Unit 2 Guided Project Apple Pie

Unit 3 Guided Project Personality quiz

代表次數的變數名以 count 結尾。

var launchCount = 0

儲存資料的 function 以 save 開頭

func saveMeals()
static func saveToFile(emojis: [Emoji])

讀取資料的 function 以 load 開頭

func loadMeals()
static func loadFromFile() -> [Emoji]?

列表頁面 & detail 頁面

列表頁面:

通常使用 table view controller,利用 property array 儲存列表顯示的東西。

class MealListTableViewController: UITableViewController {
   var meals: [Meal] = []

detail 頁面:

通常使用 view controller,利用 optional 的 property 儲存顯示的東西,property 的內容則由前一頁的列表 controller 傳過來,比方在列表 controller 的 function prepare 設定。

class MealDetailViewController: UIViewController {
   var meal: Meal?

資料或結果頁面常見的程式寫法

接收前一頁傳來的資料,顯示某個資料的 detail,或是接收前一頁傳來的結果,比方顯示心理測驗或遊戲的結果。

定義 optional 的 property 儲存前一頁傳來的資料,定義 function updateView,在 updateView 裡將 property 的內容顯示到畫面上。

4.3 Model View Controller 的 Lab Favorite Athletes

利用 guard 檢查 optional 的 property 是否有值。比方以下的 AthleteFormViewController 也可當成新增資料的頁面,因此在新增資料時 athlete 會是 nil。

class AthleteFormViewController: UIViewController {
   var athlete: Athlete?
   @IBOutlet weak var nameTextField: UITextField!
   @IBOutlet weak var ageTextField: UITextField!
   @IBOutlet weak var leagueTextField: UITextField!
   @IBOutlet weak var teamTextField: UITextField!
   override func viewDidLoad() {
      super.viewDidLoad()
      updateView()
   }
   func updateView() {
      guard let athlete = athlete else {return}
      nameTextField.text = athlete.name
      ageTextField.text = athlete.age
      leagueTextField.text = athlete.league
      teamTextField.text = athlete.team
   }
}

如果 detail 的 controller 頁面沒有要做新增修改,只是要顯示前一頁傳過來的東西,也可以將 property 以 Implicitly Unwrapped Optional ! 宣告。

Unit 3 guided project personality quiz

class ResultsViewController

var responses: [Answer]!
override func viewDidLoad() {
   super.viewDidLoad()
   navigationItem.hidesBackButton = true
   calculatePersonalityResult()
}

利用 unwind segue 返回之前頁面和回傳資料

比方可在 unwind segue 的 function 新增或修改列表上的資料。

4.3 Model View Controller 的 Lab Favorite Athletes

class AthleteFormViewController

檢查表單資料是否 ok,ok 的話再建立資料,返回前一頁。

@IBAction func saveButtonTapped(_ sender: Any) {
   guard let name = nameTextField.text,
      let age = ageTextField.text,
      let league = leagueTextField.text,
      let team = teamTextField.text else {return}
   athlete = Athlete(name: name, age: age, league: league, team: team)
   performSegue(withIdentifier: PropertyKeys.unwind, sender: self)
}

class AthleteTableViewController

在 unwind segue 的 function 新增或修改資料。

@IBAction func prepareForUnwind(segue: UIStoryboardSegue) {
   guard let source = segue.source as? AthleteFormViewController,
      let athlete = source.athlete else {return}
   if let indexPath = tableView.indexPathForSelectedRow {
      athletes.remove(at: indexPath.row)
      athletes.insert(athlete, at: indexPath.row)
      tableView.deselectRow(at: indexPath, animated: true)
   } else {
      athletes.append(athlete)
   }
}

眼尖的朋友應該會注意到剛剛的程式只有修改 array,畫面不會更新。沒錯,因為此範例是在 viewWillAppear 裡呼叫 reloadData 更新表格。

override func viewWillAppear(_ animated: Bool) {
   super.viewWillAppear(animated)
   tableView.reloadData()
}

unwind segue 參考連結

一個 file 定義一個型別。

比方 Car.swift,Driver.swift。

MVC, model controller 和 helper controller

4.3 Model View Controller

iOS App 開發最常見的架構為 MVC,不過在書本裡,Apple 提到 C 其實可細分為以下三種,,讓程式的分工更清楚。

  • view controller
  • model controller

負責實現 model 的相關功能。使用 model controller 的原因可能有三種:

(1) Multiple objects or scenes need access to the model data.

(2) The logic for adding, modifying, or deleting model data is complex.

(3) You want to keep the code in your view controllers focused on managing the views

比方你在做一個筆記 App,要處理 Note 的新增,刪除,修改等,你可以另外定義 NoteController 實現相關功能,而不用把大量的程式寫在 view controller 或 note 裡。

  • helper controller
  • 負責特定的功能,比方使用 NetworkController 處理 App 跟後台溝通的部分。

App 實例:

Controller objects are free to communicate or work directly with other controller objects. Consider the examples above. The initial view controller (NoteListViewController) of a Notes app might be responsible for displaying a list of notes, so it accesses the notes property on a note model controller (NoteController). The note controller needs to check if there are any new notes, so it tells the network controller (NetworkController) to check the web service for new data. If the NetworkController finds new notes, it downloads the data and sends it back to the NoteController, which would then update its notes property and send a callback to the NoteListViewController that it has new data, enabling the NoteListViewController to update its view of notes.

範例:

MealListTableViewController,MealDetailViewController,Meal

iOS App 的 Xcode 專案如何分類檔案(project organization)

將字串定義成型別常數

開發 iOS App 時,總有某些東西是我們無法避免,必須以字串輸入的,比方 segue ID,cell ID,storyboard ID 等。然而只要你一不小心打錯,將產生非常可怕的後果,輕則功能失效,重則讓 App 閃退,地球毀滅 !

因此,不妨參考 Apple 的做法,將字串定義成型別常數,到時輸入時 Xcode 將幫我們自動完成,一輩子都不會打錯。

讓我們看看以下幾個例子:

在 controller 裡以 struct 定義型別 PropertyKeys,宣告 static 屬性儲存 segue ID 和 cell ID。

4.3 Model View Controller 的 Lab Favorite Athletes

class AthleteTableViewController: UITableViewController {
   struct PropertyKeys {
      static let athleteCell = "AthleteCell"
      static let addAthleteSegue = "AddAthlete"
      static let editAthleteSegue = "EditAthlete"
   }
}
class AthleteFormViewController: UIViewController {
   struct PropertyKeys {
       static let unwind = "UnwindToAthleteTable"
   }
}

4.6 Intermediate Table Views 的 Lab Favorite books

將 unwind segue id 存在常數 unwind,之便之後呼叫 performSegue 時使用。

class BookFormTableViewController: UITableViewController {
   struct PropertyKeys {
      static let unwind = "UnwindToBookTable"
   }
}

用常數儲存 add & edit segue 的 id & cell 的 id,之後在 prepare 裡可以比對 segue id,在 tableView(_:cellForRowAt:) 可以使用 cell id。

class BookTableViewController: UITableViewController {
   struct PropertyKeys {
      static let bookCell = "BookCell"
      static let addBookSegue = "AddBook"
      static let editBookSegue = "EditBook"
   }
}
Storyboard ID

以 struct 定義型別 StoryboardID,宣告屬性儲存 storyboard ID。

struct StoryboardID {
   static let main = "Main"
   static let mainNC = "MainNC"
   static let zoneNC = "ZoneNC"
   static let note = "Note"
   static let noteNC = "NoteNC"
}

在 controller 裡宣告屬性 storyboardIdentifier 儲存它的 storyboard ID。

class BuildIceCreamViewController: UIViewController {
   static let storyboardIdentifier = "BuildIceCreamViewController"
}
Notification name

定義 static 常數儲存通知名稱。

Unit 5 Guided project Restaurant

class MenuController {
   static let orderUpdatedNotification = Notification.Name("MenuController.orderUpdated")
4. Dictionary 的 key

以 struct 定義型別 NotificationObjectKey,宣告屬性儲存 Notification 的 userInfo 裡自訂的 key。

struct NotificationObjectKey {
   static let reason = "reason"
   static let recordIDsDeleted = "recordIDsDeleted"
   static let recordsChanged = "recordsChanged"
   static let newNote = "newNote"
}

搭配 guard let 建立自訂型別的 cell

建立自訂類別的 cell 時,如果你很有信心,覺得不可能失敗,可使用 as! 強制轉型。

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   let cell = tableView.dequeueReusableCell(withIdentifier: PropertyKeys.loverCell, for: indexPath) as! BookTableViewCell
   let book = books[indexPath.row]
   cell.update(with: book)
   return cell
}

不過也可以考慮較安全的做法,使用 guard let 讀取搭配 as? 轉型的 cell。

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   guard let cell = tableView.dequeueReusableCell(withIdentifier: PropertyKeys.loverCell, for: indexPath) as? BookTableViewCell else {
      fatalError(“Could not dequeue a cell”)
   }
   let book = books[indexPath.row]
   cell.update(with: book)
   return cell
}

利用 guard let 或 if let 比對多個 optional,檢查使用者輸入的內容。

4.3 Model View Controller 的 Lab Favorite Athletes

表單內容 ok 時,才會建立資料,利用 performSegue 回到前一頁。

@IBAction func saveButtonTapped(_ sender: Any) {
   guard let name = nameTextField.text,  
      let age = ageTextField.text,
      let league = leagueTextField.text,
      let team = teamTextField.text else {return}
   athlete = Athlete(name: name, age: age, league: league, team: team)
   performSegue(withIdentifier: PropertyKeys.unwind, sender: self)
}

資料輸入頁面(form) & 設定頁面(setting)適合以 static cells 實作

資料的輸入頁面 & 設定頁面往往有很多欄位,適合以上下捲動的表格呈現。又因欄位個數是固定的,所以可以直接用 UITableViewController 搭配 static cell 實作,享有以下幾點好處:

(1) cell 裡的輸入欄位,諸如 text field, slider,皆可設為 controller 的 outlet 變數。(若為 Dynamic Prototypes 的表格,cell 上的元件只能設為 cell 類別的 outlet。)

(2) 鍵盤出現時,表格會自動往上捲,text field 不會被檔到。

如何設定 static cells

從 table view controller 的 table view 的 content 欄位。

4.6 intermediate table views

class AddEditEmojiTableViewController

class AddEditEmojiTableViewController: UITableViewController {
   @IBOutlet weak var symbolTextField: UITextField!
   @IBOutlet weak var nameTextField: UITextField!
   @IBOutlet weak var descriptionTextField: UITextField!
   @IBOutlet weak var usageTextField: UITextField!
}

新增或修改東西,建議用 modally present,看 detail 建議用 push。

iOS App 在新增資料時,常使用 present 另一個 navigation controller 的設計,例如內建的通訊錄 App 和行事曆 App。

4.6 intermediate table views

According to the Human Interface Guidelines, you should modally present this static table controller when it's being used to create a new Emoji or to edit an existing Emoji. If, however, you're simply displaying data without the ability to edit, it should be pushed.

新增和修改資料採用同一個 controller 頁面,利用 segue id 判斷要去的頁面是哪一個

4.6 intermediate table views

將點選 cell 修改資料的 segue id 設為 EditEmoji。

class EmojiTableViewController

segue id 為 EditEmoji 時表示觸發的是修改資料的 segue。

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
   if segue.identifier == "EditEmoji" {
      let indexPath = tableView.indexPathForSelectedRow!
      let emoji = emojis[indexPath.row]
      let navController = segue.destination as! UINavigationController
      let addEditEmojiTableViewController = navController.topViewController as! AddEditEmojiTableViewController
      addEditEmojiTableViewController.emoji = emoji
   }
}

除了利用 segue id 判斷,也可以用 indexPathForSelectedRow 是否有值判斷是點選 cell 修改資料還是點選 + 新增資料,indexPathForSelectedRow 有值表示點選 cell。

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
   if let row = tableView.indexPathForSelectedRow?.row {
   }
}

class AddEditEmojiTableViewController

class AddEditEmojiTableViewController: UITableViewController {
   @IBOutlet weak var saveButton: UIBarButtonItem!
   var emoji: Emoji?
   override func viewDidLoad() {
      super.viewDidLoad()
      if let emoji = emoji {
         symbolTextField.text = emoji.symbol
         nameTextField.text = emoji.name
         descriptionTextField.text = emoji.detailDescription
         usageTextField.text = emoji.usage
      }
   }
}

利用 unwind segue 取消 / 完成資料的新增或修改,回傳前一頁

4.6 intermediate table views

設定 segue id ,區分點選取消還是完成 button。

(1) 將點選 save button 的 unwind segue id 取名為 saveUnwind。

(2) 當 prepare 由 save button 的 segue 觸發時建立資料。

class AddEditEmojiTableViewController

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
   super.prepare(for: segue, sender: sender)
   guard segue.identifier == "saveUnwind" else { return }
   let symbol = symbolTextField.text ?? ""

let name = nameTextField.text ?? ""
   let detailDescription = descriptionTextField.text ?? ""
   let usage = usageTextField.text ?? ""
   emoji = Emoji(symbol: symbol, name: name, detailDescription: detailDescription, usage: usage)
}

(3) 判斷是新增還是修改資料。

EmojiTableViewController

@IBAction func unwindToEmojiTableView(segue: UIStoryboardSegue) {
   guard segue.identifier == "saveUnwind" else { return }
   let sourceViewController = segue.source as! AddEditEmojiTableViewController
   if let emoji = sourceViewController.emoji {
      if let selectedIndexPath = tableView.indexPathForSelectedRow {
         emojis[selectedIndexPath.row] = emoji
         tableView.reloadRows(at: [selectedIndexPath], with: .none)
      } else {
         let newIndexPath = IndexPath(row: emojis.count, section: 0)
         emojis.append(emoji)
         tableView.insertRows(at: [newIndexPath], with: .automatic)
      }
   }
}

定義 extension 的檔名可用原本的型別開頭,然後接 + 某個名字

例子: URL+Helpers.swift

extension URL {
   func withQueries(_ queries: [String: String]) -> URL? {
      var components = URLComponents(url: self, resolvingAgainstBaseURL: true)
      components?.queryItems = queries.compactMap { URLQueryItem(name: $0.0, value: $0.1) }
      return components?.url
   }
}

共用資料宣告成型別常數,取名為 shared 或 default。

App 裡有些負責特定功能的物件會在多個頁面使用,比方抓取網路資料的物件。你可將它宣告成只會建立一次的型別常數,省去每次使用時重新生成的麻煩,並享有任何地方皆可方便存取的好處,就像以下例子的 MenuController.shared。

class MenuController {
   static let shared = MenuController()
}

iOS SDK 本身就有很多類似例子,比方 URLSession.sharedUIApplication.shared, FileManager.default

利用 ?? (nil-coalescing operator) 設定資料的預設值

用 ?? 語法方便地讀取 optional 內容,並在它為 nil 時另外指定預設值,很適合運用在讀取 text field 內容建立資料的情境。

var registration: Registration {
   let firstName = firstNameTextField.text ?? “”
   let lastName = lastNameTextField.text ?? “”
   return Registration(firstName: firstName,
lastName: lastName)
}

和後台 API 溝通的的程式寫在哪

和後台 API 溝通的程式不難寫,但是如果沒有好好規劃,常常一個不小心,就讓我們的程式變得十分複雜。Apple 提到了常見的三種做法,你可依 App 需求選擇合適的做法。

1. 寫在 View Controller 裡

初學者最常採用的做法,因為它最簡單直接,在 controller 裡抓資料,然後再把它顯示到畫面上。如以下例子,在 controller 裡定義 function fetchPhotoInfo 抓資料。

class PhotoViewController: UIViewController {
   func updateUI(with photoInfo: PhotoInfo) {…}
   func fetchPhotoInfo(completion: @escaping (PhotoInfo?) ->
Void) {…}
   override func viewDidLoad() {
      fetchPhotoInfo { (photoInfo) in
      self.updateUI(with: photoInfo)
   }
}

建議只在 API 沒有太多太複雜時採用,因為它將讓你的 view controller 複雜許多,而且每個頁面的 controller 需要抓資料時,都要重寫一次相關的程式碼。(就算是用複製貼上,還是有點累呀。)

2. 寫在 model 裡,定義抓資料的 static function

既然要抓某種 model 的資料,不如就將抓取的程式碼定義成 model 的型別 function,如此之後不管在哪個 controller,都可以方便地抓取資料取得 model。

extension PhotoInfo {
   static func fetchPhotoInfo(completion: @escaping
(PhotoInfo?) -> Void) {…}
}
class PhotoViewController: UIViewController {
   override func viewDidLoad() {
      PhotoInfo.fetchPhotoInfo { (photoInfo) in
         self.updateUI(with: photoInfo)
      }
   }
   func updateUI(with photoInfo: PhotoInfo) {…}
}

3. 寫在 model controller 或 helper controller 裡

為了避免 view controller 或 model 的程式太複雜,也可考慮另外定義 model controller 或 helper controller 專門處理後台 API。

定義 model controller PhotoInfoController

struct PhotoInfoController {
   func fetchPhotoInfo(completion: @escaping (PhotoInfo?) ->
Void) {…}
}
class PhotoViewController: UIViewController {
   let photoInfoController = PhotoInfoController()
   override func viewDidLoad() {
      photoInfoController.fetchPhotoInfo { (photoInfo) in
         self.updateUI(with: photoInfo)
      }
   }
   func updateUI(with photoInfo: PhotoInfo) {…}
}

定義 helper controller NetworkController

struct NetworkController {
   static let shared = NetworkController()
   func fetchPhotoInfo(completion: @escaping (PhotoInfo?) ->
Void) {…}
}
class PhotoViewController: UIViewController {
   override func viewDidLoad() {
      NetworkController.shared.fetchPhotoInfo { (photoInfo) in
         self.updateUI(with: photoInfo)
      }
   }
   func updateUI(with photoInfo: PhotoInfo) {…}
}

enum 的使用時機

class, struct 相比,enum 常被我們忽略。其實它也有出頭天的時候,當你想表達的資料內容只有固定幾種時,enum 十分好用,不只能搭配 switch 比對,還可讓程式更安全,更不易出錯,例如以下例子:

電影的種類 genre 定義成字串,容易打錯字,甚至產生世界上不存在的電影種類。

struct Movie {
   var name: String
   var releaseYear: Int
   var genre: String
}
let loveMovie = Movie(name: "你的名字", releaseYear: 2016, genre: "帥到分手")

將電影的種類 genre 改以 enum Genre 定義,讓我們不易打錯字,而且建立電影時也更安全,你只能傳入 Genre 型別的電影種類,不可能發明種類帥到分手的電影。

enum Genre {
   case animated, action, romance, documentary, biography,
thriller
}
struct Movie {
   var name: String
   var releaseYear: Int
   var genre: Genre
}
let loveMovie = Movie(name: “Finding Dory”, releaseYear: 2016, genre: .animated)

Unit 1 Getting Started with App Development

Guided Project — Light

點選畫面切換顏色。

技術關鍵字:

IBOutlet,IBAction,ternary conditional operator,viewDidLoad,UIColor。

特別說明:

  • breakpoint 的使用。
  • 查詢 Developer Documentation。
  • 定義 function updateUI() 更新畫面。

在 viewDidLoad & IBAction 的 buttonPressed 呼叫 updateUI。

func updateUI() {
   view.backgroundColor = lightOn ? .white : .black
}

Unit 2 Introduction to UIKit

  • 2.3 Structures

技術關鍵字:

struct,property,method,initializer,default initializer, memberwise initializer,computed property,self,mutating method,property observer,willSet,didSet,type property and method,static,copy。

範例:

initializer

struct Temperature {
   var celsius: Double
   init(celsius: Double) {
      self.celsius = celsius
   }
   init(fahrenheit: Double) {
      celsius = (fahrenheit - 32) / 1.8
   }
}
let currentTemperature = Temperature(celsius: 18.5)
let boiling = Temperature(fahrenheit: 212.0)

mutating method

struct Odometer {
   var count: Int = 0
   mutating func increment() {
      count += 1
   }
   mutating func increment(by amount: Int) {
      count += amount
   }
   mutating func reset() {
      count = 0
   }
}

computed property

struct Temperature {
   var celsius: Double
   var fahrenheit: Double {
      return celsius * 1.8 + 32
   }
   var kelvin: Double {
      return celsius + 273.15
   }
}
let currentTemperature = Temperature(celsius: 0.0)
print(currentTemperature.fahrenheit)
print(currentTemperature.kelvin)

property observer

struct StepCounter {
   var totalSteps: Int = 0 {
      willSet {
         print("About to set totalSteps to \(newValue)")
      }

didSet {
         if totalSteps > oldValue  {
            print("Added \(totalSteps - oldValue) steps")
         }
     }
   }
}

type property and method

struct Temperature {
   static var boilingPoint = 100
}
let boilingPoint = Temperature.boilingPoint
let smallerNumber = Double.minimum(100.0, -1000.0)
  • 2.4 Classes, Inheritance

技術關鍵字:

class,base class,inheritance,subclass, superclass,override,reference,initializer

範例

override method & peroperty

class Vehicle {
   var currentSpeed = 0.0
   var description: String {
      return "traveling at \(currentSpeed) miles per hour"
   }
   func makeNoise() {
   }
}

class Train: Vehicle {
   override func makeNoise() {
      print("Choo Choo!")
   }
}

class Car: Vehicle {
   var gear = 1
   override var description: String {
      return super.description + " in gear \(gear)"
   }
}

override initializer

class Person {
   let name: String
   init(name: String) {
      self.name = name
   }
}
class Student: Person {
   var favoriteSubject: String
   init(name: String, favoriteSubject: String) {
      self.favoriteSubject = favoriteSubject
      super.init(name: name)
   }
}
  • 2.10 Auto Layout and Stack Views

Apple 建議可以多用 stack view

Whenever possible, it's a good practice to use stack views before trying to manage constraints individually. Stack views allow you to create nice-looking interfaces quickly—and also make it easy to modify or customize them in the future.

Lab — Calculator

Guided Project — Apple Pie

猜單字遊戲。

技術關鍵字:

auto layout,stack view,IBOutlet,IBAction,outlet collection,isEnabled,computed property,property observer,joined,contains,append,removeFirst,isEmpty

特別說明:

  • computed property
var formattedWord: String {
   var guessedWord = ""
   for letter in word {
      if guessedLetters.contains(letter) {
         guessedWord += "\(letter)"
      } else {
         guessedWord += "_"
      }
   }
   return guessedWord
}
  • property observer
var totalWins = 0 {
   didSet {
      newRound()
   }
}
var totalLosses = 0 {
   didSet {
      newRound()
   }
}
  • 定義型別 Game 儲存遊戲的相關資訊和定義猜單字的 function。

struct Game 的 Generated Interface。

Unit 3 Navigation and Workflows

  • 3.1 Optionals

技術關鍵字:

optional,?,nil,force-unwrap,!,optional binding,if let,failable initializer,optional chaining,nested optional,?.,implicitly unwrapped optional

範例:

failable initializer

struct Toddler {
   var name: String
   var monthsOld: Int
   init?(name: String, monthsOld: Int) {
      if monthsOld < 12 || monthsOld > 36 {
         return nil
      } else {
         self.name = name
         self.monthsOld = monthsOld
      }
   }
}
let possibleToddler = Toddler(name: "Joanna", monthsOld: 14)
if let toddler = possibleToddler {
   print("\(toddler.name) is \(toddler.monthsOld) months old")
} else {
   print("The age you specified for the toddler is not between 1")
}

optional chaining

struct Person {
   var age: Int
   var residence: Residence?
}
struct Residence {
   var address: Address?
}
struct Address {
   var buildingNumber: String
   var streetName: String
   var apartmentNumber: String?
}
let address = Address(buildingNumber: "18", streetName: "NeverLand", apartmentNumber: "7")
let residence = Residence(address: address)
let person = Person(age: 18, residence: residence)
if let theApartmentNumber =
person.residence?.address?.apartmentNumber {
      print("He/she lives in apartment number \(theApartmentNumber).")
}
  • 3.2 Type casting & inspetion

技術關鍵字:

type casting,as?,conditional cast,as!,Any,AnyObject

範例:

type casting

func walk(dog: Dog) {
   print("Walking \(dog.name)")
}
func cleanLitterBox(cat: Cat) {
   print("Cleaning the \(cat.boxSize) litter box'")
}
func cleanCage(bird: Bird) {
   print("Removing the \(bird.featherColor) feathers at the bottom of the cage'")
}
let pets = allClientAnimals()
for pet in pets {
   if let dog = pet as? Dog {
      walk(dog: dog)
   } else if let cat = pet as? Cat {
      cleanLitterBox(cat: cat)
   } else if let bird = pet as? Bird {
      cleanCage(bird: bird)
   }
}
  • 3.3 Guard

技術關鍵字:

guard else,guard let

範例:

guard else

使用 if

func divide(_ number: Double, by divisor: Double) {
   if divisor != 0.0 {
      let result = number / divisor
      print(result)
   }
}

使用 guard

func divide(_ number: Double, by divisor: Double) {
   guard divisor != 0.0 else { return }
   let result = number / divisor
   print(result)
}

guard let

使用 if let

func processBook(title: String?, price: Double?, pages: Int?) {
   if let theTitle = title, let thePrice = price, let thePages = pages {
      print("\(theTitle) costs $\(thePrice) and has \(thePages) pages.")
   }
}

使用 guard let

func processBook(title: String?, price: Double?, pages: Int?) {
   guard let theTitle = title, let thePrice = price, let
thePages = pages else { return }
   print("\(theTitle) costs $\(thePrice) and has \(thePages) pages.")
}
  • 3.4 Constant and Variable Scope

技術關鍵字:

variable shadowing

  • 3.5 Enumerations

技術關鍵字:

enum,case,default

  • 3.6 Segues and Navigation Controllers

技術關鍵字:

segue,model presentation,show,unwind segue,Exit object,navigation controller,navigation bar,navigation item,bar button item,large title,prepare,segue id,performSegue

Lab — Login

技術關鍵字:

performSegue,segue id,prepare

  • 3.7 Tab Bar Controllers

技術關鍵字:

tab bar controller,tab bar item,badge

Lab — About Me

  • 3.8 View Controller Life Cycle

技術關鍵字:

viewDidLoad,viewWillAppear,viewDidAppear,viewWillDisappear,viewDidDisappear,override,super

Lab — Order of events

Guided Project — Personality Quiz

心理測驗

技術關鍵字:

MVC,stack view,unwind segue,prepare,performSegue,hidesBackButton,map,sorted,enum 的 swift self,$0。

特別說明:

  • 在一個 controller 畫面設計多種問題樣式。

如下圖所示,我們可將 Single Stack View 的 Installed 取消勾選,讓它暫時不顯示。等我們設計完其它問題的樣子後,再將它的 Installed 勾選。

  • 使用 struct & enum 定義 model。

struct Question & Answer,enum ResponseType & AnimalType。

  • 使用 map,sorted,Shorthand Argument Names $0 & $1,tuple,Implicit Returns from Single-Expression Closures
var frequencyOfAnswers: [AnimalType:Int] = [:]
let responseTypes = responses.map { $0.type }
for response in responseTypes {
   frequencyOfAnswers[response] = (frequencyOfAnswers[response] ?? 0) + 1
}
let mostCommonAnswer = frequencyOfAnswers.sorted { $0.1 > $1.1 }.first!.key
resultAnswerLabel.text = "You are a \(mostCommonAnswer.rawValue)!"
  • enum 的 computed property & switch self
enum AnimalType: Character {
   case dog = "🐶", cat = "🐱", rabbit = "🐰", turtle = "🐢"
   var definition: String {
      switch self {
      case .dog:
         return "You are incredibly outgoing. You surround yourself with the people you love, and enjoy activities with your friends."
      case .cat:
         return "Mischievous, yet mild-tempered, you enjoy doing things on your own terms."
      case .rabbit:
         return "You love everything that's soft. You are healthy and full of energy."
      case .turtle:
         return "You are wise beyond your years, and you focus on the details. Slow and steady wins the race."
      }
   }
}

Unit 3A Building AR Apps with Xcode

Unit 4 Tables and Persistence

  • 4.1 protocols

技術關鍵字:

protocol,adopt,implementation,CustomStringConvertible,Equatable,Comparable,Codable,JSONEncoder,try?,delegation

範例:

控制 print 顯示的字串的 CustomStringConvertible

class Shoe: CustomStringConvertible {
   var description: String {
      let doesOrDoesNot = hasLaces ? "does" : "does not"
      return "This shoe is \(color), size \(size), and \(doesOrDoesNot) have laces."
   }
   let color: String
   let size: Int
   let hasLaces: Bool
   init(color: String, size: Int, hasLaces: Bool) {
      self.color = color
      self.size = size
      self.hasLaces = hasLaces
   }
}

比較是否相等的 Equatable

struct Employee: Equatable {
   var firstName: String
   var lastName: String  
   var jobTitle: String
   var phoneNumber: String
   static func ==(lhs: Employee, rhs: Employee) -> Bool {
return lhs.firstName == rhs.firstName && lhs.lastName ==
rhs.lastName
   }
}

若要比較每個 struct 欄位,其實不需要自己辛苦地定義 function ==,詳情可參考以下連結。

struct Employee: Equatable {
   var firstName: String
   var lastName: String
   var jobTitle: String
   var phoneNumber: String
}

比大小的 Comparable

struct Employee: Equatable, Comparable {
   var firstName: String
   var lastName: String
   var jobTitle: String
   var phoneNumber: String
   static func < (lhs: Employee, rhs: Employee) -> Bool {
      return lhs.lastName < rhs.lastName
   }
}
let sortedEmployees = employees.sorted(by:>)

編碼和解碼的 Codable

struct Employee: Codable {
var firstName: String
var lastName: String
var jobTitle: String
var phoneNumber: String
}

creating a protcol

protocol FullyNamed {
var fullName: String { get }

func sayFullName()
}
struct Person: FullyNamed {
var firstName: String
var lastName: String

var fullName: String {
return "\(firstName) \(lastName)"
}

func sayFullName() {
print(fullName)
}
}
  • 4.2 App Anatomy and Life Cycle

技術關鍵字:

AppDelegate,UIAppliccationDelegate

範例:

func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplicationLaunchOptionsKey: Any]?) -> Bool {

return true
}
func applicationWillResignActive(_ application: UIApplication) {
}
func applicationDidEnterBackground(_ application: UIApplication) {
}
func applicationWillEnterForeground(_ application: UIApplication) {
}
func applicationDidBecomeActive(_ application: UIApplication) {
}
func applicationWillTerminate(_ application: UIApplication) {
}

當使用者自己主動結束 App 時,將分成兩種 cases:

  1. App 可以在背景執行程式,比方在背景播放音樂的 App,它被使用者殺掉時將觸發 applicationDidEnterBackground。
  2. 不能在背景執行程式的 App 被使用者殺掉時,將觸發 applicationDidEnterBackground,接著再觸發 applicationWillTerminate。

Lab — App event count

利用 UIWindow 的 rootViewController 存取 App 的第一個 view controller。

var viewController: ViewController?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
   viewController = window?.rootViewController as? ViewController
   viewController?.launchCount += 1
   return true
}
  • 4.3 Model View Controller

技術關鍵字:

model,view,controller,MVC,project organization

Lab — Favorite Athletes

設計 MVC 架構。

  • 4.4 Scroll Views

技術關鍵字:

scroll view,contentSize,frame,content inset,scroll indicator inset

範例

讓圖片在放大縮小時維持置中

imageView.centerXAnchor.constraints(equalTo: 
scrollView.contentLayoutGuide.centerXAnchor)
imageView.centerYAnchor.constraints(equalTo:
scrollView.contentLayoutGuide.centerYAnchor)

利用 stack view 裝 scroll view 要顯示的內容

ScrollingForm project

keyboard 出現/收起時移動 scroll view

調整 scroll view 的 contentInsets,鍵盤出現時將它的 bottom 設為鍵盤的高度,如此可保證若 scroll view 捲動到底部,scroll view 最下面的 text filed 將不會被鍵盤檔到。調整 contentInsets 後,當使用者點選某個 text field 時,scroll view 將聰明地判斷是否需捲動,以及需捲動多少。

func registerForKeyboardNotifications() {
   NotificationCenter.default.addObserver(self, selector: #selector(keyboardWasShown(_:)), name: UIResponder.keyboardDidShowNotification, object: nil)
   NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillBeHidden(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@objc func keyboardWasShown(_ notificiation: NSNotification) {
   guard let info = notificiation.userInfo,
   let keyboardFrameValue = info[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue else { return }
   let keyboardFrame = keyboardFrameValue.cgRectValue
   let keyboardSize = keyboardFrame.size
   let contentInsets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: keyboardSize.height, right: 0.0)
   scrollView.contentInset = contentInsets
   scrollView.scrollIndicatorInsets = contentInsets
}
@objc func keyboardWillBeHidden(_ notification: NSNotification) {
   let contentInsets = UIEdgeInsets.zero
   scrollView.contentInset = contentInsets
   scrollView.scrollIndicatorInsets = contentInsets
}

Lab — I spy

利用 scroll view 縮放圖片。

技術關鍵字:

UIScrollViewDelegate,zoom,viewForZooming

特別說明:

計算圖片縮放的 minimumZoomScale,讓圖片縮到最小時可以在螢幕裡。

override func viewDidLoad() {
   scrollView.delegate = self
   updateZoomFor(size: view.bounds.size)
}
func updateZoomFor(size: CGSize) {
   let widthScale = size.width / imageView.bounds.width
   let heightScale = size.height / imageView.bounds.height
   let scale = min(widthScale, heightScale) 
   scrollView.minimumZoomScale = scale
}
  • 4.5 Table Views

技術關鍵字:

table view,dynamic,accessory view,dequeue,reuse identifier,index path,cell,readability margin,reorder,UITableViewDataSource,UITableViewDelegate

範例

Emoji dictionary app

使用 table view controller 的好處

keyboard 出現時,table 會自動 scroll,讓 text field 不被檔到

Initial view controller

table view style

plain & grouped

左邊 plain,右邊 grouped

table view cell

(1) default mode

(2) editing mode

table view 的 property isEditing 控制 table 目前是否為 editing mode。比方以下例子,我們點選 Edit button,呼叫 table view 的 function setEditing(_:animated:),讓 table 進入 editing mode。

@IBAction func editButtonTapped(_ sender: UIBarButtonItem) {
   let tableViewEditingMode = tableView.isEditing
   tableView.setEditing(!tableViewEditingMode, animated: true)
}

cell style

Basic,Subtitle,Right Detail,Left Detail

UITableViewCellAccessoryType

Disclosure Indicator,Detail Disclosure,Checkmark,Detail

table view readability margin

將 cellLayoutMarginsFollowReadableWidth 設為 true,將讓 cell 的的寬度包含在 readable margin 裡,讓 cell 將大螢幕時不會太寬。

Index Path

利用 IndexPath 的 section & row 存取某個 cell

table view delegate 例子

點選 cell

override func tableView(_ tableView: UITableView, didSelectRowAt
indexPath: IndexPath) {

}

移動(reorder) cell

1. cell.showsReorderControl = true

2.tableView.isEditing = true

3. 定義 function tableView(_:, moveRowAt fromIndexPath:, to:)

override func tableView(_ tableView: UITableView, moveRowAt 
fromIndexPath: IndexPath, to: IndexPath) {
let movedEmoji = emojis.remove(at: fromIndexPath.row)
emojis.insert(movedEmoji, at: to.row)
tableView.reloadData()
}

Lab — meal tracker

多個 section,利用 tableView(_:titleForHeaderInSection:) 顯示 section 的標題。

4.6 Intermediate Table Views

技術關鍵字:

custom cell,static cell,add & delete cell,row action,content hugging priority,cell subclass,UITableViewCell.EditingStyle,content compression resistance

範例

調整 content hugging priority

問題: emoji 圖案 label 太長

解法: 將 emoji 圖案 label 的 horizontal content hugging priority 調高。

讓 emoji 圖案 label 的 horizontal content hugging priority(252) 比右邊的 name label & description label(251) 大。horizontal content hugging priority 較大的元件可維持原本內容的大小,不會被拉長。相反的,horizontal content hugging priority 較小的元件將被拉長。

結果

調整 table view 的 editing style

(1) 沒有定義 function tableView(_:editingStyleForRowAt:) 時,預設為 delete。

(2) 定義 function tableView(_:editingStyleForRowAt:),回傳 insert style。

override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
   return .insert
}

(3) 定義 function tableView(_:editingStyleForRowAt:),回傳 none style。

override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
   return .none
}

依據表單內容設定 save button 的狀態,讓表單資料 ok 時才能點選 save button。

func updateSaveButtonState() {
   let symbolText = symbolTextField.text ?? ""
   let nameText = nameTextField.text ?? ""
   let descriptionText = descriptionTextField.text ?? ""
   let usageText = usageTextField.text ?? ""
   saveButton.isEnabled = !symbolText.isEmpty && !nameText.isEmpty && !descriptionText.isEmpty && !usageText.isEmpty
}

在 viewDidLoad 時設定 save button 的狀態。

override func viewDidLoad() {
   super.viewDidLoad()
   if let emoji = emoji {
      symbolTextField.text = emoji.symbol
      nameTextField.text = emoji.name
      descriptionTextField.text = emoji.detailDescription
      usageTextField.text = emoji.usage
   }
   updateSaveButtonState()
}

在文字輸入時設定 save button 的狀態。(連結 text field 的 editing changed event)

@IBAction func textEditingChanged(_ sender: UITextField) {
   updateSaveButtonState()
}

除了調整 button 狀態,另一種做法是讓 button 維持可點選,點選時再判斷是否可儲存資料,然後再從程式觸發回到之前頁面。(比方呼叫 performSegue)

比較

(1) 表單資料有問題時,save button 不能點選

在 function prepare 建立資料

(2) save button 維持可以點選

設定 save button 的 IBAction function,若表單資料 ok,在 function 裡建立資料 & 回到之前頁面。

table view cell 的 automatic row height

利用 auto layout 自動計算高度。

從 Xcode 9 開始,table view 預設即以 auto layout 計算 cell 高度,Row Height & Estimate 勾選為 Automatic。

若想從程式設定,方法如下

estimatedRowHeight 可設為某個預測的數字或是用 UITableView.automaticDimension。

tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 44.0

如果想要 cell 固定高度,則可參考以下連結。

content compression resistance

因為 interface builder 以為 cell 是固定高度,所以 name label & description label 並排時它覺得可能會有問題,認為其中一個可能要縮小高度,不能顯示完整內容。

如果已經將 table view 設為自動計算 cell 高度,其實可忽略此錯誤。如果不想看到錯誤,則可調整 content compression resistance,比方讓 description label 的 content compression resistance 比 name label 小。重點在讓 interface builder 覺得兩個 label 的 content compression resistance 可以分出勝負,所以誰大誰小不重要。

Lab — Favorite books

4.7 Saving Data

技術關鍵字:

Codable,data,sandboxing,documents directory,persistence,archiving,encode,decode,serialization,plist,PropertyListEncoder,PropertyListDecoder,write

Lab — Remember your emojis

一開始沒資料時,顯示預設的資料。

class EmojiTableViewController

override func viewDidLoad() {
   super.viewDidLoad()
   if let savedEmojis = Emoji.loadFromFile() {
      emojis = savedEmojis
   } else {
      emojis = Emoji.loadSampleEmojis()
   }

model Emoji

遵從 protocol Codable ,將存檔位置存在 static property

static let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
static let archiveURL = documentsDirectory.appendingPathComponent("emojis").appendingPathExtension("plist")

定義讀取資料和儲存資料的 static function。

static func loadFromFile() -> [Emoji]?
static func loadSampleEmojis() -> [Emoji]
static func saveToFile(emojis: [Emoji])

何時讀檔 ?

controller 的畫面載入完成時(viewDidLoad)。

何時存檔 ?

新增資料,修改資料,調整資料順序,刪除資料。

4.8 System View Controllers

技術關鍵字:

UIActivityController,UIAlertController,SFSafariViewController,UIImagePickerViewController,MFMailComposeViewController,handler,MFMessageComposeViewController

範例:

設定 UIActivityController 在 iPad 上分享內容時顯示的位置

UIActivityViewController 的 modalPresentationStyle 預設是 popover(彈出視窗),所以在 iPad 上顯示時我們需設定 popover 彈出的位置,否則在 iPad 上會閃退,出現以下錯誤訊息

'Your application has presented a UIActivityViewController (<UIActivityViewController: 0x7fdac9815c00>). In its current trait environment, the modalPresentationStyle of a UIActivityViewController with this style is UIModalPresentationPopover. You must provide location information for this popover through the view controller's popoverPresentationController. You must provide either a sourceView and sourceRect or a barButtonItem.  If this information is not known when you present the view controller, you may provide it in the UIPopoverPresentationControllerDelegate method -prepareForPopoverPresentation.'

設定 sourceView:

將 sourceView 設為使用者點選的 button sender,讓 popover 從 button 附近彈出。

@IBAction func buttonTapped(_ sender: UIButton) {
   let activityController =
   UIActivityViewController(activityItems: ["peter"],
applicationActivities: nil)
       
activityController.popoverPresentationController?.sourceView
= sender
   present(activityController, animated: true, completion: nil)
}

顯示網頁的 SFSafariViewController

顯示警告訊息和選單的 UIAlertController

注意: 在 iPad 顯示 actionSheet style 的 UIAlertController 時,記得也要設定 popover 彈出的位置。

拍照和存取相簿照片的 UIImagePickerViewController

利用 isSourceTypeAvailable(_:) 檢查是否能拍照。

寄信的 MFMailComposeViewController

  1. 利用 canSendMail 檢查是否可寄信。
  2. 為了設定 MFMailComposeViewController 的代理人,請設定 mailComposeDelegate (不要設成 delegate)。

Lab — Home furniture sharing

選照片 & 分享。

l

技術關鍵字:

UIActivityController,UIAlertController,UIImagePickerViewController,UIImagePickerController.InfoKey.originalImage,pngData

選取照片後更新畫面

func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
   guard let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage else {return}
   furniture?.imageData = image.pngData()
   dismiss(animated: true) {
      self.updateView()
   }
}

4.9 Building Complex Input Screens

技術關鍵字:

checkmark,accessoryType,beginUpdates,endUpdates,DateFormatter,delegate,protocol

範例

HotelForm

利用 static cells 製作複雜的表單

設定 text field 的高度條件,改變 text field 的高度。

cell 裡用 stack view 放元件。

將 Number Of Adults Label 的 horizontal content hugging priority 調高,讓 Adults label 被拉長。

cell 樣式用 Right Detail 顯某個欄位的內容。

利用 DateFormatter 將 Date 轉換成特定格式的字串。

let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
checkInDateLabel.text = dateFormatter.string(from: checkInDatePicker.date)
checkOutDateLabel.text = dateFormatter.string(from: checkOutDatePicker.date)

調整 date picker 可選擇的時間範圍

check in 的時間最小是今天的 0:0:0,check out 的時間最小是 check in 的時間加 24 小時。

super.viewDidLoad()
   let date = Date()
   let midnightToday = Calendar.current.startOfDay(for: date)
   checkInDatePicker.minimumDate = midnightToday
   checkInDatePicker.date = midnightToday
   updateDateViews()
}
func updateDateViews() {
   checkOutDatePicker.minimumDate = checkInDatePicker.date.addingTimeInterval(86400)
}
@IBAction func datePickerValueChanged(_ sender: UIDatePicker) {
   updateDateViews()
}

點選 cell 顯示 / 隱藏 date picker

方法:

調整 cell 高度,不顯示時將高度設為 0。為了讓 date picker 顯示 / 隱藏時有不錯的動畫效果,呼叫 table view 的 beginUpdates() & endUpdates()。

var isCheckInDatePickerShown = false 
var isCheckOutDatePickerShown = false 
let checkInDatePickerCellIndexPath = IndexPath(row: 1, section: 1)
let checkOutDatePickerCellIndexPath = IndexPath(row: 3, section: 1)
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
   switch (indexPath.section, indexPath.row) {
   case (checkInDatePickerCellIndexPath.section, checkInDatePickerCellIndexPath.row):
      if isCheckInDatePickerShown {
         return 216.0
      } else {
         return 0.0
      }
   case (checkOutDatePickerCellIndexPath.section, checkOutDatePickerCellIndexPath.row):
      if isCheckOutDatePickerShown {
         return 216.0
      } else {
         return 0.0
      }
   default:
      return 44.0
   }
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
   tableView.deselectRow(at: indexPath, animated: true)
   switch (indexPath.section, indexPath.row) {
   case (checkInDatePickerCellIndexPath.section, checkInDatePickerCellIndexPath.row - 1):
      if isCheckInDatePickerShown {
         isCheckInDatePickerShown = false   
      } else if isCheckOutDatePickerShown {
         isCheckOutDatePickerShown = false
         isCheckInDatePickerShown = true
      } else {
         isCheckInDatePickerShown = true
      }
     tableView.beginUpdates()
     tableView.endUpdates()
   case (checkOutDatePickerCellIndexPath.section, checkOutDatePickerCellIndexPath.row - 1):
      if isCheckOutDatePickerShown {
         isCheckOutDatePickerShown = false
      } else if isCheckInDatePickerShown {
         isCheckInDatePickerShown = false
         isCheckOutDatePickerShown = true
      } else {
         isCheckOutDatePickerShown = true
      }
      tableView.beginUpdates()
      tableView.endUpdates()
   default:
      break
   }
}

利用 static computed property 儲存選項的 array

struct RoomType: Equatable {
   var id: Int
   var name: String
   var shortName: String
   var price: Int
   static var all: [RoomType] {
      return [RoomType(id: 0, name: "Two Queens", shortName: "2Q", price: 179),
      RoomType(id: 1, name: "One King", shortName: "K", price: 209),
      RoomType(id: 2, name: "Penthouse Suite", shortName: "PHS", price: 309)]
   }
}

如何設計選項的 UI

當選項不多,且每個選項的文字不長時,可用 segmented control,否則建議用 table view。(ps: 也可考慮用 action sheet 的 alert controller)

利用 delegate 將選擇的項目傳到前一頁

SelectRoomTypeTableViewController.swift

protocol SelectRoomTypeTableViewControllerDelegate {
   func didSelect(roomType: RoomType)
}
class SelectRoomTypeTableViewController: UITableViewController {
   override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
      tableView.deselectRow(at: indexPath, animated: true)
      roomType = RoomType.all[indexPath.row]
      delegate?.didSelect(roomType: roomType!)
      tableView.reloadData()
   }
}

class AddRegistrationTableViewController

定義 protocol SelectRoomTypeTableViewControllerDelegate 的 function didSelect(roomType:) 更新房間類型。

class AddRegistrationTableViewController: UITableViewController, SelectRoomTypeTableViewControllerDelegate {
   var roomType: RoomType?
   func didSelect(roomType: RoomType) {
      self.roomType = roomType
      updateRoomType()
   }
   func updateRoomType() {
      if let roomType = roomType {
         roomTypeLabel.text = roomType.name
      } else {
         roomTypeLabel.text = "Not Set"
      }
   }
}

切換到房間類型選擇頁面前設定 delegate。

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
   if segue.identifier == "SelectRoomType" {
      let destinationViewController = segue.destination as? SelectRoomTypeTableViewController
      destinationViewController?.delegate = self
      destinationViewController?.roomType = roomType
   }
}

用 computed property 產生表單內容 ok 時生成的東西。

class AddRegistrationTableViewController

var registration: Registration? {
   guard let roomType = roomType else { return nil }
   let firstName = firstNameTextField.text ?? ""
   let lastName = lastNameTextField.text ?? ""
   let email = emailTextField.text ?? ""
   let checkInDate = checkInDatePicker.date
   let checkOutDate = checkOutDatePicker.date
   let numberOfAdults = Int(numberOfAdultsStepper.value)
   let numberOfChildren = Int(numberOfChildrenStepper.value)
   let hasWifi = wifiSwitch.isOn
   return Registration(firstName: firstName,
       lastName: lastName,
       emailAddress: email,
       checkInDate: checkInDate,
       checkOutDate: checkOutDate,
       numberOfAdults: numberOfAdults,
       numberOfChildren: numberOfChildren,
       roomType: roomType,
       wifi: hasWifi)
}

在 unwind segue 觸發的 function 讀取生成的東西

class RegistrationTableViewController

@IBAction func unwindFromAddRegistration(unwindSegue: UIStoryboardSegue) {
   guard let addRegistrationTableViewController = unwindSegue.source as? AddRegistrationTableViewController,
      let registration = addRegistrationTableViewController.registration else { return }
   registrations.append(registration)
   tableView.reloadData()
}

Lab — Employee roster

利用 enum 定義 EmployeeType

enum EmployeeType {
  case exempt
  case nonExempt
  case partTime
}

可參考以下連結產生 enum 所有 case 的 array。

顯示 / 隱藏 date picker

var isEditingDob: Bool = false {
   didSet {
      tableView.beginUpdates()
      tableView.endUpdates()
   }
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
   guard indexPath.row == dobPickerRow else {return defaultRowHeight}
   if isEditingDob {
      return dobDatePicker.frame.height
   }
   return 0
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
   tableView.deselectRow(at: indexPath, animated: true)
   guard indexPath.row == dobRow else {return}
   isEditingDob = !isEditingDob
   dobLabel.textColor = .black
   dobLabel.text = formatDate(date: dobDatePicker.date)
}

Guided Project — List

完整的 CRUD App。

設定 button 在 default & selected 時顯示不同的照片。

新增用 present modally,修改用 show。

class ToDoCell

自訂 protocol,將 controller 設為 cell 的 delegate,通知 controller 使用者點選 cell 上的東西。

@objc protocol ToDoCellDelegate: class {
   func checkmarkTapped(sender: ToDoCell)
}
class ToDoCell: UITableViewCell {
   weak var delegate: ToDoCellDelegate?
   @IBOutlet weak var isCompleteButton: UIButton!
   @IBOutlet weak var titleLabel: UILabel!
   @IBAction func completeButtonTapped() {
      delegate?.checkmarkTapped(sender: self)
   }
}

宣告 cell 的 delegate 時記得加 weak,否則將出現 retain cycle,造成 memory leak,controller 永遠不會死掉,比方以下畫面的例子。

class ToDoTableViewController

設定 cell 的 delegate 是 controller

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   guard let cell =    tableView.dequeueReusableCell(withIdentifier: "ToDoCellIdentifier") as? ToDoCell else {
fatalError("Could not dequeue a cell")
   }
   cell.delegate = self
   let todo = todos[indexPath.row]
   cell.titleLabel?.text = todo.title
   cell.isCompleteButton.isSelected = todo.isComplete
   return cell
}

定義 ToDoCellDelegate 的 function checkmarkTapped,利用 indexPath(for:) 判斷點選的 cell。

func checkmarkTapped(sender: ToDoCell) {
   if let indexPath = tableView.indexPath(for: sender) {
      var todo = todos[indexPath.row]
      todo.isComplete = !todo.isComplete
      todos[indexPath.row] = todo
      tableView.reloadRows(at: [indexPath], with: .automatic)
      ToDo.saveToDos(todos)
   }
}

Unit 5 Working with the Web

5.1 Closures

技術關鍵字:

closure,filter,map,reduce,sorted,syntactic sugar,trailing closure,$0,$1,Shorthand Argument Names,capture( 例子: closure 裡加 self)

5.2 Extensions

利用 extension 遵從 protocol。

struct Employee {
var firstName: String
var lastName: String
var jobTitle: String
var phoneNumber: String
}

extension Employee: Equatable {
static func ==(lhs: Employee, rhs: Employee) -> Bool {
return lhs.firstName == rhs.firstName && lhs.lastName == rhs.lastName
}
}

5.3 Practical Animation

Apple 範例採用 UIView 的 function animate,建議改用新的做法,UIViewPropertyAnimator。

如何在 playground 測試動畫

建立一個 view 當 PlaygroundPage 的 liveView。

let liveViewFrame = CGRect(x: 0, y: 0, width: 500, height: 500)
let liveView = UIView(frame: liveViewFrame)
liveView.backgroundColor = .white
PlaygroundPage.current.liveView = liveView

動畫的 options。

比方讓動畫 repeat。

UIView.animate(withDuration: 3.0, delay: 2.0, options:
[.repeat], animations: {
square.backgroundColor = .orange
square.frame = CGRect(x: 400, y: 400, width: 100, height:
100)
}, completion: nil)

組合 CGAffineTransform

let scaleTransform = CGAffineTransform(scaleX: 2.0, y: 2.0)
let rotateTransform = CGAffineTransform(rotationAngle: .pi)
let translateTransform = CGAffineTransform(translationX: 200, y: 200)
let comboTransform = scaleTransform.concatenating(rotateTransform).concatenating(translateTransform)

利用 identity 讓 CGAffineTransform 回到原本的狀態

UIView.animate(withDuration: 2.0, animations: {
   square.backgroundColor = .orange
   let scaleTransform = CGAffineTransform(scaleX: 2.0, y: 2.0)
   let rotateTransform = CGAffineTransform(rotationAngle: .pi)
   let translateTransform = CGAffineTransform(translationX: 200, y: 200)
   let comboTransform = scaleTransform.concatenating(rotateTransform).concatenating(translateTransform)
   square.transform = comboTransform
}) { (_) in
   UIView.animate(withDuration: 2.0, animations: {
      square.transform = CGAffineTransform.identity
   })
}

範例 MusicWireframe

分別設定 button touch down & touch up inside 的動畫。

Lab — Enter to win a contest

沒有輸入文字時,顯示 text field 移動動畫。

UIView.animate(withDuration: 0.2, animations: {
   let rightTransform  = CGAffineTransform(translationX: 20, y: 0)
   self.emailAddressTextField.transform = rightTransform
}) { (_) in
   UIView.animate(withDuration: 0.2, animations: {
      self.emailAddressTextField.transform = CGAffineTransform.identity
   })
}

5.4 Working with the Web: HTTP & URL Session

技術關鍵字:

API,asynchronous,JSON,get,post,http header,query,URL,body,request,URLSession,URLSessionDataTask,response,URLComponents

URL 格式

將網路抓下來的 Data 變成 String

let task = URLSession.shared.dataTask(with: url) { (data,
response, error) in
if let data = data,
let string = String(data: data, encoding: .utf8) {
print(string)
}
}

另一種寫法

let task = URLSession.shared.dataTask(with: url) { (data,
response, error) in
if let data = data {
        let string = String(decoding: data, as: UTF8.self)
        print(string)
}
}

利用 URLComponents 建立 URL

extension URL {
   func withQueries(_ queries: [String: String]) -> URL? {
      var components = URLComponents(url: self, resolvingAgainstBaseURL: true)
      components?.queryItems = queries.compactMap { URLQueryItem(name: $0.0, value: $0.1) }
      return components?.url
   }
}
let baseURL = URL(string: "https://api.nasa.gov/planetary/
apod")!

let query: [String: String] = [
"api_key": "DEMO_KEY",
"date": "2011-07-13"
]

let url = baseURL.withQueries(query)!

Lab — iTunes search(part 1)

let baseURL = URL(string: "https://itunes.apple.com/search?")!
let query: [String: String] = [
"term": "Inside Out 2015",
"media": "movie",
"lang": "en_us",
"limit": "10"
]

5.5 Working with the Web: Decoding JSON

技術關鍵字:

JSON,JSONDecoder,CodingKey,escaping,completion handler,where to write network code

completion handler:

將 completion handler 參數宣告成 function 型別,抓到資料後呼叫 completion handler,傳入抓到的資料。(ps: withQueries 是之前在 URL extension 定義的 function)

func fetchPhotoInfo(completion: @escaping (PhotoInfo) -> Void) {
   let baseURL = URL(string: "https://api.nasa.gov/planetary/apod")!
   let query: [String: String] = [
      "api_key": "DEMO_KEY",
   ]
   let url = baseURL.withQueries(query)!
   let task = URLSession.shared.dataTask(with: url) { (data,
response, error) in
      let jsonDecoder = JSONDecoder()
      if let data = data,
          let photoInfo = try?
         jsonDecoder.decode(PhotoInfo.self, from: data) {
         completion(photoInfo)
      } else {
         print("Either no data was returned, or data was not properly decoded.")
         completion(nil)
      }
   }
   task.resume()
}

Lab — iTunes Search(part 2)

讓 property 的名字跟 JSON key 的名字不一樣。

定義 enum CodingKeys: String, CodingKey,每個 property 對應的 case 都要寫出來,名字不一樣的則利用 = 設定對應的 JSON key。

struct StoreItem: Codable {
   var trackName: String
   var artist: String
   var kind: String
   var artworkURL: URL
    
enum CodingKeys: String, CodingKey {
      case trackName
      case artist = "artistName"
      case kind = "kind"
      case artworkURL = "artworkUrl100"
   }
}

利用 init(from:) 客製解碼。

例子:

  1. 當 JSON 的 key 有 description 時,它對應的 value 存到 property description。
  2. 若 JSON 的 key 沒有 descriptio,但是有 nlongDescription ,則將它對應的 value 存到 property description。
  3. 若 JSON 的 key 沒有 description & longDescription ,則將 property description 設成空字串。
struct StoreItem: Codable {
   var name: String
   var artist: String   
   var description: String
   var kind: String
   var artworkURL: URL

   enum CodingKeys: String, CodingKey {
      case name = "trackName"
      case artist = "artistName"
      case kind = "kind"
      case description = "description" 
      case artworkURL = "artworkUrl100"
   }
   enum AdditionalKeys: String, CodingKey {
      case longDescription
   }
   init(from decoder: Decoder) throws {
      let values = try decoder.container(keyedBy: CodingKeys.self)
      name = try values.decode(String.self, forKey: CodingKeys.name)
      artist = try values.decode(String.self, forKey: CodingKeys.artist)
      kind = try values.decode(String.self, forKey: CodingKeys.kind)
      artworkURL = try values.decode(URL.self, forKey: CodingKeys.artworkURL)
      if let description = try? values.decode(String.self, forKey: CodingKeys.description) {
self.description = description
      } else {
         let additionalValues = try decoder.container(keyedBy: AdditionalKeys.self)
         description = (try? additionalValues.decode(String.self, forKey: AdditionalKeys.longDescription)) ?? ""
      }
   }
}

5.6 Working with the Web: Concurrency

技術關鍵字:

grand central dispatch(GCD),App Transport Security(ATS),dispatch queue,main queue,background queue,async,network activity indicator

範例

UI 相關動作必須在 main queue 執行

Because updating the user interface and responding quickly to user input are two of the most critical tasks in any app, these tasks are run on the main queue.

將程式加到 main queue

DispatchQueue.main.async {
}

App Transport Security(ATS)

因為 ATS 的限制,預設只能抓取 https 連線的資料,不能抓取 http 的資料,除非做以下修改。

其它做法: 從程式將 http 連線換成 https

func withHTTPS() -> URL? {
var components = URLComponents(url: self,
resolvingAgainstBaseURL: true)
components?.scheme = "https"
return components?.url
}

利用 UIApplication 的 open function 顯示影片

let url = URL(string: "https://video-ssl.itunes.apple.com/itunes-assets/Video117/v4/2c/f6/e6/2cf6e630-8f12-e721-c80d-b689aa8f9804/mzvf_1371130011356140075.640x354.h264lc.U.p.m4v")!
UIApplication.shared.open(url, options: [:], completionHandler: nil)

network activity indicator

UIApplication.shared.isNetworkActivityIndicatorVisible = true

Lab — iTunes search(part 3)

設定 cache

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
   let temporaryDirectory = NSTemporaryDirectory()
   let urlCache = URLCache(memoryCapacity: 25000000, diskCapacity: 50000000, diskPath: temporaryDirectory)
   URLCache.shared = urlCache
   return true
}

Guided Project — Restaurant

啟動 Apple 寫好的 server app

點選 Open Images Folder 修改圖片

檔名為 1.png,2.png,其它以此類推。

OrderApp

利用 Notification 傳送訂單資訊

class MenuController

定義通知名稱 orderUpdatedNotification

static let shared = MenuController()
static let orderUpdatedNotification = Notification.Name("MenuController.orderUpdated")

class OrderTableViewController

設定接收通知,收到通知時更新 table。

override func viewDidLoad() {
   super.viewDidLoad()
   navigationItem.leftBarButtonItem = editButtonItem
   NotificationCenter.default.addObserver(tableView!, selector: #selector(UITableView.reloadData), name: MenuController.orderUpdatedNotification, object: nil)
}

class AppDelegate

設定接收通知,收到通知時更新 badege number。

@objc func updateOrderBadge() {
   switch MenuController.shared.order.menuItems.count {
   case 0:
       orderTabBarItem.badgeValue = nil
   case let count:
      orderTabBarItem.badgeValue = String(count)
   }
}

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
   let temporaryDirectory = NSTemporaryDirectory()
   let urlCache = URLCache(memoryCapacity: 25_000_000, diskCapacity: 50_000_000, diskPath: temporaryDirectory)
   URLCache.shared = urlCache
   NotificationCenter.default.addObserver(self, selector: #selector(updateOrderBadge), name: MenuController.orderUpdatedNotification, object: nil)
   orderTabBarItem = (self.window!.rootViewController! as! UITabBarController).viewControllers![1].tabBarItem!
   return true
}

class MenuItemDetailViewController

加入訂單

@IBAction func orderButtonTapped(_ sender: UIButton) {
   UIView.animate(withDuration: 0.3) {
      self.addToOrderButton.transform = CGAffineTransform(scaleX: 3.0, y: 3.0)
      self.addToOrderButton.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
   }
   MenuController.shared.order.menuItems.append(menuItem)
}

class MenuController

加入訂單時發出通知。

當我們加入訂單時會修改 order 的 menuItems,由於 Order 是 struct,所以修改它的 property 也會觸發 Order 的 didSet。(如果 Order 是 class 的話,就不會觸發)

var order = Order() {
   didSet {
      NotificationCenter.default.post(name: MenuController.orderUpdatedNotification, object: nil)
   }
}

加入訂單的動畫

button 先放大再縮小變回原本的大小。

@IBAction func orderButtonTapped(_ sender: UIButton) {
   UIView.animate(withDuration: 0.3) {
      self.addToOrderButton.transform = CGAffineTransform(scaleX: 3.0, y: 3.0)
      self.addToOrderButton.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)  
   }
   MenuController.shared.order.menuItems.append(menuItem)
}

網路相關程式定義在 model controller

class MenuController 的 generated interface。

internal class MenuController {
   internal static let shared: OrderApp.MenuController
   internal static let orderUpdatedNotification: Notification.Name
   internal let baseURL: URL
   internal var order: OrderApp.Order { get set }
   internal func fetchCategories(completion: @escaping ([String]?) -> Void)
   internal func fetchMenuItems(forCategory categoryName: String, completion: @escaping ([MenuItem]?) -> Void)
   internal func fetchImage(url: URL, completion: @escaping (UIImage?) -> Void)
   internal func submitOrder(forMenuIDs menuIDs: [Int], completion: @escaping (Int?) -> Void)
}

上傳 JSON 格式的資料

上傳訂單

class MenuController

func submitOrder(forMenuIDs menuIDs: [Int], completion: @escaping (Int?) -> Void) {
   let orderURL = baseURL.appendingPathComponent("order")
   var request = URLRequest(url: orderURL)
   request.httpMethod = "POST"
   request.setValue("application/json", forHTTPHeaderField: "Content-Type")
   let data: [String: [Int]] = ["menuIds": menuIDs]
   let jsonEncoder = JSONEncoder()
   let jsonData = try? jsonEncoder.encode(data)
   request.httpBody = jsonData
   let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
      let jsonDecoder = JSONDecoder()
      if let data = data,
         let preparationTime = try? jsonDecoder.decode(PreparationTime.self, from: data) {
         completion(preparationTime.prepTime)
      } else {
         completion(nil)
      }
   }
   task.resume()
}

判斷圖片抓到時,是否還是原本的 cell

class MenuTableViewController

設定圖片時記得呼叫 setNeedsLayout(),因為 cell 的 imageView property 原本可能是別的 size,呼叫 setNeedsLayout() 後才會更新 size。

func configure(_ cell: UITableViewCell, forItemAt indexPath: IndexPath) {
   let menuItem = menuItems[indexPath.row]
   cell.textLabel?.text = menuItem.name
   cell.detailTextLabel?.text = String(format: "$%.2f", menuItem.price)
   MenuController.shared.fetchImage(url: menuItem.imageURL) { (image) in
      guard let image = image else { return }
      DispatchQueue.main.async {
         if let currentIndexPath = self.tableView.indexPath(for: cell),
           currentIndexPath != indexPath {
           return
         }
         cell.imageView?.image = image
         cell.setNeedsLayout()
      }
   }
}

project extension: state restoration

當 App 重新啟動時,並不會返回之前操作的畫面,而且原本 App 在記憶體中的資料也會被清除。因此剛剛的訂單 App,使用者可能下了兩筆訂單,但當 App 重新啟動時,訂單都不見了,只能重新加訂單。

利用 state restoration,我們可以儲存 App 的狀態,當 App 重新啟動時,返回之前操作的畫面,並且看到之前的訂單。

state restoration 的詳細程式碼和說明可參考 project extension: state restoration 的章節。(ps: 不過如果使用者強制將 App 結束,之前 App 的操作狀態並不會保留,因此 state restoration 主要用在 App 在背景被系統殺掉的 case。)

Unit 6 Prototyping and Project Planning