iOS Storyboard入门及一些高级使用

16,882

一、前言

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 ContainerVertically in Container,然后添加这两个布局,相对于父视图水平和垂直居中
  • 2.然后点击 Add New Constraints按钮,添加 WidthHeight 约束,都为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 就知道显示的时候需要的高度。结合我们目标的样子,底部距离的设置是有个小问题的,后面来纠正。

继续拖动一个 UILableCell 里。

相对于父视图: 距离左边15,上边10

相对于 UIImageView : 距离它的左边10

然后比较麻烦的地方来了。再拖动一个 UILableCell 里。

相对于父视图: 距离左边15,距离底部10。

相对于 Game Name Label:距离其底部10。

相对于 UIImageView : 距离它的左边10。

按照逻辑来说没问题呀,因为上下左右都给了约束,是什么原因呢?

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

UILabelUIButton等控件有一个特点,它会根据内容自适应自己的大小。

如图所示两个 Label 在反馈大小给 Cell 时,Cell 也同样会反馈自己的大小给两个 Label,这就会产生两个问题:

  • 1.如果 Cell 高度比内容反馈需要的高度大的时候,需要拉伸哪个部分的内容?

  • 2.如果 Cell 高度比内容反馈需要的高度小的时候,需要压缩哪个部分内容?

这里就需要谈到 AutoLayout 中的 Content HuggingContent 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 LabelBottom 约束,可以在菜单区域选择或者在小直尺图标区域里面找到它进行双击,就来到如下界面:

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

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

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

为了让 SB 和代码关联起来,创建一个继承自 UITableControllerGameVC.swift 文件、继承自 UITableViewCellGameCell.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 只能单向跳转。

设置新控制器的 titleGame 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 CellsStyle 中选择 Grouped
  • 2.将出现的 Section 中的 Cell 删除到只剩一个,设置 CellSelectionNone, 直接复制 Section,这样就有两个含有一个 CellSection
  • 3.给 Section 设置标题( SB 中的 Header )为 Game NameGame Detail
  • 4.将第一个 Section 高度设为50,第二个设为200。拖一个 UITextField 到第一个 Cell ,布局上0底0左10右10,拖一个 UITextView 到第二个 Cell,布局上底左右都是10。
  • 5.创建继承于 UITableViewControllerGameAddVC.swift 文件,然后将里面方法删除到只剩 viewDidLoad , 并关联这个 SB
  • 6.将步骤3中的 UITextFieldUITextView 连线到 GameAddVC.swift文件中生成 @IBOutlet 属性。
@IBOutlet weak var gameNameTextField: UITextField!
@IBOutlet weak var gameDetailTextView: UITextView!

这里之所以能直接将 Cell 中的属性直接连线到控制器中,是因为静态 Cell 不会重用。

配置完成如下:

这里省略了添加图片的步骤,直接设定一个默认图片。

  • 选中刚才 Done Item 添加的 Segue,然后设置它的 IdentifierAddGameDetail

  • 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 关联跳转到添加游戏的 SegueCancle&Doneunwind SegueCustomAnimationPresentationSegue。 演示效果:

3.4 使用R.swift三方框架

R.swift Github地址

其实这不算是 IB 的高级使用,它能够扫描整个项目中的资源文件(比如图片名,View Controllersegueidentifier 等),并生成一种类型安全的获取方式。

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

Demo Github地址

关于 IB 的操作目前我知道的就这些,如果你有更好的使用技巧可以评论分享讨论一下。

最近我捡起了我的微博,因为很多 iOS 界的前辈都喜欢微博分享技术,我也关注了很多,收益匪浅。例如这个 OC 项目 ZHNCosmos Github地址,代码工整,逻辑清晰,我这个菜鸟准备好好学习一下。

另外附上我的微博,我每天都会转发一些大佬的技术动态,请大家随缘关注:

我的微博地址

参考文章

猫神博客 再看关于 Storyboard 的一些争论

WWDC2015视频自带中文字幕 What's New in Storyboards

英文 Storyboards Tutorial for iOS: Part 1

英文 Storyboards Tutorial for iOS: Part 2