原文:Auto Layout Visual Format Language Tutorial
作者:József Vesza
译者:kmyhy更新说明:本教程由 József Vesza 升级至 iOS 11、Xcode 9 和 Swift 4。原文作者是 Darren Ferguson.
自动布局的可视化格式语言(VFL)允许你用 ASCII-图片风格的字符串来定义约束。
通过一句代码,你就可以指定多个约束,比如既有水平也有垂直方向的约束。和一次只能创建一个约束而言,这能节省很多代码。
在本文中,你将和 VFL 成为亲密伙伴,你将学习:
- 构造水平和垂直约束
- 在 VFL 中使用视图定义
- 在 VFL 中使用 metrics 常量
- 用布局选项将界面元素和其它元素相对布局
- 用安全区(safe area)适配 iPhone X
注意:本教程假设你熟悉自动布局。如果你不熟悉,请阅读这篇 iOS 11 自动布局入门。
开始
从这里下载开始项目,这是一个拥有一个简单欢迎界面的初级社交 app —— Grapevine。Build & run,你会发现(要旋转模拟器,可以用 Hardware->Rotate Right):
注意:在这部分请使用矩形屏幕的 iPhones(比如 iPhone 8)。后面会讲如何处理 iPhone x。
好,真是够乱的。到底是怎么回事,就来我们该怎么做呢?
所有的界面元素现在都是紧挨着视图的左上角,这是因为它们没有自动布局约束。你将在教程中让这个视图变的漂亮。
打开 Main.storyboard,看一眼这些界面元素。注意 UI 元素的自动布局在编译时被移除了。在真实的项目中你不会这样做,但这只是为了避免写一堆视图的创建代码而已!:]
接下来,请打开 ViewController.swift 看看。你会看到有一些出口已经连接到了 Main.storyboard 中的 IB 元素中。
这里没有多少值得一提的东西,只不过是为了便于我们学习 VFL 而已!
可视化字符串语法
在你创建布局和约束之前,你需要知道什么是 VFL 格式字符串。
首先需要知道的是:这个格式字符串分成了 3 个部分:
它们分别是:
约束的方向,可选的。可能取值是:
H: 水平方向
V: 垂直方向
不指定: 自动布局默认是水平方向父视图的前导符,可选。
- 你的视图的顶端和它父视图的顶端的间距(垂直)
- 你的视图的左端和它父视图的左端(水平)
正在布局的 view,必填。
另一个相关视图,可选。
第二个相关视图,可选。
- 你的视图的底端和它父视图底部的间距(垂直)
- 你的视图的右端和它俯视图右端的间距(水平)
上图中的两个橘黄色符号的意义如下:
? 在布局字符串中这个组件是可选的。
* 在布局字符串中这个组件会出现 0 到多次。
可用字符
VFL 用多个符号来描述布局:
- | 表示父 view
-
- 标准间距 (通常是 8 像素;如果是和父 view 四边的间距,则这个值可以修改为任意值)
- == 宽度等于 (可以忽略)
- -20- 非标准间距 (20 像素)
- <= 小于等于
- >= 大于等于
@250 约束的优先级,可以是 0 - 1000 之间的任意值
- 250 - 优先级低
- 750 - 优先级高
- 1000 - 优先级必须
示例:
H:|-[icon(==iconDate)]-20-[iconLabel(120@250)]-20@750-[iconDate(>=50)]-|
各部分解释如下:
- H: 水平方向
- |-[icon icon 左边和父 view 左边的距离为标准边距
- ==iconDate icon 宽度和 iconDate 宽度相等
- ]-20-[iconLabel icon 的右边距离 iconLabel 的左边 20 像素
- [iconLabel(120@250)] iconLabel 宽 120 像素。这个约束优先级为低,如果出现约束冲突,自动布局会打破这个约束
- -20@750- iconLabel 的右边距离 iconDate 左边 20 像素。该约束优先级为高,如果出现约束冲突,自动布局不会打破这个约束
- [iconDate(>=50)] iconDate 的宽大于等于 50 像素
- -| iconDate 的右边距离父 view 标准边距
现在你大致对 VFL ——主要是格式字符串——有所了解了吧,让我们来看看怎样使用它。
创建约束
苹果提供了 NSLayoutConstraint 类方法,用于创建约束。你将在 Grapevine 中用代码方式创建约束。
打开 ViewController.swift,编写代码:
override func viewDidLoad() {
super.viewDidLoad()
appImageView.isHidden = true
welcomeLabel.isHidden = true
summaryLabel.isHidden = true
pageControl.isHidden = true
}
这段代码隐藏了除 iconImageView、appNameLabel 和 skipButton 之外的 UI 元素。Build & run,你会看到:
好。你去掉了无关的界面元素,然后在 viewDidLoad() 方法底部添加代码:
// 1
let views: [String: Any] = [
"iconImageView": iconImageView,
"appNameLabel": appNameLabel,
"skipButton": skipButton]
// 2
var allConstraints: [NSLayoutConstraint] = []
// 3
let iconVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:|-20-[iconImageView(30)]",
metrics: nil,
views: views)
allConstraints += iconVerticalConstraints
// 4
let nameLabelVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:|-23-[appNameLabel]",
metrics: nil,
views: views)
allConstraints += nameLabelVerticalConstraints
// 5
let skipButtonVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:|-20-[skipButton]",
metrics: nil,
views: views)
allConstraints += skipButtonVerticalConstraints
// 6
let topRowHorizontalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "H:|-15-[iconImageView(30)]-[appNameLabel]-[skipButton]-15-|",
metrics: nil,
views: views)
allConstraints += topRowHorizontalConstraints
// 7
NSLayoutConstraint.activate(allConstraints)
对这段代码分别进行说明:
- 用一个字典保存了 view 和它们的字符串表示,以便在格式字符串中使用。
- 创建一个可变数组,保存约束对象。你将在后面构建它们。
- 设置 iconImageView 的垂直约束,把它放到上端距父 view 上端 20 像素的地方,高度 30 像素。
- 设置 appNameLabel 的垂直约束,把它放到上端距父 view 上端 23 像素的地方。
- 设置 skipButton 的垂直约束,把它放到上端距父 view 上端 20 像素的地方。
- 设置这 3 个 UI 元素的水平约束。iconImageView 的左边距离父 view 左边 15 像素,它自己的宽度是 30 像素。然后,iconImageView 和 appNameLabel 之间的水平间距为 8 像素(标准间距)。最后,skipButton 的右端和父 view 的右端相差 15 像素。
- 用类方法 activate(_:) 激活全部约束。将添加到 allConstraints 数组中的约束一次性传入。
注意:views 字典中字符串类型的 key 必须和格式字符串中的 view 一致。如果不一致,自动布局无法解析引用,并导致运行时崩溃。
Build & run。看看界面现在变什么样了?
啧,看看,你干得真不错!
别急,这只不过是开胃菜。你要写的代码还多着呢,但最终你会觉得是值得的。
接下来,你需要对其它界面元素也进行布局。首先,移除 viewDidLoad() 方法中添加的代码。我知道……那你刚刚才写完的。
删除这几句:
appImageView.isHidden = true
welcomeLabel.isHidden = true
summaryLabel.isHidden = true
pageControl.isHidden = true
移除这些代码,将导致剩下的 UI 元素从隐藏变为显示。
然后,将当前 views 字典的声明修改为:
let views: [String: Any] = [
"iconImageView": iconImageView,
"appNameLabel": appNameLabel,
"skipButton": skipButton,
"appImageView": appImageView,
"welcomeLabel": welcomeLabel,
"summaryLabel": summaryLabel,
"pageControl": pageControl]
这里,你添加的 view 包括 appImageView、welcomeLabel、summaryLabel 和 pageControl,这些将在 VFL 格式字符串中用到东东。
在 viewDidLoad() 底部的 activeate(_:) 一句之上添加:
// 1
let summaryHorizontalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "H:|-15-[summaryLabel]-15-|",
metrics: nil,
views: views)
allConstraints += summaryHorizontalConstraints
let welcomeHorizontalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "H:|-15-[welcomeLabel]-15-|",
metrics: nil,
views: views)
allConstraints += welcomeHorizontalConstraints
// 2
let iconToImageVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[iconImageView]-10-[appImageView]",
metrics: nil,
views: views)
allConstraints += iconToImageVerticalConstraints
// 3
let imageToWelcomeVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[appImageView]-10-[welcomeLabel]",
metrics: nil,
views: views)
allConstraints += imageToWelcomeVerticalConstraints
// 4
let summaryLabelVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[welcomeLabel]-4-[summaryLabel]",
metrics: nil,
views: views)
allConstraints += summaryLabelVerticalConstraints
// 5
let summaryToPageVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[summaryLabel]-15-[pageControl(9)]-15-|",
metrics: nil,
views: views)
allConstraints += summaryToPageVerticalConstraints
这些代码分别说明如下:
- 设置 summaryLabel 和 welcomeLabel 的水平约束,让它们的左右端距离父 view 左右端 15 像素。
- 设置 iconImageView 到 appImageView 的垂直间距为 10 像素。
- 设置 appImageView 到 welcomeLabel 的垂直间距为 10 像素。
- 设置 welcomeLabel 到 summaryLabel 的垂直间距为 4 像素。
- 设置 summaryLabel 到 pageControl 的垂直间距为 15 像素,pageControl 高 9 像素,pageConrol 距离父 view 15 像素。
Build & run;现在的 UI 长什么样子?
现在你已经有所长进了。但是,这还不是你真正想要的,有些 UI 元素布局是对的,但有些是不对的。图片和 page control 没有居中对齐。
别担心,接下来我们将教你如何让布局乖乖听你指挥。
布局选项
布局选项允许你让约束和当前指定的布局方向垂直。
例如,要在一个水平布局方向上,让所有 view 垂直居中对齐,可以用 NSLayoutFormatOptions.AlignAllCenterY 这个布局选项。
不能在垂直布局方向上使用这个选项,因为在进行垂直布局的同时令它们垂直对齐是不可能的。它就不是给垂直方向用的,懂了吧!
接下来,看看如何在构造布局时使用布局选项。从 viewDidLoad() 中删除下列代码:
let nameLabelVerticalConstraints = NSLayoutConstraint.constraintsWithVisualFormat(
"V:|-23-[appNameLabel]",
metrics: nil,
views: views)
allConstraints += nameLabelVerticalConstraints
let skipButtonVerticalConstraints = NSLayoutConstraint.constraintsWithVisualFormat(
"V:|-20-[skipButton]",
metrics: nil,
views: views)
allConstraints += skipButtonVerticalConstraints
我们将 appNameLabel 和 skipButton 的垂直约束删除。然后,让它们在垂直布局中使用布局选项。
找到创建 topRowHorizontalConstraints 约束的代码,将 options 参数修改为 [.alignAllCenterY],变成:
let topRowHorizontalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "H:|-15-[iconImageView(30)]-[appNameLabel]-[skipButton]-15-|",
options: [.alignAllCenterY],
metrics: nil,
views: views)
allConstraints += topRowHorizontalConstraints
.alignAllCenterY 选项为格式字符串中的每个 view 创建垂直居中约束。这段代码能够工作的前提是,iconImageView 前面已经定义过垂直布局约束及高度约束。因此,appNameLabel 和 skipButton 才能和 iconImageView 保持垂直居中对齐。
如果你 build & run,布局实际上没有任何变化,但它的代码变得更合理了:]
删除创建 welcomeHorizontalConstraints 并添加到 constraints 数组的代码。
这将移除 welcomeLabel 的水平约束。
向后,在创建 summaryLabelVerticalConstraints 的代码中修改 options 参数如下:
let summaryLabelVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[welcomeLabel]-4-[summaryLabel]",
options: [.alignAllLeading, .alignAllTrailing],
metrics: nil,
views: views)
allConstraints += summaryLabelVerticalConstraints
这段代码在 options 数组中加入了 .alignAllLeading 和 .alignAllTrailing 选项。welcomeLabel 和 summaryLabel 的左边和右边应该距离父 view 的左边和右边 15 个像素。这段代码能够工作,是因为 summaryLabel 已经定义过水平约束。
虽然这也和之前的布局是一样的效果,但这种方式更好吧。
然后,将 summaryToPageVerticalConstraints 创建时的 options 选项修改为:
let summaryToPageVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[summaryLabel]-15-[pageControl(9)]-15-|",
options: [.alignAllCenterX],
metrics: nil,
views: views)
allConstraints += summaryToPageVerticalConstraints
这个选项将所有 view 对齐 X 轴中心。对 imageToWelcomeVerticalConstraints 进行同样的处理:
let imageToWelcomeVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[appImageView]-10-[welcomeLabel]",
options: [.alignAllCenterX],
metrics: nil,
views: views)
allConstraints += imageToWelcomeVerticalConstraints
Build & run,现在的 UI 长什么样子?
感觉对齐了吗?布局选项让你距离漂亮的用户界面更近了。
布局选项快速手册
这是你在 Grapevine 中用过的选项:
- .alignAllCenterX – 用 NSLayoutAttribute.centerX 对齐 UI 元素。
- .alignAllCenterY – 用 NSLayoutAttribute.centerY 对齐 UI 元素。
- .alignAllLeading – 用 NSLayoutAttribute.leading 对齐 UI 元素。
- .alignAllTrailing – 用 NSLayoutAttribute.trailing 对齐 UI 元素。
除此之外还有:
- .alignAllLeft – 用 NSLayoutAttribute.left 对齐 UI 元素。
- .alignAllRight – 用 NSLayoutAttribute.right 对齐 UI 元素。
- .alignAllTop – 用 NSLayoutAttribute.top 对齐 UI 元素。
- .alignAllBottom – 用 NSLayoutAttribute.bottom 对齐 UI 元素。
- .alignAllLastBaseline – 用 NSLayoutAttribute.lastBaseline 对齐 UI 元素。
你可以在这里找到完整的选项列表。
注意:至少得有其中一个元素得在垂直方向上定义过约束,布局选项才能工作。例如:
NSLayoutConstraints.constraintsWithVisualFormat( "V:[topView]-[middleView]-[bottomView]", options: [.alignAllLeading], metrics: nil, views: ["topView": topView, "middleView": middleView, "bottomView": bottomView"])
topView,middleView 或者 bottomView 之一必须定义得有左边距约束,否则自动布局无法生成正确的约束。
现在来点新的概念!来看看 metrics。
Metrics
Metrics 是一个数值组成的字典,能够使用在 VFL 格式字符串中。如果你有一些标准的坚决或者计算好的尺寸,你不想直接在格式字符串中输入它们的话,就可以用 metrics 了。
在 ViewController.swift 的 @IBOutlet 声明之上,添加下列常量声明:
private enum Metrics {
static let padding: CGFloat = 15.0
static let iconImageViewWidth: CGFloat = 30.0
}
现在,你有一个 padding 常量,以及表示图标图片宽度的常量,你可以创建一个 metrics 字典来使用这些常量。在 viewDidLoad() 声明 views 数组的代码之前添加如下代码:
let metrics = [
"horizontalPadding": Metrics.padding,
"iconImageViewWidth": Metrics.iconImageViewWidth]
这里创建了一个键值对字典用于格式字符串。
然后,将 topRowHorizontalConstraints 和 summaryHorizontalConstraints 的声明替换为:
let topRowHorizontalFormat = """
H:|-horizontalPadding-[iconImageView(iconImageViewWidth)]-[appNameLabel]-[skipButton]-horizontalPadding-|
"""
let topRowHorizontalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: topRowHorizontalFormat,
options: [.alignAllCenterY],
metrics: metrics,
views: views)
allConstraints += topRowHorizontalConstraints
let summaryHorizontalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "H:|-horizontalPadding-[summaryLabel]-horizontalPadding-|",
metrics: metrics,
views: views)
allConstraints += summaryHorizontalConstraints
我们将格式字符串中的硬编码的值用 metrics 字典中的键来代替。同时将 metrics 参数设置为 metrics 字典。
自动布局会对其进行字符串替换,将占位文本替换为 metircs 字典中真正的数值。在上面的例子中,horizontalPadding 将被替换为常量 15,iconImageViewWidth 将被替换为 30 像素。
这样,就将代码中的数字去除了,将它替换成更易读的变量。如果你想修改这个间距,你只需要修改一个地方。这岂不是更好?metrics 字典不仅限于常量,如果你需要在运行时计算某些东西,你可以可以将它们放到这个字典中。
最后一个问题是如何在嵌套在 UINavigationController 或者 UITabBarController中的 view controller 中对界面元素进行布局。
安全区
现在的 UI 看起来很完美,但你只是在一般情况下使用它而已,比如矩形屏幕中。在 2017 年 9 月,苹果又推出了一种新设备,它并不适合这种情况,即 iPhone X。要看看 Grapevine 在这种新设备下运行的样子,请用 iPhone X 模拟器运行 app。
呃,情况不太美妙。图片和欢迎文字没事,请你注意在屏幕顶部和底部的 UI 元素和系统元素。幸运的是,通过安全区,你可以轻易地解决这个问题!
在 iOS 11 后开始出现安全区的概念,它表示 App 能够显示其 UI 而不会被任何 UIKit 指定的特殊元素所影响,这些特殊元素比如状态栏、tab bar。以 iPhone X 为例,iPhone X 的安全区在横屏和竖屏模式下是不一样的:
注意在竖屏时,顶部和底部留有更多空间。但是在横屏模式中,左右留白会更多。因此虽然你已经在 viewDidLoad() 方法中写入了相关约束,但安全区在运行期间会发生改变,因此它不再适用了。幸好,view controller 会通过 viewSafeAreaInsetsDidChange() 方法通知安全区的改变,因此你可以从这里开始。
打开 ViewController.swift 将 viewDidLoad() 方法移除。对的,你没看错,你需要在 viewSafeAreaInsetsDidChange() 方法中重新实现这些内容。
然后,在 IBOutlet 定义之后添加属性:
private var allConstraints: [NSLayoutConstraint] = []
这个属性将所有激活的约束保存起来,以便它们能够在需要建立新约束时被解除。
将 viewSafeAreaInsetsDidChange() 写成:
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
if !allConstraints.isEmpty {
NSLayoutConstraint.deactivate(allConstraints)
allConstraints.removeAll()
}
let newInsets = view.safeAreaInsets
let leftMargin = newInsets.left > 0 ? newInsets.left : Metrics.padding
let rightMargin = newInsets.right > 0 ? newInsets.right : Metrics.padding
let topMargin = newInsets.top > 0 ? newInsets.top : Metrics.padding
let bottomMargin = newInsets.bottom > 0 ? newInsets.bottom : Metrics.padding
let metrics = [
"horizontalPadding": Metrics.padding,
"iconImageViewWidth": Metrics.iconImageViewWidth,
"topMargin": topMargin,
"bottomMargin": bottomMargin,
"leftMargin": leftMargin,
"rightMargin": rightMargin]
}
上述代码首先解除所有已经激活的约束,以免出现自动布局错误。它还增加了 metrics 字典的内容,加入了计算好的边距。你可以用 view 的 safeAreaInsets 属性来访问新的 insets。在常规屏的 iPhone 中,insets 都会是 0;但 iPhone X 会根据不同方向产生不同的数值。为了两种情况都满足,只需要判断 inset 值是否大于 0,如果不是的话,“降级”到原来定义的值。
最后,在 viewSafeAreaInsetsDidChange() 中添加下列约束,并使用新的 metrics 字典:
let views: [String: Any] = [
"iconImageView": iconImageView,
"appNameLabel": appNameLabel,
"skipButton": skipButton,
"appImageView": appImageView,
"welcomeLabel": welcomeLabel,
"summaryLabel": summaryLabel,
"pageControl": pageControl]
let iconVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:|-topMargin-[iconImageView(30)]",
metrics: metrics,
views: views)
allConstraints += iconVerticalConstraints
let topRowHorizontalFormat = """
H:|-leftMargin-[iconImageView(iconImageViewWidth)]-[appNameLabel]-[skipButton]-rightMargin-|
"""
let topRowHorizontalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: topRowHorizontalFormat,
options: [.alignAllCenterY],
metrics: metrics,
views: views)
allConstraints += topRowHorizontalConstraints
let summaryHorizontalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "H:|-horizontalPadding-[summaryLabel]-horizontalPadding-|",
metrics: metrics,
views: views)
allConstraints += summaryHorizontalConstraints
let iconToImageVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[iconImageView]-10-[appImageView]",
metrics: nil,
views: views)
allConstraints += iconToImageVerticalConstraints
let imageToWelcomeVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[appImageView]-10-[welcomeLabel]",
options: [.alignAllCenterX],
metrics: nil,
views: views)
allConstraints += imageToWelcomeVerticalConstraints
let summaryLabelVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[welcomeLabel]-4-[summaryLabel]",
options: [.alignAllLeading, .alignAllTrailing],
metrics: nil,
views: views)
allConstraints += summaryLabelVerticalConstraints
let summaryToPageVerticalConstraints = NSLayoutConstraint.constraints(
withVisualFormat: "V:[summaryLabel]-15-[pageControl(9)]-bottomMargin-|",
options: [.alignAllCenterX],
metrics: metrics,
views: views)
allConstraints += summaryToPageVerticalConstraints
NSLayoutConstraint.activate(allConstraints)
Build & run,你会发现修改过的 UI 在 iPhone 上变得更好了:
限制
VFL 能够让我们只用一条语句就能写出多条约束,从而减轻了我们手指的负担。但是,目前它也有一些限制;一些你必须要理解的注意事项:
- 视图居中
- 使用约束的倍乘系数
视图居中
在 Grapevine 中,我们通过 options 参数中的 .alignAllCenterY 和 .alignAllCenterX 来让视图居中。
这就意味着用其它相关视图来进行视图的垂直居中和水平居中,但只有这些视图中的某个视图已经有足够的约束描述其水平或垂直中心的时候,才能生效。
同时,虽然可以用这些方法在 VFL 中进行视图居中,但不保证今后的版本中这些方法仍然有效。
使用约束的倍乘系数
通过倍乘系数,你可以设置视图的相对比例,或者指定某个 label 的宽度为父 view 宽度的 60% 等等。因为 VFL 一次创建的是多个约束,返回的仅仅上一个约束数组,数组中的约束都未命名,因此无法通过格式字符串来设置倍乘系数。
注意:你可以遍历 constraintsWithVisualFormat 方法返回的每个约束,但你必须挨个处理它们的 NSLayoutAttritute 才能正确设置倍乘系数。但这样的话,你必须替换那个约束,因为倍乘系数是不可变的。
现在,你已经学会怎样使用 VFL 了,你可以将这些知识利用到你的 UI 布局中了。
你也学习了怎样通过布局选项减少要定义的约束数量。以及如何在编译时和运行时定义 metrics。最后,了解了 VFL 的局限,但它的好处要比坏处多,你应该正确地使用它。
接下来做什么?
你可以在这里下载完成好的项目。
注意:当多个项目共享同一个 Bundle id 的时候,Xcode 会出现一些问题。如果你已经看完本文并试图运行下载的示例项目,你必须 clean 一下 build 文件夹——按住 option 按钮,点击 Product \ Clean Build Folder。
关于 iPhone X 和安全区的更多内容,请参考下列文章:
- Update your apps for iPhone X
- iPhone X - iOS Human Interface Guidelines
- Positioning Content Relative to the Safe Area
希望你喜欢本文。有任何问题和建议,请在论坛中留言!