在 iOS 7 Apple 在 UIViewController 中引入了 topLayoutGuidebottomLayoutGuide 属性来描述没有被覆盖(status bar, navigation bar, toolbar, tab bar, etc.)屏幕的区域。在 iOS 11 中,Apple 已经弃用了这些属性,并引入了 safe area。Apple 建议我们不要在 safe area 操作,在 iOS 11 中,当在 iOS App 中定位视图时,你必须使用新的 safe area API。

UIView

在 iOS 11 UIViewController topLayoutGuidebottomLayoutGuide 属性已经被替换成了新的 UIView 中的 safe area:

@available(iOS 11.0, *)
open var safeAreaInsets: UIEdgeInsets { get }

@available(iOS 11.0, *)
open var safeAreaLayoutGuide: UILayoutGuide { get }

safeAreaInsets 属性意味着屏幕可以覆盖从四个方向,而不仅仅是顶部和底部。当被 iPhone X 呈现时,我们就明白了为什么我们需要左右 insets。

ios-safe-area

iPhone 8 vs iPhone X safe area (portrait orientation)

ios-safe-area

iPhone 8 vs iPhone X safe area (landscape orientation)

iPhone X 在 portrait orientation 有 top 和 bottom 的 safe area,在 landscape orientation 有 left right 和 bottom。

让我们来看一个例子。在 ViewController 的 View 的顶部和底部添加了两个带有文本标签和固定高度的 custom subviews,并附加 attached 到视图的边缘 edges。

ios-safe-area

Subviews are attached to the view’s edges

正如所看到的,subviews 内容与顶部的 notch 和底部的 home indicator 指示器重叠 overlapped。为了正确地定位 subviews,我们可以使用手动布局将它们附加到 safe area:

topSubview.frame.origin.x = view.safeAreaInsets.left
topSubview.frame.origin.y = view.safeAreaInsets.top
topSubview.frame.size.width = view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right
topSubview.frame.size.height = 300

或者使用 Auto Layout:

bottomSubview.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor).isActive = true
bottomSubview.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor).isActive = true
bottomSubview.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
bottomSubview.heightAnchor.constraint(equalToConstant: 300).isActive = true
ios-safe-area

Subviews are attached to the superview safe area

上面看起来好很多。此外可以在 subview subclass 添加 subviews content 到 safe area。

// 方法一:
label.translatesAutoresizingMaskIntoConstraints = false
label.leftAnchor.constraint(equalTo: superview.safeAreaLayoutGuide.leftAnchor).isActive = true
label.topAnchor.constraint(equalTo: superview.safeAreaLayoutGuide.topAnchor).isActive = true
label.bottomAnchor.constraint(equalTo: superview.safeAreaLayoutGuide.bottomAnchor).isActive = true
label.rightAnchor.constraint(equalTo: superview.safeAreaLayoutGuide.rightAnchor).isActive = true

// 或者方法二:
override func layoutSubviews() {
    super.layoutSubviews()
    var labelFrame = bounds
    labelFrame.origin.x += safeAreaInsets.left
    labelFrame.origin.y += safeAreaInsets.top
    labelFrame.size.width -= safeAreaInsets.left + safeAreaInsets.right
    labelFrame.size.height -= safeAreaInsets.top + safeAreaInsets.bottom
    label.frame = labelFrame
}
ios-safe-area

Subviews are attached to the view’s edges. Labels are attached to the superview safe area.

在 subviews 层次结构 hierarchy 的任何地方都可以将 view 添加到 safe area。

UIViewController

在 iOS 11 UIViewController 有了一个新属性:

@available(iOS 11.0, *)
open var additionalSafeAreaInsets: UIEdgeInsets

当 view controller subviews 覆盖嵌入的 child view controller views 时,将使用它。例如,Apple 在 UINavigationController 和 UITabBarController 中使用额外的 additional safe area insets,当这些条是半透明的。

additionalSafeAreaInsets 是对现有 safearea 的扩展附加。

当你改变 additional safe area insets 或者 safe area insets 被系统改变,UIView 和 UIViewController 中的方法将被调用:

// UIView
@available(iOS 11.0, *)
open func safeAreaInsetsDidChange()

//UIViewController
@available(iOS 11.0, *)
open func viewSafeAreaInsetsDidChange()

需要注意的是,当状态栏隐藏时,可能会出现一个奇怪的问题:所有的 safe area insets 计算都是正确的,但导航栏会移动到顶部的 notch 下方。这是一个 bug,目前没有很好的修复方法,只能使用一些变通方法。

Simulate iPhone X safe area

Additional safe area insets 也可以用来测试你的 app 是如何支持 iPhone X,如果你不能在模拟器上测试你的 app,而且没有 iPhone X,那就很有用了。

//portrait orientation, status bar is shown
additionalSafeAreaInsets.top = 24.0
additionalSafeAreaInsets.bottom = 34.0

//portrait orientation, status bar is hidden
additionalSafeAreaInsets.top = 44.0
additionalSafeAreaInsets.bottom = 34.0

//landscape orientation
additionalSafeAreaInsets.left = 44.0
additionalSafeAreaInsets.bottom = 21.0
additionalSafeAreaInsets.right = 44.0
ios-safe-area

UIScrollView

在 iOS 11 中,UIScrollView 的 insets 调整行为有了重大变化。让我们添加一个带有文本标签的 scroll view 到 view controller 并将其附加到视图的边缘。

你会发现 scroll view 的 insets 在顶部和底部会自动调整。在 iOS 7 及更高版本中,scroll view content insets 调整行为可以通过 UIViewController 的 automaticallyAdjustsScrollViewInsets 属性管理,但在 iOS 11 中,它被弃用并由新的 UIScrollView 的 contentInsetAdjustmentBehavior 属性替代:

@available(iOS 11.0, *)
public enum ContentInsetAdjustmentBehavior : Int {
    case automatic
    case scrollableAxes
    case never
    case always
}

@available(iOS 11.0, *)
open var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior

Content Insets Adjustment Behavior

  • never — scroll view content insets 永远不会被调整,很简单。

  • scrollableAxes — 只为可滚动的轴调整 content insets。例如,当 scroll view content size height 大于 frame size height 或 alwaysBounceVertical 属性启用时,垂直轴是可滚动的。同样,当 content size width 大于 frame size width 或 alwaysBounceHorizontal 属性启用时,水平轴是可滚动的。

例如,在横屏方向,如果水平轴不可滚动,只有底部 content inset 会被调整,左右 content insets 不会被调整。

  • always — 对所有可滚动和不可滚动的轴调整 scroll view content insets。

  • automatic — 默认值,也是最有趣的值。当以下条件为真时,它与 always 相同:

    • scroll view 水平轴可滚动,垂直轴不可滚动
    • scroll view 是 view controller 视图的第一个子视图
    • view controller 是导航控制器或标签栏控制器的子视图
    • automaticallyAdjustsScrollViewInsets 启用

在所有其他情况下,automaticscrollableAxes 相同。

automatic 行为是默认的,因为它具有向后兼容性 — 具有水平可滚动轴的 scroll views 在 iOS 10 和 iOS 11 中将具有相同的顶部和底部 insets。

Adjusted Content Insets

在 iOS 11 中,UIScrollView 有了新的 adjustedContentInset 属性:

@available(iOS 11.0, *)
open var adjustedContentInset: UIEdgeInsets { get }

contentInsetadjustedContentInset 有什么区别?让我们打印当 scroll view 被顶部的导航栏和底部的标签栏覆盖时的两个值:

// iOS 10
print("contentInset: \(scrollView.contentInset)")
// output: contentInset: UIEdgeInsets(top: 64.0, left: 0.0, bottom: 49.0, right: 0.0)

// iOS 11
print("contentInset: \(scrollView.contentInset)")
print("adjustedContentInset: \(scrollView.adjustedContentInset)")
// output: contentInset: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
// output: adjustedContentInset: UIEdgeInsets(top: 88.0, left: 0.0, bottom: 83.0, right: 0.0)

如果我们从四面添加 10 点到 contentInset 并再次打印两个值:

scrollView.contentInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)

// iOS 10
print("contentInset: \(scrollView.contentInset)")
// output: contentInset: UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0)

// iOS 11
print("contentInset: \(scrollView.contentInset)")
print("adjustedContentInset: \(scrollView.adjustedContentInset)")
// output: contentInset: UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0)
// output: adjustedContentInset: UIEdgeInsets(top: 98.0, left: 10.0, bottom: 93.0, right: 10.0)

我们会看到在 iOS 11 中,实际的 scroll view content insets 可以从 adjustedContentInset 属性读取,而不是从 contentInset 属性读取。这意味着当你的应用同时支持 iOS 10 和 iOS 11 时,应该为调整 content insets 创建不同的逻辑。

如果你更改 contentInset 或者 content insets 被系统调整,UIScrollView 和 UIScrollViewDelegate 中的适当方法将被调用:

// UIScrollView
@available(iOS 11.0, *)
open func adjustedContentInsetDidChange()

// UIScrollViewDelegate
@available(iOS 11.0, *)
optional func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView)

UITableView

UITableView 在 iOS 11 中也有新的 safe area 相关功能。让我们添加一个具有自定义 header 和自定义 cells 的 table view 到 view controller,并将其附加到视图的边缘。

你可以看到 header 和 cell 的 content view frame 在横屏方向下会改变。同时,cell 和 separator frames 不会改变。这是默认行为,可以通过新的 UITableView 的 insetsContentViewsToSafeArea 属性管理:

@available(iOS 11.0, *)
open var insetsContentViewsToSafeArea: Bool

如果你禁用 content view insets:

tableView.insetsContentViewsToSafeArea = false

你会看到现在 header/footer/cell content views frame 等于 header/footer/cell frame。

这意味着在 iOS 11 中,如果 header/footer/cell subviews 添加到 content view,你不需要更改它们的位置,UITableView 会为你完成这项工作。

UICollectionView

和 UITableView 类似,UICollectionView 也有处理 safe area 的方法。当使用 UICollectionViewFlowLayout 时,iOS 11 中新增了 sectionInsetReference 属性:

@available(iOS 11.0, *)
public enum SectionInsetReference : Int {
    case fromContentInset
    case fromSafeArea
    case fromLayoutMargins
}

@available(iOS 11.0, *)
open var sectionInsetReference: UICollectionViewFlowLayout.SectionInsetReference

这个属性用于确定 section insets 的参考点,有三个值:

  • fromContentInset — 默认值,section insets 不会受到 safe area 的影响。
  • fromSafeArea — section insets 会添加 safe area insets。在这种情况下,实际的 section content insets 将等于 section content insets 加上 safe area insets。
  • fromLayoutMargins — section insets 会添加 layout margins。

当在 iPhone X 的横屏模式下使用 grid 布局时,cells 可能会被 notch 覆盖。为了修复这个问题,可以设置 sectionInsetReference.fromSafeArea,这样就不需要手动添加 safe area insets 到 section content insets。

总结

在 iOS 11 中,Apple 添加了许多有用的工具来处理 safe area。这些工具在以下类中都有体现:

  • UIView: 提供 safeAreaInsetssafeAreaLayoutGuide 属性,允许在视图层次结构的任何地方访问 safe area。
  • UIViewController: 添加 additionalSafeAreaInsets 属性,可以扩展 safe area,对测试非常有用。
  • UIScrollView: 引入 contentInsetAdjustmentBehavioradjustedContentInset 属性,完全改变了 scroll view insets 的调整方式。
  • UITableView: 增加 insetsContentViewsToSafeArea 属性,自动调整 content views 以适应 safe area。
  • UICollectionView: 通过 UICollectionViewFlowLayout 的 sectionInsetReference 属性,提供对 section insets 如何响应 safe area 的控制。

所有这些新 API 都使得为 iPhone X 和未来的设备适配应用程序变得更加简单和统一。当开发新的 iOS 11+ 应用时,应该充分利用这些 API,以确保应用在所有屏幕尺寸和形状上看起来都很好。

Refereneces