文本输入和代理

键盘属性


键盘的外观由一系列叫做 UITextInputTraitsUITextField 属性决定。其中一个属性就是展示的键盘的类型。这个程序中, 你需要使用数字键盘。

选中文本框, 在属性指示器面板里面找到 Keyboard Type, 选择 Number Pad, 并把 Correction 和 Spell Checking 修改为 NO。

响应文本框的更改


下一步是当文本被键入到文本框中时, 更新 Celsius Label。你需要写点代码来完成这个任务。

目前, 它会响应在 ViewController.swift 中定义的 ViewController 类。然而, 对于管理华氏温度和摄氏温度转换的视图控制器来说, ViewController 不是一个很好的描述性的名字。拥有一个描述性的类型名允许你在工程变得更大的时候更容易地管理它。你将删除这个文件并使用一个更具描述性的类来代替它。

删除 ViewController.swift 并新建一个 ConversionViewController 类。在 ConversionViewController.swift 中:

1
2
3
4
5
import UIKit

class ConversionViewController: UIViewController {

}

即声明一个名为 ConversionViewController 的类继承自 UIViewController。现在你需要在 Main.storyboard 中把你创建的界面和这个你定义的新的视图控制器关联在一块儿。

打开 Main.storyboard 并选择 ConversionViewController 这个视图控制器, 要么在左侧的文档大纲中, 要么点击视图控制器上面的黄色圆圈。

打开身份检查器, 即工具视图中的第三个 tab(Command-Option-3)。 在最上面, 找到 Custom Class 一栏, 并把 Class 修改为 ConversionViewController

文本框是另外一种控制(UIButtonUITextField 都是 UIControl 的子类)并且都能在文本发生改变时发送事件。

为了完成这个, 你需要为 Celsius 文本框创建出口(outlet)并在文本发生改变时为调用的文本框创建动作(action)。

打开 ConversionViewController.swift 并定义这个 outlet 和 action。 现在, label 会被更新为用户输入到文本框中的任何东西。

1
2
3
4
5
6
7
class ConversionViewController: UIViewController {
@IBOutlet var celsiusLabel: UILabel!

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

打开 Main.storyboard 来完成这些连接。点击屏幕右上角的两个相交圆, 按住 Ctrl 键从 Conversion View Controller 中把 100 拖到右侧 ConversionViewController.swift 中的 celsiusLabel 代码中, 或者从 celsiusLabel 变量前面的小空心圆中拖到 ConversionViewController 中的 100 这个视图上去。

连接 action 会有一点不同, 因为你想要在编辑发生变化时触发这个动作(action)。

在画板中选中文本框并从工具面板中打开它的连接检查器(最右边一个 tab, 或者 Command-Option-6)。 连接检查器是一个用来设置连接并查看已经设置了什么连接的绝佳场所。

你想让发生在文本框中的变化触发你定义在 ConversionViewController 中的动作(action)。在连接检查器中, 定位到 Sent EventsEditing Changed 事件。点击并拖拽右侧的 Editing Changed 圆圈到 Conversion View Controller 并在弹出的菜单中点击 fahrenheitFieldEditingChanged: action。

img

构造并运行程序。点击文本框并输入一些数字。Celsius label 会输出和文本框中输入的一样文本。如果把文本框里的内容全部删除了, 注意你会看到 100 那个位置的 label 好像不见了。不含文本的 label 拥有宽和高都为 0 的固有内容, 所以它下面的 labels 会向上移动。我们来修复这个问题。

在 ConversionViewController.swift 中, 更新 fahrenheitFieldEditingChanged(_:), 当文本为空的时候让 label 展示 “???”。

1
2
3
4
5
6
7
8
@IBAction func fahrenheitFieldEditingChanged(textField: UITextField) {
if let text = textField.text where !text.isEmpty {
celsiusLabel.text = text
}
else {
celsiusLabel.text = "???"
}
}

销毁键盘(dismissing the keyboard)


一种通常的做法是检测当用户敲击 Return 键时用那个 action 来注销键盘。你将在第 13 章中使用这个方法。因为数字键盘没有 Return 键, 你得允许用户敲击背景视图来触发注销键盘的动作。

当轻敲文本框时,会在文本框身上调用 becomeFirstResponder() 。这会引起键盘出现。为了注销键盘, 你需要在文本框身上调用 resignFirstResponder() 。你会在第 13 章中学到更多关于这些方法的东西。

为了完成这个, 你需要为文本框设置一个出口(outlet), 和一个被触发的方法当背景视图被轻点时。这个方法会在文本框 outlet 身上调用 resignFirstResponder() 方法。

打开 ConversionViewController.swift 并在靠近最上面的位置申明一个 outlet 以引用文本框。

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

现在实现 action 方法以在被调用时注销键盘。

1
2
3
@IBAction func dismissKeyboard(sender: AnyObject) {
textField.resignFirstResponder()
}

仍然还需要 2 个东西, textField outlet 需要在 storyboard 文件中被连接, 还有你需要一种方式来触发你添加的 dismissKeyboard(_:) 方法。

首先考虑第一项, 打开 Main.storyboard 并选择 Conversion View Controller。 从 Conversion View Controller 中拖拽到画布中的文本框里并把它连接到 textField outlet。

现在你需要一种方法来触发你实现的 action 方法。你将使用手势识别来完成它。

手势识别是 UIGestureRecognizer 的一个子类, 它检测一系列特定的触摸事件并在序列被检测到时在它的目标上(target)调用一个动作(action)。手势识别有轻敲、滑动、长按等等。在这一章, 你会学习使用 UITapGestureRecognizer 来检测用户轻敲背景视图。你会在第 18 章学到更多关于手势识别的东西。

在 Main.storyboard, 在对象库中找到 Tap Gesture Recognizer 。把这个对象拖拽到 Conversion View Controller 的背景视图上。你会在 scene dock 里看见该手势识别的引用。

在 scene dock 中按住 Ctrl 键把该手势识别拖拽到 Conversion View Controller 中并把它连接到 dismissKeyboard 方法上。

img

实现温度转换


界面基本完成了, 让我们来实现华氏温标到摄氏温标的转换。你将存储当前华氏温标的值并计算摄氏温度的值当文本框的值改变时。

在 ConversionViewController.swift 中, 为 Fahrenheit 值添加一个属性。这会是一个可选类型的 double 值(一个 Double?)。

1
2
3
@IBOutlet var celsiusLabel: UILabel!

var fahrenheitValue: Double?

这个属性为什么是可选的是因为用户可能没有键入一个数字, 和之前你修复的空字符串问题类似。

现在为 Celsius 值添加一个计算属性。这个值将会基于 Fahrenheit 值被计算出来。

1
2
3
4
5
6
7
8
9
10
var fahrenheitValue: Double?

var celsiusValue: Double? {
if let value = fahrenheitValue {
return (value - 32) * (5/9)
}
else {
return nil
}
}

数字格式化程序


按照以上的步奏构建并运行程序后, 会出现小数点的问题。现在我们修复它。你使用一个 number formatter 来自定义数字的显示。

在 ConversionViewController.swift 中创建一个常量数字格式化程序。

1
2
3
4
5
6
7
let numberFormatter: NSNumberFormatter = {
let nf = NSNumberFormatter()
nf.numberStyle = .DecimalStyle
nf.minimumFractionDigits = 0
nf.maxmumFractionDigits = 1
return nf
}()

这儿你使用了闭包来实例化一个数字格式化程序。你在创建一个 .Decimal 风格的 NSNumberFormatter, 小数点不多于 1 位。

现在更新下 updateCelsiusLabel() 以使用该格式化程序。

1
2
3
4
5
6
7
8
func updateCelsiusLabel() {
if let value = celsiusValue {
celsiusLabel.text = numberFormatter.stringFromNumber(value)
}
else {
celsiusLabel.text = "???"
}
}

代理(Delegation)


代理是回调的一种面向对象的方法。回调(callback)是一个在事件之前提供的函数并且每次事件发生时都会调用该函数。有些对象需要执行多个事件的回调。例如, 当用户键入文本时还有当用户按下 Return 键时文本框将执行”回调”。

然而, 没有内置的方式用于两个(或更多)回调函数之间协同和分享信息。这是代理所强调的问题 — 你提供单个代理来接收特定对象的所有跟事件相关的回调。这个代理对象就能存储、操作、作用于和依赖从回调发出的信息。

当用户往文本框中键入东西时, 那个文本框就会询问它的代理是否想要接受用户做出的更改。对于 WorldTrotter, 如果用户尝试输入第二个数字分隔符, 就拒绝更改。文本框的代理将会是 ConversionViewController 的一个实例。

遵守协议


第一步就是使 ConversionViewController 类的实例执行 UITextField 代理的角色, 通过声明 ConversionViewController 遵守 UITextFieldDelegate 协议。对于每个代理角色, 都有一个对应的协议, 它里面声明了能在它的代理身上对象能调用的方法。

UITextFieldDelegate 协议看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
protocol UITextFieldDelegate: NSObjecProtocol {
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,
shouldChangeCharactersInRange range: NSRange,
replacementString string: String) -> Bool
optional func textFieldShouldClear(textField: UITextField) -> Bool
optional func textFieldShouldReturn(textField: UITextField) -> Bool
}

这个协议, 像所有协议一样, 是由 protocol 关键字后面跟着协议名UITextFieldDelegate来声明的。冒号后面的 NSObjectProtocol 引用 NSObject 协议并告诉你 UITextFieldDelegate 继承了 NSObject 协议中的所有方法。UITextFieldDelegate 特有的方法在下面定义。

你不能创建 protocol 实例; 它仅仅是一个方法和属性的列表。代替的是, 实现留给了遵守该协议的每个类型。

要让类遵守一个协议, 需要把协议名放在冒号后面, 如果该类有父类, 则需要用逗号分割父类和协议名, 把协议名放在父类的后面。在 ConversionViewController.swift 中, 声明那个ConversionViewController 遵守 UItextFieldDelegate 协议。

1
2
3
class ConversionViewController: UIViewController, UITextFieldDelegate {

}

用在代理中的协议叫做代理协议, 代理协议的命名约定是代理类的名字加上单词 Delegate。不是所有的协议都是代理协议, 然而你会在第 15 章看到不同种类协议的一个例子。目前我们提到的协议是 iOS SDK 的一部分, 但是你也可以写自己的协议。

使用代理


现在你已经声明了 ConversionViewController 遵守 UITextFieldDelegate 协议的类, 你可以设置文本框的 delegate 属性了。

打开 Main.storyboard 并按住 Ctrl 键拖拽文本框到 Conversion View Controller 中(拖到最上面的小黄圈中)。在弹出的菜单中选择 delegate。

下一步, 你将实现你感兴趣的 UITextFieldDelegate 方法 — textField(_:shouldChangeCharactersInRange:replacementString:)。 因为文本在它的代理上调用这个方法, 你必须在 ConversionViewController.swift 中实现这个方法。

在 在 ConversionViewController.swift 中实现 textField(_:shouldChangeCharactersInRange:replacementString:) 以打印文本框当前的文本还有替换字符串。现在, 只从该方法中返回 true

1
2
3
4
5
6
7
8
9
func textField(textField: UITextField,
shouldChangeCharactersInRange range: NSRange,
replacementString string: String) -> Bool {

print("current text: \(textField.text)")
print("Replacement text: \(string)")

return true
}

从逻辑上讲, 如果已经存在的字符串拥有一个小数分隔符并且替换字符串也拥有一个小数分隔符, 那么更改会被拒绝。

更新 textField(_:shouldChangeCharactersInRange:replacementString:) 来使用该逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func textField(textField: UITextField,
shouldChangeCharactersInRange range: NSRange,
replacementString string: String) -> Bool {

let existingTextHasDecimalSeparator = textField.text?.rangeOfString('.')
let replacementTextHasDecimalSeparator = string.rangeOfString(".")

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

运行这个程序, 当你输入多个小数点时, 程序会拒绝让你输入多余1个的小数点。

挑战


禁止用户输入字母字符。