一、前言
Swift版本 4.0
Xcode版本 9.2
这周本来我是想要写其他知识的,但在构建 Demo 工程的时候, 我情不自禁的就使用了 Storyboard (下面简称 SB ),或者说是 Interface Builder (下面简称 IB),所以就想着写一篇相关文章。
这里不讨论使用这种方式的好坏,大家仁者见仁,智者见智,猫神的文章链接在后记里面,我的观点和他一致。
二、Storyboard基础
这部分针对完全没用过
SB的读者,极其基础,熟悉的直接跳过!
2.1 完成目标的概览
下面是我这一小节需要完成目标的样子,一个游戏的展示界面和添加游戏。

2.2 界面初识
先建立一个 Demo 项目,点击 Main.storyboard 出现如下界面:

首先需要了解 SB 中几个重要的区域,这里是按照我的理解取的名字,只是简单说明区域的作用,后面会详细使用这几个区域,如上图所示:
- 1、菜单导航区域,添加的控制器、控制器之间的跳转
Segue及控制器上面的控件和布局等等信息都在这里显示。 - 2、工作展示区域: 可以在这里给各个控制器添加控件和预览布局后的控件。
- 3、配置区域: 可以在这里将
SB和代码文件关联和查看关联后的信息,也可以直接在这里配置控件的属性等等。 - 4、布局区域: 上面面有很多机型选择,可以直接选择机型和方向,区域2会根据选择的机型和自动布局直接预览控件显示的效果和布局,中间的加减符号可以放大和缩小区域2中的内容,右上角的几个按钮可以进行自动布局操作。
- 5、控件区域,我们可以直接在这里选择控件,然后拖入区域2中。
细心的读者可能会发现,区域1中,控制器在一个 scene 的下面,在 SB 中,scene 就对应着一个控制器。区域2里面还有一个灰色箭头,它代表这个控制器是当前 SB 文件的入口,会在后面详细的讲解。
2.3 添加控件

直接从控件区域拖拽了一个 UIView 控件到控制器上,然后在 Attributes inspector区域 (点击配置区域中那个楔子形状的按钮)直接配置背景颜色为灰色。如果显示菜单栏中没有没有你想要的颜色,点击 other ,里面有多种方式配置颜色,如 RGB 和16进制颜色等等。
上图这个区域里还有一些其他的属性可以配置,例如 UILabel 控件字体和字体颜色等等属性等,就不深入去展开了。
读者肯定注意到,控件在拖拽中,控制器出现了辅助虚线,可以提醒你相对其他视图的位置信息,图中所示的其中一条就是父视图的中线。
2.4 布局控件

解释一下上图的自动布局操作:
- 1.选中控件, 点击
Align按钮,勾选Horizontalliy in Container和Vertically in Container,然后添加这两个布局,相对于父视图水平和垂直居中 - 2.然后点击
Add New Constraints按钮,添加Width和Height约束,都为200。
到这里布局就完成了,因为大小和位置都已经确定。观察上图,我只添加了 Align 约束时,界面出现了红线,这代表约束不完整。并且菜单栏上方出现带有箭头的小红点,可以点击进去查看还有哪些约束没有完成。这里还有其他的约束选项,读者可以自行尝试。
- 3、图中最后,我在
Size inspector区域 (点击配置区域中那个小直尺按钮) 双击宽度约束,进入了详情配置界面,这里可以对约束进行二次修改。点击菜单栏的约束,同样可以进入这个界面。
这里还有另一种方式进行自动布局,如图所示:

按住 Ctrl,然后选中灰色 View ,移动鼠标会出现一条线,拖到你想要相对其布局的控件,图中选择的是父视图,出现了一个菜单让你选择约束条件。同样的操作也能直接在菜单栏中进行。甚至当控制器上控件比较多不容易选中时,可以直接从控制器上拖到菜单栏上的控件上。
这一部分的操作是很简单的,不过需要自动布局的相关知识。
2.5 开始一个TableView界面
选中菜单栏的 View Controller Scene,然后点击键盘上的 delete 键,删除我们鼓捣的控制器。
从控件区域拖拽一个 UITabBarController 到工作展示区域:
在工作展示空白区域,双击鼠标左键和单点鼠标右键,可以放大,缩小显示内容。

如图,UITabBarController (它和 UINavigationController 都是容器控制器) 会自带两个子控制器,并且有两个箭头从 TabBarController 指向它们,这个箭头的术语叫做 Segue, 这里的是 Relationship Segue ,代表控制器之间的关系。
删掉 item1 的控制器,拖拽一个 UITableViewController 出来,然后让它成为 UINavigationController 的子控制器, 再让 UINavigationController 成为 UITabBarController 控制器的子控制器,操作如图所示:

当然你也可以直接拖拽一个 UINavigationController 出来,然后按住 Control 拖动选择 view controllers。不过我觉得点击 Editor 这种方式更加便捷。
让我们的关注点来到 UITableViewController ,看到界面上有一个 Prototype Cells ,可以理解为我们平时使用的那种 Cell , 与之对应的是 Static Cells, 从名字就可以看出来,这种是静态的,不能够循环使用,并且只能在UITableViewController 上使用。

红框中,和我们代码实现中官方提供的4种 Cell 一样,不过这里我们需要自定义,下面是完成后的样子。

可能对没有接触过 IB 的读者来说,这里还是比较麻烦,所以详细描述一下。
选中 Cell 在右上角 Size inspector 区域修改 Cell 的高度为120,这里的高度设置只是方便我们进行布局,并不是实际显示的高度。

拖动一个 UIImageView 控件到 Cell 里面,进行布局。
iOS8以后更新了让Cell自己自适应高度的新特性,所以这里我们不光要确定自己的位置和大小,还需要将自己的大小反馈给Cell让其自适应高度,后面详细使用。
相对于父视图:
距离右边20,距离上边10,宽高100,这就已经确定了位置和大小,不过为了让 Cell 知道我们的高度,还需要设置一个距离底部的距离。这样 Cell 就知道显示的时候需要的高度。结合我们目标的样子,底部距离的设置是有个小问题的,后面来纠正。


继续拖动一个 UILable 到 Cell 里。
相对于父视图: 距离左边15,上边10
相对于 UIImageView : 距离它的左边10


然后比较麻烦的地方来了。再拖动一个 UILable 到 Cell 里。
相对于父视图: 距离左边15,距离底部10。
相对于 Game Name Label:距离其底部10。
相对于 UIImageView : 距离它的左边10。
按照逻辑来说没问题呀,因为上下左右都给了约束,是什么原因呢?


我们点击红框中的小红点进行查看:

UILabel和UIButton等控件有一个特点,它会根据内容自适应自己的大小。
如图所示两个 Label 在反馈大小给 Cell 时,Cell 也同样会反馈自己的大小给两个 Label,这就会产生两个问题:
-
1.如果
Cell高度比内容反馈需要的高度大的时候,需要拉伸哪个部分的内容? -
2.如果
Cell高度比内容反馈需要的高度小的时候,需要压缩哪个部分内容?

这里就需要谈到 AutoLayout 中的 Content Hugging 和 Content Compression Resistance。
- 1.
Content Hugging Priority: 对应上面的第1中情况,这个属性的值越高,就越不容易被拉伸。 - 2.
Content Compression Resistance:对应上面的第2种情况,这个属性的值越高,就越不容易被压缩。
显然上面报错的原因是 Cell 的高度比两个 Label 的内容高度大了,属于第一种情况,我们让 Game Name Label 不拉伸, 增加它的 Content Hugging Priority (默认值为251)比另一个 Label大(增加到252)。

这个问题解决了,但新问题又出现了:

因为 Game Detail Label 被拉伸,导致了内容居中,这看上去怪怪的,以前看到有关于讨论让 Label 居上的问题。但这并不是这里的解决办法。还记得前面说过可以对约束进行二次编辑吗?选中 Game Detail Label 的 Bottom 约束,可以在菜单区域选择或者在小直尺图标区域里面找到它进行双击,就来到如下界面:

这里有个 Relation 选项,点看可以看到:

没错我们选择让这个约束大于或等于10:

看上去是完成了,回到最初添加 UIImageView 约束的时候,我说过有一个小问题,UIImageView 的约束强行的让 Cell 的高度为120了。当 Label 内容很多换行超过120的时候,就会出现上面的第2种情况, Cell 高度不够完整显示内容,这显然不是我们想要的结果。所以修改 UIImageView 的 Bottom约束也为距离底部大于等于10,到这里布局就结束了,最后别忘了设置 identifier :

为了让 SB 和代码关联起来,创建一个继承自 UITableController 的 GameVC.swift 文件、继承自 UITableViewCell 的 GameCell.swift 文件和数据模型 Game.swift 文件,然后依次选中 SB 中的文件关联:


继续将 SB 中的属性和代码关联起来:

也可以直接从控制器中选中控件并按住 Control 进行拖动连线,这里就不再举例了,这里不仅仅只能属性连线,例如 UIButton 可以直接连线一个点击响应方法等等。连线后可以在 Connections inspectors (圆圈包含一个箭头的按钮) 查看:

注意: 一个控件属性关联多次或者其他关联错误会引发运行奔溃,这是新手最容易犯的问题,如果名字写错了,需要先取消上次的关联,再重新关联。
Game.swift文件中:
struct Game {
let name: String
let detail: String
let pictureName: String
static func getData() -> [Game] {
return [
Game(name: "绝地求生",
detail: "神仙打架游戏",
pictureName: "game_one"),
Game(name: "英雄联盟",
detail: "《英雄联盟》(简称LOL)是由美国拳头游戏(Riot Games)开发、中国大陆地区腾讯游戏代理运营的英雄对战MOBA竞技网游。游戏里拥有数百个个性英雄,并拥有排位系统、符文系统等特色养成系统。《英雄联盟》还致力于推动全球电子竞技的发展,除了联动各赛区发展职业联赛、打造电竞体系之外,每年还会举办“季中冠军赛”“全球总决赛”“All Star全明星赛”三大世界级赛事,获得了亿万玩家的喜爱,形成了自己独有的电子竞技文化",
pictureName: "game_two")]
}
}
GameVC.swift 文件中:
class GameVC: UITableViewController {
var games: [Game] = []
override func viewDidLoad() {
super.viewDidLoad()
games = Game.getData()
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return games.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "GameCell", for: indexPath) as! GameCell
cell.game = games[indexPath.row]
return cell
}
}
GameCell.swift 文件中:
class GameCell: UITableViewCell {
var game: Game! {
didSet {
gameNameLabel.text = game.name
gameDetailLabel.text = game.detail
gameImageView.image = UIImage(named: game.pictureName)
}
}
@IBOutlet weak var gameImageView: UIImageView!
@IBOutlet weak var gameNameLabel: UILabel!
@IBOutlet weak var gameDetailLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
}
}
准备工作完毕,运行 Demo:

出错了,细心的读者肯定早就发现这个问题了,那就是前面说的那个入口箭头:

配置完毕后再运行:

2.6 界面之间跳转
先给控制器增加一个 title:

然后把 Game 控制器拖到 UITabBarController 第一个位置:

继续添加一个 UIBarButtonItem, 并设置风格为 Add:

添加一个 UITableViewController 并让其成为 UINavigationController的子控制器 ,按住Control 点击 Add Item ,拖动到新的控制器上,会出现弹窗选择跳转方式,这里选择 Present Modally ,对应着我们代码中 Present 那个方法。
这里的 Show 代表,如果是 UINavigationController 的子控制器就会执行 Push 方法,不是就会执行 Present 方法。

两个控制器之间多出了一个带箭头的连线,这可以理解为界面切换 Segue ,用来描述控制器之间的跳转,一个界面切换 Segue 只能单向跳转。
设置新控制器的 title 为 Game Add ,左边添加一个 Cancle Item, 右边添加一个 Done Item 。然后继续在 GameVC.Swift 的底部添加分类。
// MARK: - IBActions
extension GameVC {
@IBAction func cancelToGameVC(_ segue: UIStoryboardSegue) {
}
@IBAction func saveGameDetail(_ segue: UIStoryboardSegue) {
}
}
这是 unwind Segue,用来返回到目标控制器。直接上图:

选中 Game Add 中的 TableView.接下来我直接用 static cell 进行布局,
- 1.
content中选择Static Cells,Style中选择Grouped。 - 2.将出现的
Section中的Cell删除到只剩一个,设置Cell的Selection为None, 直接复制Section,这样就有两个含有一个Cell的Section。 - 3.给
Section设置标题(SB中的Header)为Game Name和Game Detail。 - 4.将第一个
Section高度设为50,第二个设为200。拖一个UITextField到第一个Cell,布局上0底0左10右10,拖一个UITextView到第二个Cell,布局上底左右都是10。 - 5.创建继承于
UITableViewController的GameAddVC.swift文件,然后将里面方法删除到只剩viewDidLoad, 并关联这个SB。 - 6.将步骤3中的
UITextField和UITextView连线到GameAddVC.swift文件中生成@IBOutlet属性。
@IBOutlet weak var gameNameTextField: UITextField!
@IBOutlet weak var gameDetailTextView: UITextView!
这里之所以能直接将
Cell中的属性直接连线到控制器中,是因为静态Cell不会重用。
配置完成如下:

这里省略了添加图片的步骤,直接设定一个默认图片。
-
选中刚才
Done Item添加的Segue,然后设置它的Identifier为AddGameDetail。
-
在
AddGameVC.swift中重写父类方法并添加代码:
var game: Game?
// 这个方法点击 `Done` 的时候会调用
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "GameAddDetail",
let name = gameNameTextField.text {
game = Game(name: name,
detail: gameDetailTextView.text!,
pictureName: "game_default")
}
}
- 在
GameVC.swift中,添加如下代码:
// 这个方法前面有,只是添加方法内的内容
@IBAction func saveGameDetail(_ segue: UIStoryboardSegue) {
guard let gameAddVC = segue.source as? GameAddVC,
let game = gameAddVC.game else {
return
}
games.append(game)
let indexPath = IndexPath(row: games.count - 1, section: 0)
tableView.insertRows(at: [indexPath], with: .automatic)
}
运行 Demo, 如下:

2.7 Storyboard Reference
在 SB中,如果多个人同时对一个地方(例如同一个控制器)进行修改,很容易造成 Git 冲突,这也是反对者们反对使用 SB 的一个理由。不过在苹果增加 Storyboard Reference 功能后,这种情况在开发中完全可以避免了。

Demo 中的控制器数量较少,但在实际项目中,如果多个人都都只操作这个 Main.storyboard ,那将是一件很恐怖的事情。之前没有 Storyboard Reference 功能时,多个 SB 之间的跳转只能使用代码的方式实现。现在来看看 Storyboard Reference 吧。
- 1.按住鼠标左键,然后圈中你想要脱离
Main.storyboard的控制器,就像桌面用鼠标多选文件那样。 - 2.点击 Editor>Refactor to Storyboard。
- 3.取名为
Game.storyboard,选择在哪个文件夹下面创建,然后确定。

完成后,我们可以看到 Main.storyboard 中的控制器变成了一个了 Storyboard Reference,其他控制器移到我们新创建的 Game.storyboard 中去了。多人开发时,各自操作自己的业务 SB ,就基本避免了 Git 冲突。
同样,我们也可以先直接创建 SB 文件,然后再从控件区拖拽一个 Storyboard Reference, 然后再让它和我们新创建的 SB 文件关联。

到这里这一下节就结束了,我自认为是写得比较啰嗦,不过这也是没有办法的选择,这部分知识更多的是界面上的操作,如果不写明白,不容易阐述清楚!
3 Storyboard高级用法
这里的所谓高级用法,是我一厢情愿认为的。
3.1 @IBInspectable
如果你之前没有见过这个东西,那么你肯定为某些属性没有暴露在 IB 的设置面板中而困扰过。@IBInspectable 的用处很简单,就是让我们自定义的属性也能直接在 IB 中选择,例如猫神的文章中的建议:
- 为一个显示文字的 view 设置本地化字符串:
extension UILabel {
@IBInspectable var localizedKey: String? {
set {
guard let newValue = newValue else { return }
text = NSLocalizedString(newValue, comment: "")
}
get { return text }
}
}
extension UIButton {
@IBInspectable var localizedKey: String? {
set {
guard let newValue = newValue else { return }
setTitle(NSLocalizedString(newValue, comment: ""), for: .normal)
}
get { return titleLabel?.text }
}
}
extension UITextField {
@IBInspectable var localizedKey: String? {
set {
guard let newValue = newValue else { return }
placeholder = NSLocalizedString(newValue, comment: "")
}
get { return placeholder }
}
}
IB 中可以直接设置:

- 为一个
image view设置圆角(这里可以直接扩展UIView)
@IBInspectable var cornerRadius: CGFloat {
get {
return layer.cornerRadius
}
set {
layer.cornerRadius = newValue
layer.masksToBounds = newValue > 0
}
}
IB 中可以直接设置:

仅仅使用 @IBInspectable 无法将属性的设置实时显示出来,还需要另一个关键字的帮助。
3.2 @IBDesignable
它能够将一些绘图代码和 UIView 及其子类的 @IBInspectable 属性实时渲染到 IB 中。
- 1.结合
@IBInspectable使用,创建UIView子类CustomView。拖拽一个UIView到另一个Item控制器上,布局上下居中,款高200,然后将它们关联。此时如图所示:

- 2.在
CustomView.swift中添加代码,注意@IBDesignable的位置:
@IBDesignable
class CustomView: UIView {
@IBInspectable var cornerRadius: CGFloat {
get {
return layer.cornerRadius
}
set {
layer.cornerRadius = newValue
layer.masksToBounds = newValue > 0
}
}
@IBInspectable var borderColor: UIColor = UIColor.white {
didSet {
layer.borderColor = borderColor.cgColor
}
}
@IBInspectable var borderWidth: Int = 1 {
didSet {
layer.borderWidth = CGFloat(borderWidth)
}
}
}
然后看效果:

- 3.再添加绘图代码,并将上面的
Corner Radius设为0:
override func draw(_ rect: CGRect) {
let path = UIBezierPath(ovalIn: rect)
UIColor.green.setFill()
path.fill()
}
结果如图:

3.3 自定义Segue跳转动画
我们都知道 Presnet 切换时系统默认的公开动画有四种,如果我们想自定义的话,需要创建一个 UIStoryboardSegue 的子类。

class CustomAnimationPresentationSegue: UIStoryboardSegue, , UIViewControllerTransitioningDelegate, UIViewControllerAnimatedTransitioning {
override func perform() {
destination.transitioningDelegate = self
super.perform()
}
func animationController(forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return self
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return self
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.5
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
let toView = transitionContext.view(forKey: UITransitionContextViewKey.to)!
if transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) == destination {
// Presenting.
UIView.performWithoutAnimation {
toView.alpha = 0
containerView.addSubview(toView)
}
let transitionContextDuration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: transitionContextDuration, animations: {
toView.alpha = 1
}, completion: { success in
transitionContext.completeTransition(success)
})
}
else {
// Dismissing.
let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from)!
UIView.performWithoutAnimation {
containerView.insertSubview(toView, belowSubview: fromView)
}
let transitionContextDuration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: transitionContextDuration, animations: {
fromView.alpha = 0
}, completion: { success in
transitionContext.completeTransition(success)
})
}
}
}
自定义了一个简单的渐隐动画,这里关于自定义的跳转动画的部分我不想仔细探讨(排在我想写内容的队列总)。然后我们在 IB 关联跳转到添加游戏的 Segue 和 Cancle&Done 的 unwind Segue为 CustomAnimationPresentationSegue。
演示效果:

3.4 使用R.swift三方框架
其实这不算是 IB 的高级使用,它能够扫描整个项目中的资源文件(比如图片名,View Controller 和 segue 的 identifier 等),并生成一种类型安全的获取方式。
let icon = UIImage(named: "settings-icon")
let viewController = UIStoryboard(name: "Main",
bundle: nil).instantiateViewController(withIdentifier: "myViewController") as! MyViewController
let icon = R.image.settingsIcon()
let viewController = R.storyboard.main.myViewController()
四、后记及Demo
关于 IB 的操作目前我知道的就这些,如果你有更好的使用技巧可以评论分享讨论一下。
最近我捡起了我的微博,因为很多 iOS 界的前辈都喜欢微博分享技术,我也关注了很多,收益匪浅。例如这个 OC 项目 ZHNCosmos Github地址,代码工整,逻辑清晰,我这个菜鸟准备好好学习一下。
另外附上我的微博,我每天都会转发一些大佬的技术动态,请大家随缘关注:
参考文章
WWDC2015视频自带中文字幕 What's New in Storyboards
