第四章——文本輸入和委託模式

WorldTrotter 看起來不錯,但到目前爲止它並沒有做任何事情。 在本章中,您將向 WorldTrotter 添加一個 UITextField 實例。 文本字段將允許用戶鍵入華氏溫度,然後將其轉換爲攝氏溫度並顯示在界面上(圖4.1)。

圖4.1 具有 UITextField 的 WorldTrotter

您要做的第一件事是向界面添加一個 UITextField,併爲該文本字段設置約束。 此文本字段將替換當前界面中文本爲 「212」 的頂部標籤。

打開 Main.storyboard。 選擇頂部標籤,然後按 Delete 鍵刪除此子視圖。 所有其他標籤的限制將變爲紅色,因爲它們都直接或間接地錨定到該頂部標籤(圖4.2)。 這無所謂, 你會很快修復它。

圖4.2 標籤的不明確邊框

打開對象庫,然後將 Text Field 拖動到畫布頂部之前放置標籤的位置。

現在爲此文本字段設置約束。 選中文本字段後,打開 Align 菜單,並將 Horizontally in Container 設置爲 0.確保 Update Frames 設置爲 None,然後點擊 Add 1 Constraint

現在打開 Add New Constraints 菜單。 給文本字段提供 8 點的頂邊約束,8 點的底邊約束,250 的寬度(圖4.3)。 添加這三個約束。

圖4.3 文本字段 Add New Constraints 菜單

最後,選擇文本字段下面的標籤。 打開 Align 菜單,設置 Horizontal Centers 爲 0,點擊 Update Frames 菜單的 All Frames in Container ,最後點擊 Add 1 Constraint(圖4.4)。

圖4.4 對齊文本字段

接下來,自定義一些文本字段屬性。 打開文本字段的屬性檢查器,並進行以下更改:

  • 將文本顏色(從 Color 菜單)設置爲燒橙色。
  • 將字體大小設置爲 System 70
  • Alignment 設置爲居中。
  • 將佔位符文本設置爲 value。這是當用戶沒有輸入任何文本時將顯示的文本。
  • 將帶有虛線的分段控件的第一個元素的 Border Style 設置爲 none

文本字段的屬性檢查器應如圖 4.5 所示。

圖4.5 文本字段屬性檢查器

因爲文本字段的字體改變了,畫布上的視圖現在錯位了。 選擇灰色背景視圖,打開 Resolve Auto Layout Issues 菜單,然後從 All Views in View Controller 部分中選擇 Update Frames。 文本字段和標籤將重新定位以匹配其約束(圖4.6)。

圖4.6 更新邊框

構建並運行應用程序。 點擊文本字段並輸入一些文本。 如果沒有看到鍵盤,請單擊模擬器的 Hardware 菜單,然後選擇 KeyboardToggle Software Keyboard 或使用鍵盤快捷鍵 Command-K。 默認情況下,模擬器將計算機的鍵盤視爲連接到模擬器的藍牙鍵盤。 這通常不是你想要的。 反而您希望模擬器使用的是不附帶任何附件的IOS設備屏幕鍵盤。

鍵盤屬性

點擊文本字段時,鍵盤自動向上滑到屏幕上。 (您將在本章後面看到這爲什麼會發生。)鍵盤的外觀由一組名爲 UITextInputTraitsUITextField 屬性決定。 這些屬性之一是顯示的鍵盤類型。 對於這個應用程序,你想使用數字鍵盤。

在文本字段的屬性檢查器中,找到名爲 Keyboard Type 的屬性,然後選擇 Decimal Pad。 在同一部分,您可以看到您可以自定義鍵盤的其他一些文本輸入特徵。 將 校正(Correction)拼寫檢查(Spell Checking) 更改爲 No(圖4.7)。

圖4.7 鍵盤文本輸入特徵

構建並運行應用程序。 點擊文本字段將輸入數字。

響應文本字段更改

項目的下一步將是在文本字段中鍵入文本時更新攝氏度標籤。 您將需要編寫一些代碼來執行此操作。 具體來說,這個代碼將寫在與該界面關聯的視圖控制器子類中。

當前界面對應於 ViewController.swift 中定義的 ViewController 類。 但是,對於管理華氏和攝氏之間轉換的視圖控制器,ViewController 不是一個非常具有描述性的名稱。 具有描述性的類型名稱可以讓您在項目越來越大時更輕鬆地維護。

你將刪除這個文件,並用更具描述性的類替換它。

在項目導航器中,找到 ViewController.swift 並將其刪除。 然後通過選擇 FileNewFile... (或按Command-N)創建一個新文件。 選擇頂部的 iOS 後,在 Source 標籤下選擇 Swift File,然後單擊 Next

在下一個窗格中,將此文件命名爲 ConversionViewController。 將文件保存在 WorldTrotter 項目中的 WorldTrotter 組中,並確保選中了 WorldTrotter 目標,如圖4.8所示。 單擊 CreateXcode 將在編輯器中打開 ConversionViewController.swift

圖4.8保存Swift文件

ConversionViewController.swift 中,導入 UIKit 並定義一個名爲 ConversionViewController 的新視圖控制器。

import Foundation
import UIKit

class ConversionViewController: UIViewController {
}

現在,您需要將您在 Main.storyboard 中創建的界面與此新的視圖控制器相關聯。

打開 Main.storyboard,然後在文檔大綱中或通過單擊界面上方的黃色圓圈選擇 View Controller。

打開 身份(identity) 檢查器,這是實用程序視圖(Command-Option-3)中的第三個選項卡。 在頂部,找到 Custom Class,並將類更改爲 ConversionViewController(圖4.9)。 (您將在第5章中瞭解這些)

圖4.9 更改自定義類

您在第1章中看到,當按鈕被點擊時,按鈕可以將事件發送到控制器。 文本字段是另一個控件(UIButtonUITextField 都是 UIControl 的子類),並且可以在文本更改時發送事件。

要使這一切正常工作,您將需要創建一個 outlet 到攝氏文本標籤,併爲文本字段創建一個動作,以便在文本更改時調用。

打開 ConversionViewController.swift 並定義此 outlet 和 action。 現在,標籤將隨用戶輸入文本字段的任何文本而更新。

class ConversionViewController: UIViewController {

  @IBOutlet var celsiusLabel: UILabel!

  @IBAction func fahrenheitFieldEditingChanged(_ textField: UITextField){
    celsiusLabel.text = textField.text
  }
}

打開 Main.storyboard 將它們連接起來。 outlet 將像第1章一樣連接。右鍵從 Conversion View Controller 拖動到攝氏標籤(當前顯示爲 「100」),並將其連接到 celsiusLabel

連接動作會有所不同,因爲您希望在編輯更改時觸發動作。

選擇畫布上的文本字段,然後從實用程序窗格(最右邊的選項卡或Command-Option-6)中打開其連接檢查器。 連接檢查器允許您進行連接並查看已經建立了哪些連接。

您將對文本字段進行更改,觸發您在 ConversionViewController 中定義的操作。 在連接檢查器中,找到 Sent Events 部分和 Editing Changed。 單擊並拖動 Editing ChangedConversion View Controller 的右側的圓圈,然後單擊彈出菜單中的 fahrenheitFieldEditingChanged:動作(圖4.10)。

圖4.10連接編輯更改的事件

構建並運行應用程序。 點擊文本字段並鍵入一些數字。 攝氏標籤將模擬輸入的文本。現在刪除文本字段中的文本,並注意標籤是如何消失的。 沒有文字的標籤的內容內容寬度和高度爲 0,因此下方的標籤會向上移動。 我們來解決這個問題。

ConversionViewController.swift 中,如果文本字段爲空,更新 fahrenheitFieldEditingChanged(_ :) 顯示爲 「???」。

@IBAction func fahrenheitFieldEditingChanged(_ textField: UITextField) {
  //celsiusLabel.text = textField.text

  if let text = textField.text, !text.isEmpty {
    celsiusLabel.text = text
  } else {
    celsiusLabel.text = "???"
  }
}

如果文本字段有文本,文本不爲空,它將在 celsiusLabel 上顯示。 如果這些條件中的任何一個都不爲真,那麼 celsiusLabel 將被賦予字符串「???」。

構建並運行應用程序。 添加一些文本,刪除它,並在文本字段爲空時確認攝氏標籤填充有「???」。

取消鍵盤

目前,沒有辦法解除鍵盤。 我們來補充一下這個功能。 這樣做的一個常見方法是檢測用戶何時點擊 Return 鍵並使用該動作來關閉鍵盤; 您將在第14章中使用此方法。由於數字小鍵盤沒有Return鍵,您將允許用戶點擊背景視圖以觸發解除。

當文本字段被點擊時,FirstFirstResponder() 將被調用 。 這是除了別的以外,使鍵盤出現的方法。 要關閉鍵盤,可以在文本字段中調用 resignFirstResponder() 方法。 您將在第14章中更多地瞭解這些方法。

對於 WorldTrotter,您將需要一個文本字段的 outlet 和當後臺視圖被輕觸時觸發的方法。 此方法將在文本字段插槽上調用 resignFirstResponder()。 我們先來看看代碼。

打開 ConversionViewController.swift 並在文本字段引用附近聲明一個 outlet 。

@IBOutlet var celsiusLabel: UILabel!
@IBOutlet var textField: UITextField!

現在實現一個在調用時會關閉鍵盤的動作方法。

(在上面的代碼中,我們加上了現有的代碼,以便您可以正確定位新的代碼的位置。在下面的代碼中,我們不提供該上下文,因爲新代碼的位置不重要,只要它在大括號內 在這種情況下,實現的類型是 ConversionViewController 類,當一個代碼塊包含所有新的代碼時,我們建議你把它放在類型的實現的末尾,就在最後的大括號裏面,在第15章你會看到當您的文件變得更長,更復雜時,如何輕鬆地在實現文件中導航。)

@IBAction func dismissKeyboard(_ sender: UITapGestureRecognizer)
{
  textField.resignFirstResponder()
}

仍然需要完成以下:textField outlet 需要連接在故事板文件中,您需要一個觸發您添加的 dismissKeyboard(_ :) 方法的方法。

要處理第一項,請打開 Main.storyboard 並選擇 Conversion View Controller。右鍵從 Conversion View Controller 拖動到畫布上的文本字段,並將其連接到 textField

現在您需要一種方式來觸發您實現的方法。 您將使用手勢識別器來完成此操作。

手勢識別器是 UIGestureRecognizer 的子類,它檢測特定的觸摸序列,並在檢測到該序列時對其目標調用動作。 有手勢識別器檢測觸擊,滑動,長按等等。 在本章中,您將使用 UITapGestureRecognizer 來檢測用戶何時觸擊背景視圖。 您將在第19章中更多地瞭解手勢識別器。

Main.storyboard 中,在對象庫中找到 Tap Gesture Recognizer。 將此對象拖動到 Conversion View Controller 的背景視圖中。 您將在 scene 底座,也就是畫布上方的圖標行中的看到對該手勢識別器的引用。

右鍵從 Tap Gesture Recognizer 拖動到 Conversion View Controller,並將其連接到 dismissKeyboard: 方法(圖4.11)。

圖4.11連接手勢識別器動作

實現溫度轉換

瞭解了界面的基本原理後,讓我們來實現從華氏溫度轉爲攝氏溫度。 您將要存儲當前的華氏值,並在文本字段更改時計算攝氏值。

ConversionViewController.swift 中,爲華氏值添加一個屬性。 這將是溫度的可選測量 (Measurement?)。

@IBOutlet var celsiusLabel: UILabel!
var fahrenheitValue: Measurement<UnitTemperature>?

該屬性是可選的原因是因爲用戶可能沒有鍵入一個數字,類似於您之前修復的空字符串問題。

現在爲 Celsius 值添加一個用於計算的屬性。 該值將根據華氏值計算。

var fahrenheitValue: Measurement<UnitTemperature>?

var celsiusValue: Measurement<UnitTemperature>? {
  if let fahrenheitValue = fahrenheitValue {
    return fahrenheitValue.converted(to: .celsius)
  } else {
    return nil
  }
}

首先檢查一下是否有華氏值。 如果有,您將該值轉換爲等值的攝氏度。 如果沒有華氏值,那麼您不能計算攝氏度值,因此您返回 nil

接下來完成:每當華氏值變化時,更新攝氏標籤。

ConversionViewController 添加一個方法來更新 celsiusLabel

func updateCelsiusLabel() {
  if let celsiusValue = celsiusValue {
    celsiusLabel.text = "\(celsiusValue.value)"
  } else {
    celsiusLabel.text = "???"
  }
}

您想要在華氏值變化時調用此方法。 爲此,您將使用 屬性觀察者(property observer),它是一個代碼塊,當屬性的值更改時,該代碼將被調用。

在屬性聲明之後立即使用花括號來聲明屬性觀察者。 在大括號內,您可以使用 willSetdidSet 來聲明您的觀察者,具體取決於是否要在屬性值更改之前或之後立即通知屬性值更改。

添加一個屬性觀察器,使得當屬性值更改後調用 fahrenheitValue

var fahrenheitValue: Measurement<UnitTemperature>? {
  didSet {
    updateCelsiusLabel()
  }
}

(一個小筆記:當屬性值從構造方法中更改時,不會觸發屬性觀察者。)

有了這個邏輯,您現在可以在文本字段更改時更新華氏值(這又會觸發更新攝氏標籤)。

fahrenheitFieldEditingChanged(_ :) 中,刪除以前的非轉換實現,而沒有正確更新華氏值。

@IBAction func fahrenheitFieldEditingChanged(_ textField: UITextField) {
  if let text = textField.text, !text.isEmpty {
    celsiusLabel.text = text
  } else {
    celsiusLabel.text = "???"
  }

  if let text = textField.text, let value = Double(text) {
    fahrenheitValue = Measurement(value: value, unit: .fahrenheit)
  } else {
    fahrenheitValue = nil
  }
}

首先,您檢查文本字段是否有文本。 如果有,檢查該文本是否可以由 Double 表示。 例如,「3.14」 可以由 Double 表示,但 「three」 和 「1.2.3」 不能。 如果這兩個檢查都通過了,那麼華氏溫度值被設置爲用該 Double 值初始化的 Measurement。 如果這些檢查中的任何一個失敗,則華氏值設置爲 nil

構建並運行應用程序。 華氏和攝氏之間的轉換效果非常好——只要您輸入有效的數字。 (它顯示的數字有些你並不想要(小數點後的數字過多),接下來我們來解決它)

如果應用程序首次啓動時就更新 celsiusLabel,而不是仍然顯示 「100」,那就更好了。

覆蓋 viewDidLoad() 以設置初始值,類似於第1章中的操作。

override func viewDidLoad() {
  super.viewDidLoad()

  updateCelsiusLabel()
}

在本章的剩餘部分中,您將更新 WorldTrotter 以解決兩個問題:您將格式化 Celsius 值以顯示最多一個小數位的精度,並且不允許用戶鍵入多個小數分隔符。

您的應用程序還有其他幾個問題,但現在您將重點關注這兩個問題。在本章結尾處,其他問題將作爲挑戰呈現。 我們開始更新攝氏度值的精度。

數字格式化

您使用 數字格式化器(number formatter) 自定義數字的顯示。 還有其他格式化器用於格式化日期,能量,質量,長度,測量等。

ConversionViewController.swift 中創建一個 number formatter。

let numberFormatter: NumberFormatter = {
  let nf = NumberFormatter()
  nf.numberStyle = .decimal
  nf.minimumFractionDigits = 0
  nf.maximumFractionDigits = 1
  return nf
}()

在這裏,您使用閉包來實例化數字格式化程序。 您正在創建具有 .decimal 樣式的 NumberFormatter,並將其配置爲顯示不超過一個小數位數。 您將在第16章中瞭解更多關於聲明屬性的新語法。

現在修改 updateCelsiusLabel() 來使用這個格式化程序。

func updateCelsiusLabel() {
  if let celsiusValue = celsiusValue {
    celsiusLabel.text = "\(celsiusValue.value)"
    celsiusLabel.text =numberFormatter.string(from: NSNumber(value: celsiusValue.value))
  } else {
    celsiusLabel.text = "???"
  }
}

構建並運行應用程序。 輸入多個華氏溫度觀察格式化方法是否有效。 您將不會在攝氏標籤上看到多於一位的小數出現。

在下一節中,您將更新應用程序,實現在文本字段中接受最多一個小數分隔符。 爲此,您將使用一種常見的 iOS 設計模式,稱爲 委託模式(delegation)

委託模式

委託是一個面向對象的 回調(callback) 方法。 回調是在事件發生前提供的一個函數,每次事件發生時都會調用它。 一些對象需要對多個事件進行回調。 例如,當用戶輸入文本以及用戶按 Return 鍵時,文本字段都需要「回調」。

然而,兩個(或多個)回調函數之間沒有內置的方式來協調和共享信息。 這是委託所解決的問題——你提供一個委託來接收特定對象的所有與事件有關的回調。 然後,該委託對象可以進行存儲,操作等,並在它認爲合適的時候從回調中傳遞信息。

當用戶文本字段輸入時,該文本字段將詢問其委託是否接受用戶所做的更改。 對於 WorldTrotter,如果用戶嘗試輸入第二個小數分隔符,則要拒絕該更改。 文本字段的委託將是 ConversionViewController 的實例。

符合協議

第一步是通過聲明 ConversionViewController 符合 UITextFieldDelegate 協議,使 ConversionViewController 類的實例成爲 UITextField 的委託角色。 對於每個委託角色,都有一個相應的協議,聲明對象可以調用它的委託的方法。

UITextFieldDelegate 協議如下所示:

protocol UITextFieldDelegate: NSObjectProtocol {
  optional func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool
  optional func textFieldDidBeginEditing(_ textField: UITextField)
  optional func textFieldShouldEndEditing(_ textField: UITextField) -> Bool
  optional func textFieldDidEndEditing(_ textField: UITextField)
  optional func textField(_ textField: UITextField,
            shouldChangeCharactersIn range: NSRange,
            replacementString string: String) -> Bool
  optional func textFieldShouldClear(_ textField: UITextField) -> Bool
  optional func textFieldShouldReturn(_ textField: UITextField) -> Bool
}

UITextFieldDelegate ,與其它協議一樣,使用關鍵字 protocol 來聲明,其後面是它的名字 。 冒號之後的 NSObjectProtocol 是指 NSObject 協議,並告訴您 UITextFieldDelegate 繼承 NSObject 協議中的所有方法。 接下來聲明特定於 UITextFieldDelegate 的方法。

您無法創建協議實例; 它只是方法和屬性的列表。 相反,實現方法被保留到符合協議的每一種類型。

在類的聲明中,類遵循的協議列表跟在父類(如果有的話)之後並以逗號分隔的。 在 ConversionViewController.swift 中,聲明 ConversionViewController 符合 UITextFieldDelegate 協議。

class ConversionViewController: UIViewController, UITextFieldDelegate {

用於委託的協議稱爲 委託協議(delegate protocol),委託協議的命名約定是委派類的名稱加上 Delegate 一詞。 然而並不是所有的協議都是委託協議,第16章中將會看到一種不同類型協議的例子。目前爲止我們提到的協議是 iOS SDK 的一部分,您也可以編寫自己的協議。

使用委託

現在您已將 ConversionViewController 聲明爲符合 UITextFieldDelegate 協議,您可以去設置文本字段的 delegate* 屬性了。

打開 Main.storyboard,然後從文本框拖動到 Control View Controller。 從彈出菜單中選擇 delegate 並將文本字段的 delegate 屬性連接到 ConversionViewController

接下來,您將實現您感興趣的 UITextFieldDelegate 方法 —— textField(_:shouldChangeCharactersIn:replacementString :)。 因爲文本字段在其委託中調用此方法,所以必須在 ConversionViewController.swift 中實現它。

ConversionViewController.swift,實現 textField(_:shouldChangeCharactersIn:replacementString:) 打印文本字段的當前文本以及替換字符串。現在,只要這個方法返回 true即可。

func textField(_ textField: UITextField,
      shouldChangeCharactersIn range: NSRange,
      replacementString string: String) -> Bool {

  print("Current text: \(textField.text)")
  print("Replacement text: \(string)")
  return true
}

注意,Xcode 能夠自動完成這個方法,因爲 ConversionViewController 符合 UITextFieldDelegate。在實現協議的方法之前,先聲明一個協議是一個好主意,這樣 Xcode 纔會提供這種支持。

構建並運行應用程序。 在文本字段中輸入幾位數字,並觀看 Xcode 控制檯(圖4.12)。 它打印文本字段的當前文本以及替換字符串。

圖4.12打印到控制檯

您的目標是防止多個小數分隔符,考慮這個 當前文本(current text)替換文本(replacement text) 。 邏輯上,如果現有的字符串有一個小數分隔符,替換字符串有一個小數分隔符,那麼更改應該被拒絕。

ConversionViewController.swift 中,更新 textField(_:shouldChangeCharactersIn:replacementString :) 以使用此邏輯。

func textField(_ textField: UITextField,
      shouldChangeCharactersIn range: NSRange,
      replacementString string: String) -> Bool {

  print("Current text: \(textField.text)")
  print("Replacement text: \(string)")
  return true

  let existingTextHasDecimalSeparator = textField.text?.range(of: ".")
  let replacementTextHasDecimalSeparator = string.range(of: ".")

  if existingTextHasDecimalSeparator != nil,
    replacementTextHasDecimalSeparator != nil {
    return false
  } else {
    return true
  }
}

構建並運行應用程序。 嘗試輸入多個小數分隔符; 應用程序將拒絕您輸入的第二個小數分隔符。

協議其他

UITextFieldDelegate 協議中,有兩種方法:處理信息更新的方法和處理輸入請求的方法。 例如,如果文本字段的代理想知道用戶在文本字段上點擊的時候,文本字段的委託應該實現 textFieldDidBeginEditing(_ :) 方法。

另一方面,textField(_:shouldChangeCharactersIn:replacementString :) 是一個輸入請求。 文本字段在其委託中調用此方法來詢問替換字符串是否應被接受或拒絕。 該方法返回一個 Bool,它是委託者的回覆。

在協議中聲明的方法可以是必需的或可選的。 默認情況下,協議方法是必要的,這意味着符合協議的類必須具有這些方法的實現。 如果一個協議有可選的方法,那麼它們前面會有 optional。 回顧 UITextFieldDelegate 協議,您可以看到所有的方法都是可選的。 委託協議通常是這樣的。

青銅挑戰:禁止字母字符

目前,用戶可以通過使用藍牙鍵盤或將複製的文本粘貼到文本字段中來輸入字母字符。 解決這個問題。 提示:您需要使用到 NSCharacterSet 類。