添加对Interface Builder的支持
如果你在Interface Builder中看rating控件,你会发现它就是个大的、空的矩形。更糟糕的是,如果你选择rating控件,它的边框将变成红色,这表明rating 控件的布局有问题。事实上,还有另外两个表明可能有问题的迹象。在右侧的Activity viewer(活动查看器)有一个黄色警告三角。在View Controller场景旁边的大纲视图还有一个红色的错误图标。
如果你点击这些图标,Xcode会显示关于这两个错误和警告的更多信息。
这两种情况,根本的原因是一样的。Interface Builder 不知道任何关于rating控件的内容。为了修复它,你需要使用@IBDesignabel来定义控件。它让Interface Builder实例化你的控件的一个副本,并直接将其绘制到画布中。另外,现在Interface Builder具有一个活动的控件副本,它的布局引擎能够正确的定位和设置控件的大小。
把控件声明为@IBDesignable
- 在RatingControl.swift,找到类声明:
class RatingControl: UIStackView {
- 在它前面加上 @IBDesignable。
@IBDesignable class RatingControl: UIStackView {
- 按下Command-B来构建项目(或者选择 Product > Build)。
-
打开Main.storyboard。当构建完成,storyboard将显示rating控件的实时视图。
注意,现在画布正确的设置了RatingControl视图的尺寸和位置。而警告和错误也已经消失。
Interface Builder能够做很多事,不仅仅是显示你的自定义视图。你能够指定一些属性可以在Attributes Inspector中被设置。添加@IBInspectable属性到所需的属性。Interface Builder支持基本类型(以及相应的可选项)的检查,包括:布尔值、数字、字符串,以及CGRect、CGSize、CDPoint和UIColor。
添加可检查属性
- 在RatingControl.swift中,在//MARK: Properties 部分的下面添加如下属性:
@IBInspectable var starSize: CGSize = CGSize(width: 44.0, height: 44.0)
@IBInspectable var starCount: Int = 5
这几行代码定义了按钮的尺寸,并定义了你的控件有多少个按钮。
- 现在你需要使用这些值。定位到setupButtons()方法,做如下改变:
- 在for-in声明,把数字5改为startCount。
- 在 button.heightAnchor.constraint()方法调用,把数字44.0改为starSize.height。
- 在 button.widthAnchor.constraint()方法调用,把数字44.0改为starSize.width。现在方法应该如下所示:
private func setupButtons() {
for _ in 0..<starCount {
// Create the button
let button = UIButton()
button.backgroundColor = UIColor.red
// Add constraints
button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: starSize.height).isActive = true
button.widthAnchor.constraint(equalToConstant: starSize.width).isActive = true
// Setup the button action
button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
// Add the button to the stack
addArrangedSubview(button)
// Add the new button to the rating button array
ratingButtons.append(button)
}
}
如果你切换到 Main.storyboard并选择RatingControl,你将看到Star Size 和Star Count 已设置到了Attributes inspector中。虚线表示控件当前正在使用默认的值(44.0点和5星)。但是现在改变这些值还不会改变控件。
- 要更新控件,你需要在每次这些属性改变的时候重新设置控件的按钮。为了实现它,给每个属性添加一个属性观察器(property observer)。属性观察器在属性值每次被设置时调用,并且可以在值改变之前或之后立刻执行。
@IBInspectable var starSize: CGSize = CGSize(width: 44.0, height: 44.0) {
didSet {
setupButtons()
}
}
@IBInspectable var starCount: Int = 5 {
didSet {
setupButtons()
}
}
这里,你为starSize和starCount属性定义了属性观察器。具体来说,didSet属性观察器会在属性值被设置之后立刻被调用。你的实现是调用 setupButtons()方法。这个方法使用更新的尺寸和数量添加新的按钮;但是,这个实现没有摆脱旧的按钮。
- 为了清除旧的按钮,在setupButtons() 方法的开始位置添加如下代码:
// clear any existing buttons
for button in ratingButtons {
removeArrangedSubview(button)
button.removeFromSuperview()
}
ratingButtons.removeAll()
这段代码遍历所有的rating控件的按钮。首先,它从stack view管理的视图列表中删除按钮。这告诉stack view它不用再计算这个按钮的尺寸和位置——但按钮仍然是stack view的子视图。接下来,代码把按钮从stack view中完全删除。最后,当所有的按钮都被删除后,代码清空ratingButtons数组。
现在setupButtons()方法看上去是这样的。
private func setupButtons() {
// clear any existing buttons
for button in ratingButtons {
removeArrangedSubview(button)
button.removeFromSuperview()
}
ratingButtons.removeAll()
for _ in 0..<starCount {
// Create the button
let button = UIButton()
button.backgroundColor = UIColor.red
// Add constraints
button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: starSize.height).isActive = true
button.widthAnchor.constraint(equalToConstant: starSize.width).isActive = true
// Setup the button action
button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
// Add the button to the stack
addArrangedSubview(button)
// Add the new button to the rating button array
ratingButtons.append(button)
}
}
注意
从性能角度上看,删除并替换所有按钮并不是一个好主意。但是,didSet观察器只能在设计的时候被Interface Builder调用。当应用运行时,setupButtons()只被调用一次,在控件第一次从storyboard被加载的时候。因此,没有必要创建更复杂的解决方案来更新现有的按钮。
检查点:打开Main.storyboard并选择RatingControl对象。尝试改变Start Size和StarCount属性。画布中的控件会发生改变以匹配新的设置。运行应用,你将在模拟器中看到这些改变。
记住,当你测试完了之后,把值改回默认的。
进一步探索
更多关于使用自定义视图的信息,见Xcode help中的Lay out user interfaces > Add objects and media > Render custom views。
添加星星图片到按钮
接下来,你将添加空的、填充的、以及高亮的星星图片到按钮。
你可以在课后的下载文件中找到Images文件,从里面找到这些图片,或者使用你自己的图片。(确保图片的名字和你在稍后代码中图片的名字保持一致。)
添加图片到你的项目
- 在project navigator中,选择Assets.xcassets来查看资源目录(asset catalog)。
回想一下,资源目录是为应用存储和组织图片资源的地方。 -
在左下角,点击加号(+)并从弹出菜单选择New Folder。
- 双击文件名称,重命名为Rating Images。
- 选中这个文件,在右下角,点击加号按钮并在弹出菜单中选择New Image Set。
一个图片集合(image set)代表一个图像资源,但是能够包含图像的不同版本,这些版本是用来在不同屏幕分辨率上显示的。 - 双击image set的名字,重命名为emptyStar。
- 在电脑中,选择你想添加的空心星星图片。
-
拖拽这个图片放到image set的2x插槽内。
2x是本课你选中的iPhone 7模拟器的显示分辨率。
- 选中这个文件,在右下角,点击加号按钮并在弹出菜单中选择New Image Set。
- 双击image set的名字,重命名为filledStar。
- 在电脑上,选择你想要添加的填充星星图片。
-
拖拽这个图片放到image set的2x插槽内。
- 选中这个文件,在右下角,点击加号按钮并在弹出菜单中选择New Image Set。
- 双击image set的名字,重命名为highlightedStar。
- 在电脑上,选择你想要添加的填充星星图片。
-
拖拽这个图片放到image set的2x插槽内。
你的资源目录看上去是这样的。
接下来,编写代码来在相应的时候为按钮设置合适的图片。
为按钮设置星星图片
- 在RatingControl.swift导航到setupButtons()方法,并且在创建按钮的for-in循环的上面添加如下代码:
// Load Button Images
let bundle = Bundle(for: type(of: self))
let filledStar = UIImage(named: "filledStar", in: bundle, compatibleWith: self.traitCollection)
let emptyStar = UIImage(named:"emptyStar", in: bundle, compatibleWith: self.traitCollection)
let highlightedStar = UIImage(named:"highlightedStar", in: bundle, compatibleWith: self.traitCollection)
这些行从资源目录加载星星图片。注意资源目录是在应用的主束(bundle)里。这意味着应用可以使用 UIImage(named:)方法加载图片。但是,因为控件是@IBDesignable,所以代码也需要运行在Interface Builder中。要让图片在Interface Builder中正确的加载,你必须明确指定目录的束。这样就确保系统能找到并加载图片。
- 找到设置背景颜色的代码行,并用下面的代码进行替换。
// Set the button images
button.setImage(emptyStar, for: .normal)
button.setImage(filledStar, for: .selected)
button.setImage(highlightedStar, for: .highlighted)
button.setImage(highlightedStar, for: [.highlighted, .selected])
按钮有五种不同状态:normal(一般)、高亮(highlighted)、聚焦(focused)、选中(selected)、和禁用(disabled)。默认时,按钮根据它的状态来修改自身的显示,例如,一个禁用的按钮呈现灰色。一个按钮可以在同时呈现多种状态,例如按钮即是禁用又是高亮。
按钮总是从normal状态开始(不是高亮、选中、聚焦、或者禁用)。无论何时用户点击时,按钮是高亮。你也能用代码设置按钮是选中还是禁用。聚焦状态使用在基于焦点的界面,例如Apple TV。在上面的代码中,你告诉按钮normal状态下,使用空心星星图片。这时按钮默认的图片。每当一个状态或混合状态没有它们自己的图片时,系统就会使用这个图片(可能具有附加效果)。
接下来,上面的代码为选中状态设置了填充图片。如果你用编码的方式将按钮设置为选中,它将从空心星星变为已填充星星。最后,为高亮状态以及高亮和选中混合状态都设置高亮图片。当用户点击按钮的时候,无论是否选中,系统都会显示高亮按钮图片。
你的setupButtons()方法看上去是这样了:
private func setupButtons() {
// Clear any existing buttons
for button in ratingButtons {
removeArrangedSubview(button)
button.removeFromSuperview()
}
ratingButtons.removeAll()
// Load Button Images
let bundle = Bundle(for: type(of: self))
let filledStar = UIImage(named: "filledStar", in: bundle, compatibleWith: self.traitCollection)
let emptyStar = UIImage(named:"emptyStar", in: bundle, compatibleWith: self.traitCollection)
let highlightedStar = UIImage(named:"highlightedStar", in: bundle, compatibleWith: self.traitCollection)
for _ in 0..<starCount {
// Create the button
let button = UIButton()
// Set the button images
button.setImage(emptyStar, for: .normal)
button.setImage(filledStar, for: .selected)
button.setImage(highlightedStar, for: .highlighted)
button.setImage(highlightedStar, for: [.highlighted, .selected])
// Add constraints
button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: starSize.height).isActive = true
button.widthAnchor.constraint(equalToConstant: starSize.width).isActive = true
// Setup the button action
button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
// Add the button to the stack
addArrangedSubview(button)
// Add the new button to the rating button array
ratingButtons.append(button)
}
}
检查点:运行应用。你将看到星星替代了红色按钮。点击这里的任何按钮让然会调用ratingButtonTapped(_:)并且在控制台上打印消息。当你点击按钮的时候甚至能看到高亮星星,但是你的按钮还没有变为填充图片。你要接着修改。
实现按钮动作
用户需要能够通过点击星星来选择评分,所以你要使用真正的代码来代替ratingButtonTapped(_:) 方法中调试用的代码。
实现评分动作
- 在RatingControl.swift中,找到ratingButtonTapped(button:)方法。
func ratingButtonTapped(button: UIButton) {
print("Button pressed 👍")
}
- 用下面的代码替换print语句:
func ratingButtonTapped(button: UIButton) {
guard let index = ratingButtons.index(of: button) else {
fatalError("The button, \(button), is not in the ratingButtons array: \(ratingButtons)")
}
// Calculate the rating of the selected button
let selectedRating = index + 1
if selectedRating == rating {
// If the selected star represents the current rating, reset the rating to 0.
rating = 0
} else {
// Otherwise set the rating to the selected star
rating = selectedRating
}
}
在上面的代码中,indexOf(:)方法尝试在按钮数组中找这个按钮,并在找到后返回它在数组中的索引值。这个方法返回的是可选类型Int,因为你查找的对象在集合中可能不存在。但是,因为触发该动作的唯一按钮集是你创建的,如果indexOf(:)方法不能找到一个匹配的按钮,那么代码就有了严重的错误。抛出错误、终止应用,并在控制台上打印有用的错误信息,帮助你在设计和测试应用时找到并修复错误。
一旦你有按钮的索引的时候(这个值在0-4之间),你给索引加1来计算评分(就是1-5之间的值)。如果用户点击的星星恰好是当前的评分,你就重置控件的rating属性为0。否则,你就设置rating值为选中的评分值。
- 一旦评分值被设置,你需要一些方法来更新按钮的显示。在RatingControl.swift中,在结束的花括号前(}),添加如下方法:
private func updateButtonSelectionStates() {
}
这个辅助方法可以用来更新按钮的选择状态。
- 在updateButtonSelectionStates()方法中,添加如下for-in循环:
private func updateButtonSelectionStates() {
for (index, button) in ratingButtons.enumerated() {
// If the index of a button is less than the rating, that button should be selected.
button.isSelected = index < rating
}
}
这个代码遍历按钮数组,并基于评分的位置对每个按钮的选中状态进行设置。就像你较早前看到的,选中状态影响按钮的呈现。如果按钮的索引小于评分,则isSelected属性设置为true,并且按钮显示填充的星星图片。否则,isSelected属性设置为false,按钮显示空心星星图片。
- 添加一个属性观察器到rating属性,当rating值改变时都会调用updateButtonSelectionStates()方法。
var rating = 0 {
didSet {
updateButtonSelectionStates()
}
}
- 每当按钮添加到控件的时候,也需要更新按钮的选择状态。在setupButtons()方法中,在方法结束的花括号(})之前添加 updateButtonSelectionStates()方法。现在setupButtons()方法看上去是这样的:
private func setupButtons() {
// Clear any existing buttons
for button in ratingButtons {
removeArrangedSubview(button)
button.removeFromSuperview()
}
ratingButtons.removeAll()
// Load Button Images
let bundle = Bundle(for: type(of: self))
let filledStar = UIImage(named: "filledStar", in: bundle, compatibleWith: self.traitCollection)
let emptyStar = UIImage(named:"emptyStar", in: bundle, compatibleWith: self.traitCollection)
let highlightedStar = UIImage(named:"highlightedStar", in: bundle, compatibleWith: self.traitCollection)
for index in 0..<starCount {
// Create the button
let button = UIButton()
// Set the button images
button.setImage(emptyStar, for: .normal)
button.setImage(filledStar, for: .selected)
button.setImage(highlightedStar, for: .highlighted)
button.setImage(highlightedStar, for: [.highlighted, .selected])
// Add constraints
button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: starSize.height).isActive = true
button.widthAnchor.constraint(equalToConstant: starSize.width).isActive = true
// Setup the button action
button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
// Add the button to the stack
addArrangedSubview(button)
// Add the new button to the rating button array
ratingButtons.append(button)
}
updateButtonSelectionStates()
}
检查点:运行应用。你应该看到五颗星星,点击一颗就改变评分。例如,点击第三颗星星把评分改为3。第二次点击同一颗星星,控件将评分重置为零颗星。
添加辅助信息
借助iOS内置辅助功能,您可以为每个客户(包括有特殊需求的客户)提供出色的移动体验。 这些功能包括VoiceOver,开关控制,隐藏式字幕或音频描述视频的回放,指导访问,文本到语音等。
在大多数情况下,用户从这些功能中获得好处而无需任何额外的工作。然而,VoiceOver,通常需要一些额外的工作。VoiceOver是为盲人和低视力用户提供的革命性屏幕阅读功能。VoiceOver把用户界面读给用户听。尽管内置控件的默认描述提供了一个很好的开端,但是你可能需要优化用户界面的显示;特别是自定义视图和控件。
- 附加功能标签(Accessibility label)。一个简短的本地化单词或短语,简洁的描述这个控件或视图,但是不能辨认元素的类型。例如“添加”或“播放”。
- 附加功能值(Accessibility value)。一个元素的当前值,当该值不由标签表示时。例如一个滑块(slider)的标签可能是“速度”,但它的当前值可能是“50%”。
- 附加功能提示(Accessibility hint)。一个简短的本地化短语,用来描述一个元素的动作的结果。例如“添加一个标题”或者“打开购物单”。
在rating控件中,每个按钮的附加功能标签描述了每个按钮设置的值。例如,第一个按钮标签是“设置一个评分。”附加功能值包含了控件当前的评分。例如,如果你有一个4星的评分,这个值是“4星设置”。最后,你分配一个提示给当前选中的星星,“点击重置评分为零。”所有其他星星的提示值为nil,因为它们的效果是已经被它们的标签描述了。
添加附加功能标签、值、和提示
- 在 RatingControl.swift中,导航到setupButtons()方法,找到for-in声明。
for index in 0..<starCount {
- 在for-in循环内部,紧接着约束,添加如下代码:
// Set the accessibility label
button.accessibilityLabel = "Set \(index + 1) star rating"
这段代码使用按钮的所以计算标签字符串,然后把它分配到按钮的accessibilityLabel属性。setupButtons()方法看上去是这样的:
private func setupButtons() {
// Clear any existing buttons
for button in ratingButtons {
removeArrangedSubview(button)
button.removeFromSuperview()
}
ratingButtons.removeAll()
// Load Button Images
let bundle = Bundle(for: type(of: self))
let filledStar = UIImage(named: "filledStar", in: bundle, compatibleWith: self.traitCollection)
let emptyStar = UIImage(named:"emptyStar", in: bundle, compatibleWith: self.traitCollection)
let highlightedStar = UIImage(named:"highlightedStar", in: bundle, compatibleWith: self.traitCollection)
for index in 0..<starCount {
// Create the button
let button = UIButton()
// Set the button images
button.setImage(emptyStar, for: .normal)
button.setImage(filledStar, for: .selected)
button.setImage(highlightedStar, for: .highlighted)
button.setImage(highlightedStar, for: [.highlighted, .selected])
// Add constraints
button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: starSize.height).isActive = true
button.widthAnchor.constraint(equalToConstant: starSize.width).isActive = true
// Set the accessibility label
button.accessibilityLabel = "Set \(index + 1) star rating"
// Setup the button action
button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
// Add the button to the stack
addArrangedSubview(button)
// Add the new button to the rating button array
ratingButtons.append(button)
}
updateButtonSelectionStates()
}
- 导航到updateButtonSelectionStates()方法。在for-in循环内部,紧挨着设置按钮的isSelected属性下面,添加如下代码:
// Set the hint string for the currently selected star
let hintString: String?
if rating == index + 1 {
hintString = "Tap to reset the rating to zero."
} else {
hintString = nil
}
// Calculate the value string
let valueString: String
switch (rating) {
case 0:
valueString = "No rating set."
case 1:
valueString = "1 star set."
default:
valueString = "\(rating) stars set."
}
// Assign the hint string and value string
button.accessibilityHint = hintString
button.accessibilityValue = valueString
这里,你通过检查按钮是否是当前选中的按钮开始。如果它是,你就分配一个提示。如果不是,你就设置按钮的hintString属性为nil。
接下来,你基于控件的评分计算值。使用switch语句,如果评分是0或1,则分配一个自定义字符串。如果评分大于1,你就使用字符串插值来计算提示内容。最后,分配这些值给accessibilityHint和accessibilityValue属性。
当用户在VoiceOver可用的环境里运行应用,当用户点击其中一个按钮的时候,VoicePver就会阅读这个按钮的标签,跟在单词按钮后面。然后读附加功能值。最后它读附加功能提示(如果有)。这让用户知道控件当前的值,以及按下当前的按钮会有什么结果。
进一步探索
更多关于附加功能的信息,参见Accessibility on iOS.
还有,因为本课的目的,你只是分配了简单的字符串给附加功能属性;但是,一个产品级的应用应该使用本地化字符串。更多关于国际化和本地化的信息,参见Build Apps for the World。
连接Rating控件到View Controller
作为设置rating控件的最后一步,你需要把它的一个引用给ViewController。
连接rating控件到ViewController.swift
- 打开storyboard。
-
点击Xcode工具条上的Assistant 按钮来打开助理编辑器。
-
想要更大空间,就把project navigator和utility area折叠起来。
也可以把大纲视图折叠起来。
- 选择rating 控件。
ViewController.swift显示在右侧的编辑器。(如果不是这样,在编辑器选择器栏里选择 Automatic > ViewController.swift)。 -
把rating控件拖拽到photoImageView属性的下面。
-
在弹出的对话框中,Name字段键入ratingControl。
其他选项不变。你的对话框看上去是这样的:
- 点击连接。
ViewController类现在有一个引用指向storyboard中的rating控件。
清理项目
你已接近完成菜品场景的用户界面了,但在之前你需要做一些清理工作。现在这个FoodTracker应用实现了很多比之前课程更高级的行为和不同的用户界面,你应该移除一些不再需要的部分。你还需要把元素放到栈视图的中心,以平衡界面。
清理UI
-
返回到标准编辑器。
- 打开storyboard。
-
选择Set Default Label Text按钮,然后按下删除键删除它。
栈视图布置你的界面元素填充按钮留下来的控件。
-
如果必要,打开大纲视图,选择Stack View对象。
- 打开Attributes inspector
-
在Attributes inspector中,找到Alignment(对齐)字段并选择Center。
在栈视图中的元素都居中对齐:
现在,移除和你删掉的按钮对应的action方法。
清理代码
- 打开 ViewController.swift.
- 在ViewController.swift中,删除setDefaultLabelText(_:) action方法。
@IBAction func setDefaultLabelText(sender: UIButton) {
mealNameLabel.text = "Default Text"
}
这就是现在你需要删除的全部了。你将在下一课对label outlet(mealNameLabel)作出一些改变。
检查点:运行应用。所有事都应该和之前一样,只是没有那个删掉的按钮了,并且元素都水平居中了。按钮应该是并排的。点击任何一个按钮仍然调用ratingButtonTapped(_:),并且会恰当的改变按钮的图片。
重要
如果你运行出现构建问题,尝试按下Command-Shift-K组合键来清理你的项目。
小结
在本课中,你学习了如何构建一个自定义控件,它能显示在Interface Builder中。这个控件还会在Attributes inspector中显示可修改的属性。最后,你添加了附加功能信息,确保控件能很好的使用Voice Over。
下一课,你将设计和连接应用的数据模型。
注意
想看本课的完整代码,下载这个文件并在Xcode中打开。
下载文件