iOS控件程序员iOS开发杂货铺

iOS UIViewController 的布局问题

2017-09-26  本文已影响925人  程序员钙片吃多了

假如应用的 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 的影响了。

注意:下面的解析均以本文起始处代码设置的控制器层级结构为前提。

默认表现

为了方便查看 testVC.view,这里在 MyownTestController 的 viewDidLoad 方法中设置一下其背景色,如下代码:

class MyownTestController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = UIColor.yellow
    }
 }

运行程序,可以看到效果如下图左:

Screen Shot

上图右是使用 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 只有顶部延展到屏幕边缘,效果如下图:

Screen Shot

从上图左可以看出标签栏的背景已经没有黄色元素了,从上图右可以看出 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
    }
 }

运行效果如下图:

Screen Shot

从上图右可以看出,虽然 edgesForExtendedLayoutall,testVC.view 顶部并没有延展到屏幕边缘,这是由于我们将导航栏的 isTranslucent 设置为了 false。上图左可以看出,导航栏的透明度没有了,但是这里再次提醒一句,是否有透明度和 isTranslucent 并不是一个概念,后面会介绍到。

同理,如果将标签栏的 isTranslucent 属性设置为 false,那么 testVC.view 的底部也不会延展至屏幕边缘了。

修改 isTranslucent 的值

  1. 手动设置

上一个的例子就是采用了手动设置 isTranslucent 的值,我们可以直接设置导航栏/标签栏的 isTranslucent 属性。

  1. 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 属性值。运行结果如下图:

Screen Shot

打印的 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)
    }
 }

运行结果如下图:

Screen Shot

打印的 isTranslucent 属性值为 true。可以看出,为导航栏设置了有透明度的背景图(只要背景图的任意一个像素有透明度即可,即背景图存在至少一个像素的 alpha 值小于 1)后,isTranslucent 属性被置为 true,此时,按照上面讲述的 isTranslucent 对布局影响的结论,testVC.view 的顶部会延展至屏幕边缘,上图右也确实如此。

经过上面两个测试,我们明确了设置导航栏(标签栏可自行测试)背景图对 isTranslucent 属性的影响,并且也验证了 isTranslucent 对 testVC.view 的布局影响。

还有一点需要注意,如果手动设置了 isTranslucent 的值,那么设置导航栏的背景图,不会改变 isTranslucent 的值。

透明与 isTranslucent

上面多次提到导航栏/标签栏的是否透明与 isTranslucent 不是一个概念,但是 isTranslucent 在一定程度上影响了导航栏/标签栏是否透明。下面分情况进行分析:

  1. 设置不透明背景图,并且设置 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)
    }
 }

那么结果如下图左:

Screen Shot

上图右是将 isTranslucent 设置为 false 后的运行结果,通过对比导航栏我们可以发现,左图导航栏略微有点黄色背景。

本示例说明即使我们设置了不透明的背景图,只要 isTranslucent 为 true,那么系统渲染到屏幕时,会为导航栏添加一个透明度。

  1. 设置透明背景图,并且设置 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)
    }
 }

那么结果如下图左:

Screen Shot

上图中图是在上述代码中另外设置了: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 的 extendedLayoutIncludesOpaqueBarstrue,那么,不论导航栏和标签栏的 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)
    }
 }
Screen Shot

上述代码中,我们为导航栏设置了一个不透明的背景图,那么导航栏的 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
    }
    
}

运行程序的结果如下图:

Screen Shot

从上图可以看出,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,运行后如下:

Screen Shot

可以看出 tableView 的内容不再被导航栏遮挡,打印出 tableview 的 contentInst 为:top : 64.0 left : 0.0 bottom : 49.0 right : 0.0

所以系统为 tableview 自动调整了 contentInset 和初始的 contentOffset。还有两点需要注意:

感兴趣可以测一下上面两点对布局的影响。

如果不想让 ViewController 自动调整 ScrollView 的 contentInset,手动将 automaticallyAdjustsScrollViewInsets 设置为 false 就可以了。注意,苹果文档声明了如果一个 ViewController 有不止一个 UIScrollView,应当将 automaticallyAdjustsScrollViewInsets 设为 false,然后开发者自己进行布局的调整。

总结

本文主要讲解了 iOS7之后系统的 ViewController view 的布局问题。目前 iOS11 已经开始适配,在 iOS11 中对布局做了一些策略调整,所以本文更适用于 iOS7-iOS10 系统,iOS11系统的适配问题在之后也会整理。

上一篇 下一篇

猜你喜欢

热点阅读