“对齐”是 SwiftUI 中极为重要的概念,但是相当多的开发者并不能很好地驾驭这个布局利器。在 WWDC 2022 中,苹果为 SwiftUI 增添了 Layout 协议,让咱们有了更多的时机了解和验证 SwiftUI 的布局原理。本文将结合 Layout 协议的内容对 SwiftUI 的 “对齐” 进行整理,期望能让读者对“对齐”有愈加明晰地认识和把握。
本文并不会对 alignment 、alignmentGuide 等内容作翔实的介绍,想了解更多的内容可以阅览文中引荐的材料。可以在此处下载 本文所需的源代码
原文宣布在我的博客wwww.fatbobman.com
欢迎订阅我的公共号:【肘子的Swift记事本】
什么是对齐( Alignment )
对齐是发生在多个对象之间的一种行为。比方将书桌上的一摞书摆放整齐,列队练习时向左(右)看齐等等。在 SwiftUI 中,对齐是指在布局容器中,将多个视图依照对齐攻略( Alignment Guide )进行对齐。比方下面的代码就是要求 ZStack 容器内的一切视图,依照各自的中心点进行对齐:
ZStack(alignment: .center) {
Text("Hello")
Text("World")
Circle()
.frame(width: 50, height: 50)
}
在“对齐”行为中最关键的两点为:
- 以什么为对齐攻略
- 对哪些视图进行“对齐”
对齐攻略
概述
对齐攻略( alignment guide)用来标识视图间进行对齐的依据,它具有如下特色:
-
对齐攻略不只可以标识点,还可以标识线
在 SwiftUI 中,别离用 HorizontalAlignment 和 VerticalAlignment 来标识在视图纵轴和横轴方向的参阅线,并且可以由两者共同构成对视图中的某个详细的参阅点的标识。
HorizontalAlignment.leading 、HorizontalAlignment.center 、HorizontalAlignment.trailing 别离标识了前沿、中心和后缘( 沿视图水平轴 )。
VerticalAlignment.top 、VerticalAlignment.center 、VerticalAlignment.bottom 则别离标识了顶部、中心和底部( 沿视图垂直轴 )。
而 Alignment.topLeading 则由 HorizontalAlignment.leading 和 VerticalAlignment.top 构成,两条参阅线的交叉点标识了视图的顶部—前沿。
-
对齐攻略由函数构成
HorizontalAlignment 和 VerticalAlignment 本质上是一个回来类型为 CGFloat 的函数。该函数将回来沿特定轴向的对齐方位( 偏移量 )
-
对齐攻略支撑多种布局方向
正是因为对齐攻略由函数构成,因而其先天便具有了灵活的适应能力。在 SwiftUI 中,体系预置对齐攻略都供给了对不同布局方向的支撑。只需修改视图的排版方向,对齐攻略将主动改变其对应的方位
VStack(alignment:.leading){
Text("Hello world")
Text("WWDC 2022")
}
.environment(\.layoutDirection, .rightToLeft)
想更多地了解自界说对齐攻略以及 Alignment Guide 的应用事例,引荐阅览 Javier 的 Alignment Guides in SwiftUI 一文
自界说对齐攻略
除了 SwiftUI 供给的预置对齐攻略外,开发者也可以自界说对齐攻略:
struct OneThirdWidthID: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context.width / 3
}
}
// 自界说了一个 HorizontalAlignment , 该参阅值为视图宽度的三分之一
extension HorizontalAlignment {
static let oneThird = HorizontalAlignment(OneThirdWidthID.self)
}
// 也可以为 ZStack 、frame 界说一起具有两个维度值的参阅点
extension Alignment {
static let customAlignment = Alignment(horizontal: .oneThird, vertical: .top)
}
自界说对齐攻略与 SwiftUI 预置的对齐攻略一样,可用于任何支撑对齐的容器视图。
alignmentGuide 润饰器
在 SwiftUI 中,开发者可以运用 alignmentGuide 润饰器来修改视图某个对齐攻略的值( 为对齐攻略设定显式值,有关显式值见下文)。比方:
struct AlignmentGuideDemo:View{
var body: some View{
VStack(alignment:.leading) {
rectangle // Rectangle1
.alignmentGuide(.leading, computeValue: { viewDimensions in
let defaultLeading = viewDimensions[.leading] // default is 0
let newLeading = defaultLeading + 30
return newLeading
})
rectangle // Rectangle2
}
.border(.pink)
}
var rectangle:some View {
Rectangle()
.fill(.blue.gradient)
.frame(width: 100, height: 100)
}
}
经过 alignmentGuide 咱们将 Rectangle1 的 HorizontalAlignment.leading 沿水平轴向右侧偏移了 30 ,与 Rectangle2 在 VStack 中按 .leading 对齐后结果如下图:
对齐攻略的显式值
对齐攻略值 = 显式值 ?? 默许值
视图中的每个对齐攻略都有默许值( 经过在对齐攻略界说中的 defaultValue 办法获取 )。在不为对齐攻略设置显式值( 显式值为 nil )的情况下,对齐攻略将回来默许值。
Rectangle()
.fill(.blue.gradient)
.frame(width: 100, height: 100)
// 默许的对齐攻略值:
// leading: 0 , HorizontalAlignment.center: 50, trailing: 50
// top: 0 , VerticalAlignment.center: 50 , bottom: 100
// firstTextBaseline : 100 , lastTextBaseline : 100
假如咱们运用了 alignmentGuide 为某个对齐攻略设置了显式值,那么此刻对齐攻略的值为咱们设置的显式值。
Rectangle()
.fill(.blue.gradient)
.frame(width: 100, height: 100)
.alignmentGuide(.leading, computeValue: { viewDimensions in
let leading = viewDimensions[.leading] // 因为此刻显式值为 nil , 因而 leading 值为 0
return viewDimensions.width / 3 // 将 leading 的显式值设置为宽度三分之一处
})
.alignmentGuide(.leading, computeValue: { viewDimensions in
let leading = viewDimensions[.leading] // 因为上面设置了显式值,此刻 leading 值为 33.33
let explicitLeading = viewDimensions[explicit: .leading] // 显式值 , 此刻为 Optional(33.33)
return viewDimensions[HorizontalAlignment.center] // 再度设置 leading 的显式值。此刻显式值为 Optional(50) , .leading 值为 50
})
即使你没有修改对齐攻略的默许值,但只需为 alignmentGuide 供给了回来值,便设置了显式值:
Rectangle()
.fill(.blue.gradient)
.frame(width: 100, height: 100)
.alignmentGuide(.leading, computeValue: { viewDimensions in
let leading = viewDimensions[.leading] // 此刻 leading 的显式值为 nil
return leading // 此刻 leading 为 0 ,leading 的显式值为 0
})
特别的对齐攻略
在上文中,咱们故意避开了两个容易令人困惑的对齐攻略:firstTextBaseline、lastTextBaseline 。因为这两个对齐攻略会依据视图内容的不同而改变。
在阅览下面的代码时,请在心中自行分析一下视图对应的 firstTextBaseline 和 lastTextBaseline 对齐攻略的方位:
Rectangle()
.fill(.orange.gradient)
.frame(width: 100, height: 100)
视图中没有文字,firstTextBaseline 和 lastTextBaseline 等同于 bottom
Text("Hello world")
.border(.red)
单行文字,firstTextBaseline 和 lastTextBaseline 相同。文字基线不同于 bottom
Text("山不在高,有仙则名。水不在深,有龙则灵。斯是陋室,惟吾德馨。苔痕上阶绿,草色入帘青。谈笑有鸿儒,来往无白丁。可以调素琴,阅金经。无丝竹之乱耳,无案牍之劳形。南阳诸葛庐,西蜀子云亭。孔子云:何陋之有?")
.frame(width:200)
多行文字,firstTextBaseline 为第一行文字基线,lastTextBaseline 为最后一行文字基线
SwiftUI 关于布局容器( 复合视图 )的 firstTextBaseline 和 lastTextBaseline 的不透明计算办法,是产生困惑的主要原因。
Button("Hello world"){}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button(action: {}, label: {
Capsule(style: .circular).fill(.yellow.gradient).frame(width: 30, height: 15)
})
.buttonStyle(.borderedProminent)
.controlSize(.large)
Text("Hello world")
.frame(width: 100, height: 100, alignment: .topLeading)
.border(.red)
VStack {
Rectangle().fill(.red.gradient).frame(width: 50, height: 10)
Text("Hello world")
Text("WWDC 2022")
Text("肘子的 Swift 记事本")
Rectangle().fill(.blue.gradient).frame(width: 50, height: 10)
}
.border(.red)
VStack {
Rectangle().fill(.red.gradient).frame(width: 50, height: 50)
Rectangle().fill(.blue.gradient).frame(width: 50, height: 50)
}
.border(.red)
HStack(alignment: .center) {
Rectangle().fill(.blue.gradient).frame(width: 20, height: 50)
Text("Hello world")
.frame(width: 100, height: 100, alignment: .top)
Text("山不在高,有仙则名。水不在深,有龙则灵。斯是陋室,惟吾德馨。苔痕上阶绿,草色入帘青。谈笑有鸿儒,来往无白丁。可以调素琴,阅金经。无丝竹之乱耳,无案牍之劳形。南阳诸葛庐,西蜀子云亭。孔子云:何陋之有?")
.frame(width: 100)
Text("WWDC 2022")
.frame(width: 100, height: 100, alignment: .center)
Rectangle().fill(.blue.gradient).frame(width: 20, height: 50)
}
.border(.red)
ZStack {
Text("Hello world")
.frame(width: 100, height: 100, alignment: .topTrailing)
.border(.red)
Color.blue.opacity(0.2)
Text("肘子的 Swift 记事本")
.frame(width: 100, height: 100, alignment: .bottomLeading)
.border(.red)
}
.frame(width: 130, height: 130)
.border(.red)
Grid {
GridRow(alignment:.lastTextBaseline) {
Text("Good")
Text("Hello world")
.frame(width: 50, height:50, alignment: .top)
.border(.red)
Text("Nice")
}
GridRow {
Color.red.opacity(0.3)
Color.green.opacity(0.2)
Color.pink.opacity(0.2)
}
GridRow(alignment:.top) {
Text("Start")
Text("WWDC 2022")
.frame(width: 70, height:50, alignment: .center)
.border(.red)
Rectangle()
.fill(.blue.gradient)
}
}
.frame(maxWidth: 300, maxHeight: 300)
.border(.red)
HStack {
Text("First")
VStack {
Text("Hello world")
Text("肘子的 Swift 记事本")
Text("WWDC")
}
.border(.red)
.padding()
Text("Second")
Rectangle().fill(.red.gradient)
.frame(maxWidth: 10, maxHeight: 100)
}
.border(.green)
请暂停阅览下文,看看你是否可以从上面的代码中总结出 SwiftUI 关于布局容器( 复合视图 )的 firstTextBaseline 和 lastTextBaseline 的计算规则。
…
…
…
…
…
…
…
…
…
…
复合视图的 firstTextBaseline 和 lastTextBaseline 计算办法为:
- 关于 firstTextBaseline ,假如复合视图中( 容器中 )的子视图存在显式值非 nil 的 firstTextBaseline ,则回来显式值方位最高的 firstTextBaseline,不然回来默许值( 一般为 bottom )
- 关于 lastTextBaseline ,假如复合视图中( 容器中 )的子视图存在显式值非 nil 的 lastTextBaseline ,则回来显式值方位最低的 lastTextBaseline,不然回来默许值( 一般为 bottom )
这就是尽管开发者很少会在 alignmentGuide 中关怀并运用对齐攻略的显式值,但它在 SwiftUI 中仍十分重要的原因。
为契合 Layout 协议的自界说布局设置显式对齐攻略
SwiftUI 4.0 新增的 Layout 协议,让开发者具有了自界说布局容器的能力。经过运用 Layout 协议供给的 explicitAlignment 办法,咱们可以验证上面有关布局容器( 复合视图 )的 firstTextBaseline 和 lastTextBaseline 的算法正确与否。
Layout 协议供给了两个不同参数类型的 explicitAlignment 办法,别离对应 VerticalAlignment 和 HorizontalAlignment 类型。explicitAlignment 让开发者可以站在布局的视点来设置对齐攻略的显式值。explicitAlignment 的默许完结将为任何的布局攻略的显式值回来 nil 。
下面的代码片段来自本文顺便的源码 —— 用 Layout 协议拷贝 ZStack 。我将经过在 explicitAlignment 办法中别离为 firstTextBaseline 和 lastTextBaseline 设置了显式对齐攻略,以证实之前的猜测。
// SwiftUI 经过此办法来获取特定的对齐攻略的显式值
func explicitAlignment(of guide: VerticalAlignment, // 查询的对齐攻略
in bounds: CGRect, // 自界说容器的 bounds ,该 bounds 的尺度由 sizeThatFits 办法计算得出,与 placeSubviews 的 bounds 参数共同
proposal: ProposedViewSize, // 父视图的引荐尺度
subviews: Subviews, // 容器内的子视图署理
cache: inout CacheInfo // 缓存数据,本例中,咱们在缓存数据中保存了每个子视图的 viewDimension、虚拟 bounds 能信息
) -> CGFloat? {
let offsetY = cache.cropBounds.minY * -1
let infinity: CGFloat = .infinity
// 检查子视图中是否有 显式 firstTextBaseline 不为 nil 的视图。假如有,则回来方位最高的 firstTextBaseline 值。
if guide == .firstTextBaseline,!cache.subviewInfo.isEmpty {
let firstTextBaseline = cache.subviewInfo.reduce(infinity) { current, info in
let baseline = info.viewDimension[explicit: .firstTextBaseline] ?? infinity
// 将子视图的显式 firstTextBaseline 转化成 bounds 中的偏移值
let transformBaseline = transformPoint(original: baseline + info.bounds.minY, offset: offsetY, targetBoundsMinX: 0)
// 回来方位最高的值( 值最小 )
return min(current, transformBaseline)
}
return firstTextBaseline != infinity ? firstTextBaseline : nil
}
if guide == .lastTextBaseline,!cache.subviewInfo.isEmpty {
let lastTextBaseline = cache.subviewInfo.reduce(-infinity) { current, info in
let baseline = info.viewDimension[explicit: .lastTextBaseline] ?? -infinity
let transformBaseline = transformPoint(original: baseline + info.bounds.minY, offset: offsetY, targetBoundsMinX: 0)
return max(current, transformBaseline)
}
return lastTextBaseline != -infinity ? lastTextBaseline : nil
}
return nil
}
因为视图运用 Layout 协议的 explicitAlignment 办法的默许完结作用与运用咱们自界说的办法作用完全共同,因而可以证明咱们之前的猜测是正确的。假如你只想让你的自界说布局容器呈现与 SwiftUI 预置容器共同的对齐攻略作用,直接运用 Layout 协议的默许完结即可( 无需完结 explicitAlignment 办法 )。
即使布局容器经过 explicitAlignment 为对齐攻略供给了显式值,开发者依然可以经过 alignmentGuide 做进一步设置。
对哪些视图进行“对齐”
在上文中咱们用了不小的篇幅介绍了对齐攻略,本节中咱们将讨论“对齐”的另一大关键点 —— 在不同的上下文中,哪些视图会运用对齐攻略进行“对齐”。
VStack、HStack、ZStack 等支撑多视图的布局容器
你是否了解 SwiftUI 常用布局容器结构办法中的对齐参数的含义?它们又是怎么完结的呢?
VStack(alignment:.trailing) { ... }
ZStack(alignment: .center) { ... }
HStack(alignment:.lastTextBaseline) { ... }
GridRow(alignment:.firstTextBaseline) { ... }
因为苹果对容器视图的 alignment 参数的描绘并不很明晰,因而开发者很容易呈现了解误差。
The guide for aligning the subviews in this stack. This guide has the same vertical screen coordinate for every child view —— Apple documentation for VStack’s alignment
关于本段视图声明代码,你会挑选下面哪种文字表述:
ZStack(alignment: .bottomLeading) {
Rectangle()
.fill(.orange.gradient)
.frame(width: 100, height: 300)
Rectangle()
.fill(.cyan.gradient).opacity(0.7)
.frame(width: 300, height: 100)
}
-
在 ZStack 中按次序堆叠摆放子视图( Rectangle1 和 Rectangle2 ),并让每个子视图的 bottomLeading 与 ZStack 的 bottomLeading 对齐
-
按次序堆叠摆放 Rectangle1 和 Rectangle2,并让两者的 bottomLeading 对齐
假如你挑选了 1 ,请问你该怎么解说下面代码中的 alignmentGuide 无法影响子视图的对齐。
ZStack(alignment: .bottomLeading) {
Rectangle()
.fill(.orange.gradient)
.frame(width: 100, height: 300)
Rectangle()
.fill(.cyan.gradient).opacity(0.7)
.frame(width: 300, height: 100)
}
.alignmentGuide(.leading){
$0[.leading] + 10
}
描绘 1 在绝大多数的情况下( 不设置对齐攻略显式值 )看起来都像是正确的,而且也很契合人的直觉,但从 SwiftUI 的视点来说,它将依据描绘二来执行。因为在布局容器结构办法中设定的对齐攻略只用于容器的子视图之间。
为了更好地了解之所以描绘二才是正确的,咱们需求对 SwiftUI 的布局原理以及 ZStack 的处理方法有所了解。
布局容器在布局时,容器会为每个子视图供给一个主张尺度( proposal size ),子视图将参阅容器供给的主张尺度回来自己的需求尺度( 子视图也可以完全无视容器的主张尺度而供给任意的需求尺度 )。容器依照预设的行为( 在指定轴向摆放、点对齐、线对齐 、增加空隙等 )在一个虚拟的画布中摆放一切的子视图。摆放结束后,容器将汇总摆放后的一切子视图的情况并向它的父视图( 父容器 )回来一个本身的需求尺度。
因而,在布局容器对子视图进行对齐摆放过程中,布局容器的尺度并没有确认下来,所以不会存在将子视图的对齐攻略与容器的对齐攻略进行“对齐”的或许。
经过创立契合 Layout 协议的布局容器可以清楚地展现上述的过程,下面的代码来自本文顺便的演示代码 —— 一个 ZStack 的复制品 :
// 容器的父视图(父容器)经过调用容器的 sizeThatFits 获取容器的抱负尺度,本办法一般会被屡次调用,并供给不同的主张尺度
func sizeThatFits(
proposal: ProposedViewSize, // 容器的父视图(父容器)供给的主张尺度
subviews: Subviews, // 当时容器内的一切子视图的署理
cache: inout CacheInfo // 缓存数据,本例中用于保存子视图的回来的需求尺度,削减调用次数
) -> CGSize {
cache = .init() // 清除缓存
for subview in subviews {
// 为子视图供给主张尺度,获取子视图的需求尺度 (ViewDimensions)
let viewDimension = subview.dimensions(in: proposal)
// 依据 MyZStack 的 alignment 的设置获取子视图 alignmentGuide 对应的点
let alignmentGuide: CGPoint = .init(
x: viewDimension[alignment.horizontal],
y: viewDimension[alignment.vertical]
)
// 以子视图的 alignmentGuide 对应点为 (0,0) , 在虚拟的画布中,为子视图创立 Bounds
let bounds: CGRect = .init(
origin: .init(x: -alignmentGuide.x, y: -alignmentGuide.y),
size: .init(width: viewDimension.width, height: viewDimension.height)
)
// 保存子视图在虚拟画布中的信息
cache.subviewInfo.append(.init(viewDimension: viewDimension, bounds: bounds))
}
// 依据一切子视图在虚拟画布中的数据,生成 MyZStack 的 Bounds
cache.cropBounds = cache.subviewInfo.map(\.bounds).cropBounds()
// 回来当时容器的需求尺度,当时容器的父视图将运用该尺度在它的内部进行摆放
return cache.cropBounds.size
}
// 容器的父视图(父容器)将在需求的时机调用本办法,为本容器的子视图设置烘托方位
func placeSubviews(
in bounds: CGRect, // 依据当时容器在 sizeThatFits 供给的尺度,在实在烘托处创立的 Bounds
proposal: ProposedViewSize, // 容器的父视图(父容器)供给的主张尺度
subviews: Subviews, // 当时容器内的一切子视图的署理
cache: inout CacheInfo // 缓存数据,本例中用于保存子视图的回来的抱负尺度,削减调用次数
) {
// 虚拟画布左上角的偏移值 ( 到 0,0 )
let offsetX = cache.cropBounds.minX * -1
let offsetY = cache.cropBounds.minY * -1
for index in subviews.indices {
let info = cache.subviewInfo[index]
// 将虚拟画布中的方位信息转化成烘托 bounds 的方位信息
let x = transformPoint(original: info.bounds.minX, offset: offsetX, targetBoundsMinX: bounds.minX)
let y = transformPoint(original: info.bounds.minY, offset: offsetY, targetBoundsMinX: bounds.minY)
// 将转化后的方位信息设置到子视图上
subviews[index].place(at: .init(x: x, y: y), anchor: .topLeading, proposal: proposal)
}
}
VStack 和 HStack 相关于 ZStack 在布局时将愈加复杂。因为需求考虑在特定维度上可动态调整尺度的子视图,比方: Spacer 、Text 、frame(minWidth:maxWidth:minHeight:maxHeight) 等,VStack 和 HStack 会为子视图进行屡次尺度提案( 包括抱负尺度、最小尺度、最大尺度、特定尺度等 ),并结合子视图的布局优先级( layoutPriority )才干计算出子视图的需求尺度,并最终确认本身的尺度。
总归,为 VStack、HStack、ZStack 这类可包括多个子视图的官方布局容器设置 alignment 的含义就只有一种 —— 在特定维度上,将一切的子视图依照给定的对齐攻略进行对齐摆放。
overlay、background
在 SwiftUI 中,除了咱们了解的 VStack、HStack、ZStack 、Grid 、List 外,许多 modifier 的功能也都是经过布局来完结的。例如 overlay、background、frame、padding 等等。
你可以将 overlay 和 background 视作一个特别版别的 ZStack 。
// 主视图
Rectangle()
.fill(.orange.gradient)
.frame(width: 100, height: 100)
// 附加视图
.overlay(alignment:.topTrailing){
Text("Hi")
}
比方上面的代码,假如用布局的逻辑可以表示为( 伪代码):
_OverlayLayout {
// 主视图
Rectangle()
.fill(.orange.gradient)
.frame(width: 100, height: 100)
// 附加视图
Text("Hi")
.layoutValue(key: Alignment.self, value: .topTrailing) // 一种子视图向最近容器传递信息的方法
}
与 ZStack 的不同在于,它只会包括两个子视图,且它的尺度将仅由主视图来决定。主视图将和附加视图依照设定的对齐攻略进行对齐。只需了解了这点,就会知道该怎么调整主视图或辅佐视图的对齐攻略了,比方:
// 主视图
Rectangle()
.fill(.orange.gradient)
.frame(width: 100, height: 100)
.alignmentGuide(.trailing, computeValue: {
$0[.trailing] - 30
})
.alignmentGuide(.top, computeValue: {
$0[.top] + 30
})
// 附加视图
.overlay(alignment:.topTrailing){
Text("Hi")
}
frame
frame 本质上就是 SwiftUI 中一个用于调理尺度的布局容器,它会改换容器传递给子视图的主张尺度,也或许会改变子视图回来给容器的需求尺度。比方:
VStack {
Text("Hello world")
.frame(width: 10, height: 30, alignment: .top)
}
在上面的代码中,因为增加了 frame 润饰器,因而 FrameLayout( 完结 frame 的后端布局容器 )将无视 VStack 供给的主张尺度,强行为 Text 供给 10 x 30 的主张尺度,并且无视子视图 Text 的需求尺度,为父视图( VStack )回来 10 x 30 的需求尺度。尽管 FrameLayout 中只包括一个子视图,但在布局时它会让子视图与一个特定尺度的虚拟视图进行对齐。或许将上面的 frame 代码转化成 background 的布局形式会愈加便利了解:
_BackgroundLayout {
Color.clear
.frame(width: 10, height: 30)
Text("Hello world")
.layoutValue(key: Alignment.self, value: .top)
}
动态版别的 frame( FlexFrameLayout ) 润饰器是一个学习、了解 SwiftUI 布局中尺度协商机制的绝佳事例。有爱好的朋友可以运用 Layout 协议对其进行拷贝。
总结
尽管本文并没有供给详细的对齐运用技巧,但只需你了解并把握了对齐的两大要点:以什么为对齐攻略、对哪些视图进行“对齐”,那么相信一定会削减你在开发中遇到的对齐困扰,并可以经过对齐完结许多以前不容易完结的作用。
假如你想对 Layout 协议做更全面地了解,引荐你观看 ChaoCode( 美眉 up 主)制造的有关 SwiftUI Layout 协议的中文视频 —— 自订 Layout 排版教育 。
期望本文可以对你有所协助。
原文宣布在我的博客wwww.fatbobman.com
欢迎订阅我的公共号:【肘子的Swift记事本】
我正在参与技术社区创作者签约计划招募活动,点击链接报名投稿。