探究UIScrollView及其子類佈局和適配的影響因素

前言

很久之前寫過兩篇文章, 都是關於適配佈局的, 分別是iOS6與iOS7屏幕適配 edgesForExtendedLayout影響屏幕適配的因素及tableview的ContentSize不正確的問題。當然也歡迎大家先看下這兩篇文章預熱一下, 因爲這篇文章其實是對上面這兩篇的不足補充和勘正。

今天重新總結一下關於UIScrollView及其子類的佈局的問題及影響因素, 只是本文是以TableView爲例的, 請大家注意。

UIScrollView佈局的影響因素

博主一共發現了9個UIScrollView佈局的影響因素。 我把它們歸爲兩個類型, 一類通過影響當前ViewController的View的frame從而影響佈局的; 另一類是通過影響scrollView的contentInset從而影響佈局的。接下來的每一個因素都會對ScrollView的佈局產生影響。
第一類: navigationBar.translucent, vc.edgesForExtendedLayout, vc.extendedLayoutIncludesOpaqueBars
第二類: scrollView.contentInset, vc.automaticallyAdjustsScrollViewInsets, navigationBar/statusBar的隱藏, NavigationBar/TabBar的有無, TabBar隱藏的方式, 第一個addSubview的是不是當前的scrollView
iOS11之後, API對於UIScrollView和其子類又做了一些調整, 所以第二類影響因素又分爲iOS10及之前版本的影響因素和iOS11及之後版本的影響因素。
而且從iOS11開始, 除了之前的影響因素還多了兩個別的因素:
contentInsetAdjustmentBehavioradditionalSafeAreaInsets

一個默認條件下的例子

這裏我用
UITabBarViewController->UINavigationController->UIViewController->UITableView的結構來進行舉例。

- (void)viewDidLoad {
    [super viewDidLoad];
    //    self.navigationController.navigationBar.translucent = NO;
    //    self.extendedLayoutIncludesOpaqueBars = YES;
    //    self.edgesForExtendedLayout = UIRectEdgeNone;
    //    self.navigationController.navigationBar.hidden = YES;
    //    self.automaticallyAdjustsScrollViewInsets = NO;

    //    [self createAnyView];
    [self createTableView];

    if (@available(iOS 11.0, *)) {
    //    _tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
    //        self.additionalSafeAreaInsets = UIEdgeInsetsMake(80.0f, 0.0f, 30.0f, 0.0f);
    }
}
- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    [self printRelatedParameters];
}
- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    [self printRelatedParameters];
}
- (void)createTableView {
    self.tableView = [[UITableView alloc]initWithFrame:CGRectMake(0, 0, kWidth, kHeight) style:UITableViewStylePlain];
    _tableView.backgroundColor = [UIColor lightGrayColor];
    _tableView.delegate = self;
    _tableView.dataSource = self;
    [self.view addSubview:_tableView];
    [_tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"cell"];

    UILabel *header = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, kWidth, 64)];
    header.text = @"This is header";
    header.textAlignment = NSTextAlignmentCenter;
    header.backgroundColor = [UIColor cyanColor];
    _tableView.tableHeaderView = header;

    UILabel *footer = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, kWidth, 49)];
    footer.text = @"This is footer";
    footer.textAlignment = NSTextAlignmentCenter;
    footer.backgroundColor = [UIColor purpleColor];
    _tableView.tableFooterView = footer;
}

#pragma mark - UITableViewDelegate/UITableViewDataSource

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 30;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];
    cell.textLabel.text = [NSString stringWithFormat:@"The Num of %ld", indexPath.row];
    return cell;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return 40;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    [self printRelatedParameters];
}


- (void)printRelatedParameters {
    NSLog(@"ContentInset---%f_%f_%f_%f", _tableView.contentInset.top, _tableView.contentInset.left, _tableView.contentInset.bottom, _tableView.contentInset.right);
    NSLog(@"ContentOffset---%f_%f", _tableView.contentOffset.x, _tableView.contentOffset.y);
    NSLog(@"Self.View.Frame---%f_%f_%f_%f", self.view.frame.origin.x, self.view.frame.origin.y, self.view.frame.size.width, self.view.frame.size.height);
    if (@available(iOS 11.0, *)) {
        NSLog(@"safeAreaInsets---%f_%f_%f_%f", _tableView.safeAreaInsets.top, _tableView.safeAreaInsets.left, _tableView.safeAreaInsets.bottom, _tableView.safeAreaInsets.right);
        NSLog(@"adjustedContentInset---%f_%f_%f_%f", _tableView.adjustedContentInset.top, _tableView.adjustedContentInset.left, _tableView.adjustedContentInset.bottom, _tableView.adjustedContentInset.right);
        NSLog(@"Self.additionalSafeAreaInsets---%f_%f_%f_%f", self.additionalSafeAreaInsets.top, self.additionalSafeAreaInsets.left, self.additionalSafeAreaInsets.bottom, self.additionalSafeAreaInsets.right);
    }
    NSLog(@"\n\n");
}

效果是這樣的:

第一類影響因素

@property(nonatomic,assign,getter=isTranslucent) BOOL translucent NS_AVAILABLE_IOS(3_0) UI_APPEARANCE_SELECTOR; // Default is NO on iOS 6 and earlier. Always YES if barStyle is set to UIBarStyleBlackTranslucent

導航欄透明狀態影響佈局, iOS6及之前默認爲不透明, 之後就默認爲透明的了. 這也是爲什麼 iOS6和 iOS7在添加視圖時, frame 不一致需要適配的區別之處.

這個屬性影響的是當前VC.View.frame, 在默認的佈局下, translucent爲YES, 即導航欄爲半透明, 此時self.View.Frame的值爲0.000000_0.000000_375.000000_667.000000.

self.navigationController.navigationBar.translucent = NO;

當我們把此屬性改爲NO後, self.View.Frame的值爲0.000000_64.000000_375.000000_603.000000.

默認屬性下, 向上滑動tableView, 淺藍色的header是可以透過NavigationBar的, 修改translucent屬性爲NO後, 正是因爲VC的view佈局不在navigationBar底下了, 所以這是看不到淺藍色的header的。

edgesForExtendedLayout

@property(nonatomic,assign) BOOL extendedLayoutIncludesOpaqueBars NS_AVAILABLE_IOS(7_0); // Defaults to NO, but bars are translucent by default on 7_0.

This property is applied only to view controllers that are embedded in a container such as UINavigationController. The window’s root view controller does not react to this property. The default value of this property is UIRectEdgeAll.

這個枚舉如下:

typedef NS_OPTIONS(NSUInteger, UIRectEdge) {
    UIRectEdgeNone   = 0,
    UIRectEdgeTop    = 1 << 0,
    UIRectEdgeLeft   = 1 << 1,
    UIRectEdgeBottom = 1 << 2,
    UIRectEdgeRight  = 1 << 3,
    UIRectEdgeAll    = UIRectEdgeTop | UIRectEdgeLeft | UIRectEdgeBottom | UIRectEdgeRight
} NS_ENUM_AVAILABLE_IOS(7_0);

我這裏的控制器是既有TabBar又有NavigationBar的, 所以, 在默認值UIRectEdgeAll下, 當前控制器就做了上面和下面的延展布局。Self.View.Frame的值爲0.000000_0.000000_375.000000_667.000000。

self.edgesForExtendedLayout = UIRectEdgeNone;

當我將此屬性改變爲UIRectEdgeNone時, 上面和下面的延展全部會禁止。Self.View.Frame的值爲0.000000_64.000000_375.000000_554.000000, 這說明view從導航欄開始佈局, 而且高度爲 667-64-49。

這裏footer顯示不全的原因就是VC的View的高度比tableView的高度小。

extendedLayoutIncludesOpaqueBars

@property(nonatomic,assign) BOOL extendedLayoutIncludesOpaqueBars NS_AVAILABLE_IOS(7_0); // Defaults to NO, but bars are translucent by default on 7_0.

A Boolean value indicating whether or not the extended layout includes opaque bars.
The default value of this property is NO.

這個屬性是指是否在不透明導航欄情況下延展布局。

首先要爲默認條件添加一行代碼:

self.navigationController.navigationBar.translucent = NO;

在導航欄不透明, extendedLayoutIncludesOpaqueBars又爲默認值NO的情況下, 當然這個效果就是我們所說的第一條中的圖片。self.View.Frame的值爲0.000000_64.000000_375.000000_603.000000.
當我們將此屬性改爲YES:

self.extendedLayoutIncludesOpaqueBars = YES;

此時self.View.Frame的值爲0.000000_0.000000_375.000000_667.000000.

第二類影響因素(iOS10及之前版本)

scrollView.contentInset

@property(nonatomic) UIEdgeInsets contentInset; // default UIEdgeInsetsZero. add additional scroll area around content

The distance that the content view is inset from the enclosing scroll view.
Use this property to add to the scrolling area around the content. The unit of size is points. The default value is UIEdgeInsetsZero.

關於ScrollView的這個屬性, 初始值爲0, 所有對contentInset值的自動調整都是在viewWillAppearviewDidAppear 之間進行調整的。默認條件下, viewWillAppear中打印ContentInset—0.000000_0.000000_0.000000_0.000000, viewDidAppear中打印ContentInset—64.000000_0.000000_49.000000_0.000000。

!!!特別注意!!!
正常情況下想要設置contentInset的值需要在viewDidLoad中進行. 需要注意的是, 設置contentInset值並不是覆蓋了原值, 而是與原值進行了各項相加的處理。
比如, 我在創建tableView的時候, 添加一行代碼:

_tableView.contentInset = UIEdgeInsetsMake(-64, 0, 0, 0);

默認值top應該是64的, 設置新值後並沒有覆蓋, 而應該是-64+64=0;
這時, 打印的結果爲: viewWillAppear中打印ContentInset—-64.000000_0.000000_0.000000_0.000000, viewDidAppear中打印ContentInset–ContentInset—0.000000_0.000000_49.000000_0.000000。

automaticallyAdjustsScrollViewInsets

@property(nonatomic,assign) BOOL automaticallyAdjustsScrollViewInsets API_DEPRECATED(“Use UIScrollView’s contentInsetAdjustmentBehavior instead”, ios(7.0,11.0),tvos(7.0,11.0)); // Defaults to YES

A Boolean value that indicates whether the view controller should automatically adjust its scroll view insets.
The default value of this property is YES, which lets container view controllers know that they should adjust the scroll view insets of this view controller’s view to account for screen areas consumed by a status bar, search bar, navigation bar, toolbar, or tab bar. Set this property to NO if your view controller implementation manages its own scroll view inset adjustments.

這個屬性爲當前控制器是否要自動調整contentInset, 默認爲YES。
而且這個屬性在iOS7.0開始啓用, iOS11開始廢棄, 被UIScrollViewcontentInsetAdjustmentBehavior屬性所替代(它的具體介紹請看下文)。
默認條件下, viewWillAppear中打印ContentInset—0.000000_0.000000_0.000000_0.000000, viewDidAppear中打印ContentInset—64.000000_0.000000_49.000000_0.000000。

當修改其值爲NO後,

self.automaticallyAdjustsScrollViewInsets = NO;

修改後控制器不會根據系統的默認情況自動調整contentInset了, 所以在viewWillAppear中打印ContentInset—0.000000_0.000000_0.000000_0.000000, viewDidAppear中打印ContentInset—0.000000_0.000000_0.000000_0.000000。

就如上面說過的, NavigationBar和TabBar的存在纔是以上那些影響因素的前提。如果沒有它們這些屬性是無效果的, 只有它們的存在才能通過這些屬性影響ScrollView的佈局。沒有導航欄和TabBar的情況是這樣的:

默認情況下NavigationBar和StatusBar都是不隱藏的, 當我們設置NavigationBar的hide爲YES後, 會有什麼顯示效果?

NavigationBar和StatusBar都隱藏後顯示效果呢?

如圖, 我們看到的, NavigationBar隱藏後, 在默認自動調整contentInset的情況下, 其top的值爲20。所以, 想要做透明導航欄又不隱藏狀態欄的需求的, 需要注意下這裏。 當然使用上面的設置scrollView.contentInset的方法可以解決此問題。

TabBar 隱藏的方式

一般的App在每個Tab首頁的VC纔會展示出TabBar, 而在第二層頁面開始就要隱藏TabBar了。但是TabBar不同的隱藏方式, 會產生某些影響, 從而影響scrollView.contentInset。
關於TabBar的隱藏方式有興趣的可以瞭解我的另一篇文章iOS各種 bar 隱藏的方法

比如說我們使用的是系統的TabBar, 通過hidesBottomBarWhenPushed方法進行隱藏TabBar和tabBar.hidden 兩種方式來對比。

- (BOOL) hidesBottomBarWhenPushed {
    return YES;
}

此方法下, viewWillAppear中打印ContentInset—0.000000_0.000000_0.000000_0.000000, viewDidAppear中打印ContentInset—64.000000_0.000000_0.000000_0.000000。

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    [self.tabBarController.tabBar setHidden:YES];
}
- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    [self.tabBarController.tabBar setHidden:YES];
}

此方法下, viewWillAppear中打印ContentInset—0.000000_0.000000_0.000000_0.000000, viewDidAppear中打印ContentInset—64.000000_0.000000_49.000000_0.000000。

兩種方法的區別在於, 執行tabBar隱藏的時機不同, hidesBottomBarWhenPushed方法在viewDidLoad之前執行, 而tabBar.hidden 是在viewWillAppear中執行的。正是因爲此, 造成ScrollView.contentInset的不同, 因爲hidesBottomBarWhenPushed方法在佈局配置前就隱藏了tabBar了, 所以不會有內嵌的影響了。
雖與本主題無關, 但還是推薦大家使用hidesBottomBarWhenPushed`方法。

第一個addSubview的是不是當前的scrollView

當前控制器中, self.view上第一個addSubview的如果是ScrollView, 則以上的所有因素纔可以影響其佈局。如果不是第一個添加的, 則以上所有的屬性影響失效。

當我們在viewDidLoad中第一個添加一個任何的View

- (void)createAnyView {
    UIView *firstView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 200)];
    firstView.backgroundColor = [UIColor redColor];
    [self.view addSubview:firstView];
}

代碼添加的效果如下:

而且還有一個佈局方面的問題, 在默認情況下只要是第一個添加的ScrollView, 無論ScrollView.frame爲多少, 它的contentInset是會一直存在的。

_tableView.frame = CGRectMake(0, 100, kWidth, kHeight - 200);

效果如下:

第二類影響因素(iOS11及之後版本)

iOS11開始, 關於影響UIScrollView的佈局的API有了一些調整, 棄用了一些之前版本的屬性, 又加入了一些新的屬性。比如automaticallyAdjustsScrollViewInsets 在iOS11開始被棄用, 取而代之的是contentInsetAdjustmentBehavior 屬性。我們先了解下iOS11 之後新添加的一些概念和屬性。
但最大的區別是, iOS11引入一個全新的概念 safeAreaLayoutGuide, 它改變了整個iOS的佈局機制, 當然也影響了ScrollView的佈局。還有相應的一系列的屬性, adjustedContentInset, additionalSafeAreaInsets, contentInsetAdjustmentBehavior, 它們與UIScrollView的佈局相關。

默認的示例代碼在iOS11中, 的顯示結果如下:

打印結果如下:
這裏寫圖片描述

safeAreaLayoutGuide 和 safeArea

@property(nonatomic,readonly,strong) UILayoutGuide *safeAreaLayoutGuide API_AVAILABLE( ios(11.0), tvos(11.0));

指可以與自動佈局交互的矩形區域。使用佈局指南來替換您可能創建的虛擬視圖來表示視圖間空間或封裝在用戶界面中。傳統上,有一些自動佈局技術需要虛擬視圖。虛擬視圖是一個空視圖,它沒有任何自己的可視元素,僅用於在視圖層次結構中定義一個矩形區域。
這個屬性是UIView的一個只讀屬性,意味着所有UIView對象都有並且是系統自動定義好的。繼承自UILayoutGuide,它有layoutFrame屬性意味着它能代表一塊區域, 代表的這塊區域就是safeArea。它反映了UIView對象避開Navigation bar, tab bar,tool bar以及隱藏視圖控制器視圖的父View中覆蓋的區域, 從而形成的一個安全區域。safeAreaLayoutGuide是一個相對抽象的概念, 但我們可以理解爲它的layoutFrame屬性, 即一個矩形區域可以稱爲safeArea

以iPhone X爲例, safeArea區域:


iOS11中 ContentInset 和 safeAreaInsets

其實在iOS11開始, 第二類影響因素(除第一個addSubview的是不是當前的scrollView外)依然會影響到UIScrollView的佈局。而且影響的效果也與iOS10及之前一致, 那麼區別是什麼?
看一段打印結果你就明白了, 默認舉例的代碼中viewDidAppear中打印結果對比。在iOS10及之前版本中:
這裏寫圖片描述
iOS11中:
這裏寫圖片描述

根據上面的打印結果, 可以得出: iOS10及之前版本 中所有第二類影響因素最終都會以系統自動調整UIScrollView的contentInset 屬性的值來對其佈局產生影響。而iOS11開始所有的自動調整都會通過safeAreaInsets中的值來影響佈局。所以我們會看到無論我們如何設置第二類影響因素, UIScrollView的contentInset 默認情況下都會是0.000000_0.000000_0.000000_0.000000。反而, 這些值, 會出現在safeArea中64.000000_0.000000_49.000000_0.000000。

總結一下就是, 在iOS11 中, 第二類影響因素同樣會影響UIScrollView的佈局, 只是在iOS10 自動調整的值有contentInset 改爲了 safeAreaInsets。但有一個因素在iOS11 將不受影響, 就是第一個addSubview的是不是當前的scrollView 這個因素。

contentInsetAdjustmentBehavior

iOS11 開始, Controller的屬automaticallyAdjustsScrollViewInsets 被棄用, 新加入了ScrollView的屬性 contentInsetAdjustmentBehavior 與其有相同的功能。

@property(nonatomic) UIScrollViewContentInsetAdjustmentBehavior contentInsetAdjustmentBehavior API_AVAILABLE( ios(11.0), tvos(11.0));

typedef NS_ENUM(NSInteger, UIScrollViewContentInsetAdjustmentBehavior) {
UIScrollViewContentInsetAdjustmentAutomatic,
UIScrollViewContentInsetAdjustmentScrollableAxes,
UIScrollViewContentInsetAdjustmentNever,
UIScrollViewContentInsetAdjustmentAlways,
} API_AVAILABLE(ios(11.0),tvos(11.0));

ScrollView的屬性 contentInsetAdjustmentBehavior 分爲四個枚舉, 分別是Automatic, ScrollableAxes, Never, Always。有些疑惑的可能是前面兩個枚舉, 那我做個解析。
先說ScrollableAxes, 是在可滑動方向上根據安全區域來自動調整contentInset, 不可滑動的方向不會自動調整contentInset。 就像API中描述的 (contentSize.width/height > frame.size.width/height or alwaysBounceHorizontal/Vertical = YES), 可滑動時或者強制設置爲橫向/縱向可回彈時, 纔會自動調整contentInset。
再來說Automatic, 默認枚舉, 與ScrollableAxes類似, 但它會根據你的佈局來判斷是否需要自動調整contentInset。在NavigationBar, TabBar, StatusBar 等情況下, 一定會自動調整contentInset, 只有在特殊情況它也不會做自動調整的。 比如, 下圖綠色箭頭處自動調整了, 但左側的iphoneX的齊劉海沒有自動調整:

這裏參考了contentInsetAdjustmentBehavior各個值之間的區別 這篇文章, 具體情況, 裏面也有代碼舉例。

在扯回我們的主題中, 當在示例代碼中添加這樣的代碼

_tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;

打印的結果如下:
這裏寫圖片描述

adjustedContentInset

@property(nonatomic, readonly) UIEdgeInsets adjustedContentInset API_AVAILABLE( ios(11.0), tvos(11.0));

這是UIScrollView的屬性, 且爲只讀屬性, 當contentInsetAdjustmentBehavior允許時,他是safeAreaInsetsadjustedContentInset 的和。

如果當我們調整tablevew的contentInset時,

_tableView.contentInset = UIEdgeInsetsMake(100.0f, 0.0f, 50.0f, 0.0f);

顯示結果如下:

打印結果如下:
這裏寫圖片描述

additionalSafeAreaInsets

@property(nonatomic) UIEdgeInsets additionalSafeAreaInsets API_AVAILABLE( ios(11.0), tvos(11.0));

這個屬性是UIViewController的屬性, UIViewController的子類可以使用這個屬性來調整UIViewController的safeAreaInsets。我們可以使用此屬性來擴展安全區域,以在界面中包含自定義內容。 例如,繪圖應用程序可能會使用此屬性來避免在工具選項板下顯示內容。

顯示結果如下:

打印結果如下:
這裏寫圖片描述

此文章僅僅是拋磚引玉, 若有問題或者新的因素的話, 麻煩在我的博客留言, 謝謝。

相關代碼下載地址:
Scrollview/TableView佈局的影響因素
相關資料:
iOS6與iOS7屏幕適配 edgesForExtendedLayout
影響屏幕適配的因素及tableview的ContentSize不正確的問題
iOS各種 bar 隱藏的方法

發佈了209 篇原創文章 · 獲贊 128 · 訪問量 58萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章