iOS UIViewController 的布局问题
假如应用的 Controller 层级有以下场景:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// 新建一个测试控制器
let testVC = MyownTestController()
// 新建一个导航栏控制器,以 testVC 为根控制器
let navigationVC = UINavigationController(rootViewController: testVC)
// 新建标签栏控制器
let tabbarVC = UITabbarController()
// 为标签栏控制器设置各个标签栏的控制器实例
tabbarVC.setViewControllers([navigationVC], animated: false)
// 设置 window 的根控制器为标签栏控制器
window?.rootViewController = tabbarVC
window?.makeKeyAndVisiable()
return true
}
上段代码对 iOS 开发来说应该很容易理解。
由于 testVC 位于 UITabbarController 和 UINavigationController 中,因此 testVC 出现在屏幕时,导航栏和标签栏也会同时出现,那么当我们为 testVC.view 的 subviews 布局时,就需要考虑导航栏(navigationBar)和标签栏(tabbar)对 testVC.view 的影响了。
-
在 iOS7 之前,导航栏和标签栏默认不透明。导航栏和标签栏下面的所有视图都是不可见的,如果 testVC.view 起始位置位于屏幕左上角,那么我们为 testVC.view 的 subviews 布局时,需要注意不能被导航栏遮挡,这样很麻烦。所以 testVC.view 起始位置默认被设置为导航栏的左下角,其高度为
屏幕高度 - 导航栏高度(64pt) - 标签栏的高度(49pt)
,subviews 就不可能被导航栏和标签栏遮挡了。如果将导航栏设置为透明,显然,此时我们更希望 testVC.view 起始位置为屏幕的左上角,并且其高度为屏幕大小,那么我们需要设置 testVC 的
wantsFullScreenLayout
属性为 true,该属性表示是否使用全屏布局,如果使用全屏布局,那么 testVC.view 的位置和大小将不再考虑导航栏和标签栏。 -
在 iOS7 之后,苹果为了提供更好的视觉体验,导航栏和标签栏默认加入了半透明效果,当然也可以将其设置为不透明。当导航栏和标签栏有透明度的时候,我们希望 test.view 的起始位置为屏幕左上角、大小为屏幕大小。
当我们将其设置为不透明时,我们希望 testVC.view 的起始位置为导航栏左下角,高度为
屏幕高度 - 导航栏高度(64pt) - 标签栏的高度(49pt)
,原因与上面相同。苹果为了方便开发者灵活控制 testVC.view 的起始位置和大小,在 iOS7及之后系统中为 UIViewController 提供了两个设置选项:edgesForExtendedLayout
和extendedLayoutIncludesOpaqueBars
,这两个选项与导航栏/标签栏的一些属性一起产生效果,下面就来看一下这两个属性对 testVC.view 的布局影响。
注意:下面的解析均以本文起始处代码设置的控制器层级结构为前提。
默认表现
为了方便查看 testVC.view,这里在 MyownTestController 的 viewDidLoad 方法中设置一下其背景色,如下代码:
class MyownTestController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.yellow
}
}
运行程序,可以看到效果如下图左:
![](https://img.haomeiwen.com/i2249791/c877fc3d31126dab.png)
上图右是使用 Xcode 的 Debug View Hierarchy 查看到的视图层级,黄色的就是 testVC.view,可以看出其大小为屏幕大小,起始位置为屏幕的左上角,所以此时 testVC 的 view 是采用了全屏布局。
本节给了一个简单的示例。在该示例中,上面提到过的 edgesForExtendedLayout、extendedLayoutIncludesOpaqueBars以及导航栏和标签栏的一些属性均使用了默认值,我们除了设置了 MyownTestController 的 view 背景色外,没有其他任何设置。
下面会分别说明这几个关键属性对位于 MyownTestController 的 view 布局影响。
edgesForExtendedLayout
edgesForExtendedLayout 是 UIViewController 的一个属性,其定义为:
var edgesForExtendedLayout: UIRectEdge
UIRectEdge 的定义为:
struct UIRectEdge : OptionSetType {
init(rawValue rawValue: [UInt]
static var None: [UIRectEdge] { get }
static var Top: [UIRectEdge] { get }
static var Left: [UIRectEdge] { get }
static var Bottom: [UIRectEdge] { get }
static var Right: [UIRectEdge] { get }
static var All: [UIRectEdge] { get }
}
该属性表示你希望当前 ViewController 的 view 的哪个方向延展到屏幕边缘,默认值是 All
。所以上一节的例子中,testVC.view 忽视了导航栏和标签栏,将视图顶部和底部都延展到了屏幕边缘。
注意:edgesForExtendedLayout 属性只适用于嵌入到容器控制器中的控制器,例如 UINavigationController、UITabBarController 都是容器控制器,在上面的例子中,testVC 同时嵌入到了 UINavigationController 和 UITabBarController 中,所以我们可以用来测试 edgesForExtendedLayout 对布局的影响。
如何我们设置为 Top:
class MyownTestController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.yellow
edgesForExtendedLayout = .top
}
}
将 edgesForExtendedLayout 设置为 top 表示我们希望 testVC.view 只有顶部延展到屏幕边缘,效果如下图:
![](https://img.haomeiwen.com/i2249791/b2d887e41e85deb2.png)
从上图左可以看出标签栏的背景已经没有黄色元素了,从上图右可以看出 testVC.view 的底部已经不再延展到屏幕边缘了,而是在标签栏的上面,此时, testVC.view 的高度 = 屏幕高度 - 标签栏高度(49)
。
我们还可以将 edgesForExtendedLayout 设置为 none 表示我们希望 testVC.view 在任何方向上都不要延展到屏幕边缘,结果应该是显然可见的,这里不再贴结果图,可以自己测试一下。
isTranslucent
上节的例子中,我们没有对导航栏和标签栏进行任何设置,在这个前提下,我们看到了 edgesForExtendedLayout 对布局的影响。在本节中会说明导航栏和标签栏的 isTranslucent 属性对布局的影响。
上节介绍了设置 edgesForExtendedLayout 的值可以影响 testVC.view 在哪个方向延展到屏幕边缘,但是这个特性是以导航栏和标签栏的 isTranslucent 为
true为前提的。iOS7之后系统的导航栏和标签栏的 isTranslucent
默认为 true
,所以上节的 edgesForExtendedLayout 才能显示出作用,如果我们将导航栏/标签栏的 isTranslucent
设置为 false
呢?
这里要注意,本文开头为了更方便理解苹果设计布局的本意,以导航栏/标签栏是否有透明度的角度分析了布局问题,但是 isTranslucent 其实跟导航栏/标签栏是否有透明度不是一个概念,而对 edgesForExtendedLayout 有影响的是 isTranslucent,请不要混淆两者。
下面做个测试,edgesForExtendedLayout 使用默认值(all),然后我们手动将导航栏的 isTranslucent
设置为 false
:
class MyownTestController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.yellow
navigationController?.navigationBar.isTranslucent = false
}
}
运行效果如下图:
![](https://img.haomeiwen.com/i2249791/2607f1ef93548143.png)
从上图右可以看出,虽然 edgesForExtendedLayout
为 all
,testVC.view 顶部并没有延展到屏幕边缘,这是由于我们将导航栏的 isTranslucent
设置为了 false
。上图左可以看出,导航栏的透明度没有了,但是这里再次提醒一句,是否有透明度和 isTranslucent 并不是一个概念,后面会介绍到。
同理,如果将标签栏的 isTranslucent 属性设置为 false,那么 testVC.view 的底部也不会延展至屏幕边缘了。
修改 isTranslucent 的值
- 手动设置
上一个的例子就是采用了手动设置 isTranslucent 的值,我们可以直接设置导航栏/标签栏的 isTranslucent 属性。
- setBackgroundImage
导航栏和标签栏提供 setBackgroundImage
来设置背景图,而该方法也会影响 isTranslucent 的值。
我们首先设置一个没有透明度的背景图:
class MyownTestController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.yellow
// 注:UIImage.imageWithColor 是一个通过颜色生成图片的方法,具体实现这里不再贴出,google搜一下就能得到答案。
navigationController?.navigationBar.setBackgroundImage(UIImage.imageWithColor(color: UIColor(red: 1, green: 0, blue: 0, alpha: 1)), for: .default)
print(navigationController?.navigationBar.isTranslucent)
}
}
上面为导航栏设置了一个不透明的图片,并打印了导航栏的 isTranslucent 属性值。运行结果如下图:
![](https://img.haomeiwen.com/i2249791/8e643e548006bf0d.png)
打印的 isTranslucent 属性值为 false。可以看出,为导航栏设置了不透明的背景图(这里要求背景图的任何一个像素都不能有透明度,即每个像素的 alpha 都必须为 1.0)后,isTranslucent 属性被置为 false,此时,按照上面讲述的 isTranslucent 对布局影响的结论,testVC.view 的顶部不会延展至屏幕边缘,上图右也确实如此。
下面我们再设置一个有透明度的背景图:
class MyownTestController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.yellow
navigationController?.navigationBar.setBackgroundImage(UIImage.imageWithColor(color: UIColor(red: 1, green: 0, blue: 0, alpha: 0.6)), for: .default)
print(navigationController?.navigationBar.isTranslucent)
}
}
运行结果如下图:
![](https://img.haomeiwen.com/i2249791/9308b5970c5e567d.png)
打印的 isTranslucent 属性值为 true。可以看出,为导航栏设置了有透明度的背景图(只要背景图的任意一个像素有透明度即可,即背景图存在至少一个像素的 alpha 值小于 1)后,isTranslucent 属性被置为 true,此时,按照上面讲述的 isTranslucent 对布局影响的结论,testVC.view 的顶部会延展至屏幕边缘,上图右也确实如此。
经过上面两个测试,我们明确了设置导航栏(标签栏可自行测试)背景图对 isTranslucent 属性的影响,并且也验证了 isTranslucent 对 testVC.view 的布局影响。
还有一点需要注意,如果手动设置了 isTranslucent 的值,那么设置导航栏的背景图,不会改变 isTranslucent 的值。
透明与 isTranslucent
上面多次提到导航栏/标签栏的是否透明与 isTranslucent 不是一个概念,但是 isTranslucent 在一定程度上影响了导航栏/标签栏是否透明。下面分情况进行分析:
- 设置不透明背景图,并且设置 isTranslucent 为 true
根据上文的分析,为导航栏/标签栏设置不透明的背景图,isTranslucent 会被置为 false。现在我们同时手动设置 isTranslucent 为 true:
class MyownTestController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.yellow
navigationController?.navigationBar.isTranslucent = true
navigationController?.navigationBar.setBackgroundImage(UIImage.imageWithColor(color: UIColor(red: 1, green: 1, blue: 1, alpha: 1)), for: .default)
print(navigationController?.navigationBar.isTranslucent)
}
}
那么结果如下图左:
![](https://img.haomeiwen.com/i2249791/f58f88891ffad168.png)
上图右是将 isTranslucent 设置为 false 后的运行结果,通过对比导航栏我们可以发现,左图导航栏略微有点黄色背景。
本示例说明即使我们设置了不透明的背景图,只要 isTranslucent 为 true,那么系统渲染到屏幕时,会为导航栏添加一个透明度。
- 设置透明背景图,并且设置 isTranslucent 为 false
根据上文的分析,为导航栏/标签栏设置透明的背景图,isTranslucent 会被置为 true。现在我们同时手动设置 isTranslucent 为 false:
class MyownTestController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.yellow
navigationController?.navigationBar.isTranslucent = false
navigationController?.navigationBar.setBackgroundImage(UIImage.imageWithColor(color: UIColor(red: 1, green: 1, blue: 1, alpha: 0.6)), for: .default)
print(navigationController?.navigationBar.isTranslucent)
}
}
那么结果如下图左:
![](https://img.haomeiwen.com/i2249791/a75f07b4f6b24f71.png)
上图中图是在上述代码中另外设置了:navigationController?.navigationBar.barStyle = .black
。可见,系统为导航栏添加了一个黑色的不透明背景色,但由于设置的图片是有透明度的,所以看上去是有透明度的。
上图右图在上述代码中设置了:navigationController?.navigationBar.barTintColor = UIColor.red
。可见,系统为导航栏添加了一个红色的不透明背景色,但由于设置的图片是有透明度的,所以看上去是有透明度的。这里如果同时设置 barStyle 和 barTintColor,以 barTintColor 为准。
通过上面三张图也看一看出 testVC.view 的顶部没有延展到屏幕边缘,很显然,这是由于这三张图中的 isTranslucent 均为 false 导致的,而上面几张图的导航栏是有透明度的,所以可以得出结论,是否使用全屏布局与导航栏/标签栏是否有透明度无关,而是由 isTranslucent 属性决定的。
这一个示例说明了即使 isTranslucent 为 false,只要设置了一个有透明度的背景图,那么导航栏还是有透明效果(当然,如果背景是白色,则看不出透明效果,如上图左所示)。
本文主要讨论的是布局问题,所以这里对其他设置选项不再进行测试,有兴趣可以自己试一下。比如 isTranslucent 为 false 且设置了非透明背景图,两者语义上是一致的,得到的肯定是一个非透明的导航栏。
extendedLayoutIncludesOpaqueBars
从上文得知,如果导航栏/标签栏的 isTranslucent 属性为 false,那么 testVC.view 将不再使用全屏布局。不过,extendedLayoutIncludesOpaqueBars 属性可以改变结果。
如果我们设置 testVC 的 extendedLayoutIncludesOpaqueBars
为 true
,那么,不论导航栏和标签栏的 isTranslucent 是否为 true,testVC.view 将会按照 edgesForExtendedLayout
的设置进行全屏布局。测试的代码如下:
class MyownTestController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.yellow
extendedLayoutIncludesOpaqueBars = true
navigationController?.navigationBar.setBackgroundImage(UIImage.imageWithColor(color: UIColor(red: 1, green: 0, blue: 0, alpha: 1)), for: .default)
print(navigationController?.navigationBar.isTranslucent)
}
}
![](https://img.haomeiwen.com/i2249791/413b15956d24a287.png)
上述代码中,我们为导航栏设置了一个不透明的背景图,那么导航栏的 isTranslucent 属性被置为 false,根据上文分析,testVC.view 的顶部将以导航栏底部作为起点。但这里我们又将 extendedLayoutIncludesOpaqueBars
设置为了 true
,可以看到上面结果图中,testVC.view 的顶部是以屏幕顶部作为起点的,由此可知,extendedLayoutIncludesOpaqueBars 属性可以忽略导航栏和标签栏的 isTranslucent 信息。
automaticallyAdjustsScrollViewInsets
该属性表示是否允许 ViewController 在有导航栏、标签栏或工具栏时,自动调整 ScrollView 的 contentInset 和 scrollIndicatorInsets,以便 ScrollView 的内容部分不会被导航栏、标签栏或工具栏遮挡。当然,这里的 ScrollView 需要是 ViewController.view 的 subview。该属性默认值为 true。
下面,我们在 MyownTestController 内添加一个 tableview,主要代码如下:
class MyownTestController: UIViewController {
var tableView: UITableView = UITableView()
override func viewDidLoad() {
super.viewDidLoad()
loadSubviews()
view.backgroundColor = UIColor.yellow
// 为了观察,我们设置了一个有透明度的背景图
navigationController?.navigationBar.setBackgroundImage(UIImage.imageWithColor(color: UIColor(red: 1, green: 0, blue: 0, alpha: 0.6)), for: .default)
}
func loadSubviews() {
tableView.delegate = self
tableView.dataSource = self
tableView.backgroundColor = UIColor.blue
self.view.addSubview(tableView)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
tableView.frame = view.bounds
}
}
运行程序的结果如下图:
![](https://img.haomeiwen.com/i2249791/71a284836041f9cd.png)
从上图可以看出,tableView的一部分内容被导航栏和标签栏遮挡住了,而且如果打印出 tableView 的 contentInset,会发现,contentInset 并没有被调整。
这与上面提到的 automaticallyAdjustsScrollViewInsets 为 true 时,viewcontroller 自动调整子view中的 scrollView 的 contentInset 不符合。这是一个奇怪的问题,具体原因没有找到,但是测试到该问题与控制器层级的设置有关,在本文起始处我们将 window 的 rootViewController 设置为了 tabBarController,如果我们将其设置为 navigationBarController,automaticallyAdjustsScrollViewInsets 就生效了。(如果有人知道具体原因,求解释)
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// 新建一个测试控制器
let testVC = MyownTestController()
// 新建一个导航栏控制器,以 testVC 为根控制器
let navigationVC = UINavigationController(rootViewController: testVC)
// 新建标签栏控制器
let tabbarVC = UITabbarController()
// 为标签栏控制器设置各个标签栏的控制器实例
tabbarVC.setViewControllers([navigationVC], animated: false)
// 设置一个导航栏控制器作为 window 的根控制器
let rootNaviVC = UINavigationController(rootViewController: tabbarVC)
// 设置 window 的根控制器为导航栏控制器
window?.rootViewController = rootNaviVC
window?.makeKeyAndVisiable()
return true
}
不需要修改 MyownTestController,运行后如下:
![](https://img.haomeiwen.com/i2249791/c0c7a677a7e00d95.png)
可以看出 tableView 的内容不再被导航栏遮挡,打印出 tableview 的 contentInst 为:top : 64.0 left : 0.0 bottom : 49.0 right : 0.0
所以系统为 tableview 自动调整了 contentInset 和初始的 contentOffset。还有两点需要注意:
- UIScrollView或其子类需要是 ViewController.view 的第一个子view,automaticallyAdjustsScrollViewInsets 才能生效。
- 设置 UIScrollView 的 frame 时考虑 automaticallyAdjustsScrollViewInsets 是否会生效,如果 frame.origin.y 设置为 64,经过调整后,你的 UIScrollView 的内容会向下偏 64 的(即使导航栏并不会遮挡住 UIScrollView),所以这里并不是特别智能。该属性已在 iOS11 弃用。
感兴趣可以测一下上面两点对布局的影响。
如果不想让 ViewController 自动调整 ScrollView 的 contentInset,手动将 automaticallyAdjustsScrollViewInsets 设置为 false 就可以了。注意,苹果文档声明了如果一个 ViewController 有不止一个 UIScrollView,应当将 automaticallyAdjustsScrollViewInsets 设为 false,然后开发者自己进行布局的调整。
总结
本文主要讲解了 iOS7之后系统的 ViewController view 的布局问题。目前 iOS11 已经开始适配,在 iOS11 中对布局做了一些策略调整,所以本文更适用于 iOS7-iOS10 系统,iOS11系统的适配问题在之后也会整理。