在学习
SwiftUI
的过程中,首先会学到的modifiers
可能就是.frame
,关·modifiers
这里有必要提一下 ,其实SwiftU
中的modifies
并不是改变View
上的某个属性而是用一个带有相关属性的新View
来包装原有的View
,当然.frame
也不例外,具体的modifiers
的使用可以见我的另外一篇文章,本篇文章主要弄清楚.frame
在SwiftUI
布局体系中到底扮演着什么重要的角色。
基本View
SwiftUI
的布局原则其实很简单:对于层级中的View
,SwiftUI
都会提供一个建议尺寸 ,然后View
将自己布局在这个建议尺寸的可用空间内,并报告自己的实际尺寸,默认情况下系统将View
置于可用空间的中心。但是View
的实际尺寸和收到的建议尺寸是会有出入的,比如实际尺寸比建议尺寸大该如何显示?实际尺寸比建议尺寸小呢?有多的空间时该如何分配给各个View
呢?这些都和SwiftUI
中的基本View
有关,下面我们从基本的View
入手。
stack
Stack
是尽可能的占据更多的空间在满足自己的内容,可不会委屈它们。
Text
Text
的行为和UIKit
中的差不多,这类View
的特点是及其''老实本分'',根据自身内容来占据应有的尺寸,当空间不够时像Text
就宁愿改变自己也不会超出父View
当初提议的尺寸,比如宽度不够时则截断文本显示,高度不够时就显示单行,他们尽可能的尊重提议的尺寸。
Text("hello there, this is a long line that won't fit parent's size.")
.border(Color.blue)
.frame(width: 200, height:30)
.border(Color.green)
.font(.title)
.padding(0)
由于.frame
包装后的View
向Text
提议的尺寸是宽度为200
高度为30
,但是Text
的实际宽度是大于提议尺寸的200
的,为了"尊重"提议的尺寸,Text
截断自身显示,当然也是可以忽略提议尺寸的,后面会解释到。
Image
默认情况下Image
的尺寸是固定的,其会忽略掉布局系统建议的尺寸而总是返回图片的尺寸。要让一个图片 view
尺寸可变,或者说,想让它能接受建议尺寸,并将图片适配显示在这个空间里,我们可以在它上面调用 .resizable
,默认情况下,这会拉伸图片,让它填满整个建议尺寸的空间 (我们也可以设置成以瓷砖平铺的方式,或者只拉伸图片的某个部分来进行填充),因为大部分的图片应该是需要以固定的高宽比展示的,所以 .aspectRatio
经常被直接搭配在 .resizable
后面组合使用。
.aspectRatio
修饰器会去获取建议尺寸,并且基于给定的高宽比,创建一个能最大限度填满建议尺寸的新的尺寸值。接下来,它会将这个尺寸建议给大小可变的图像 (resizable
的图像会填满整个建议尺寸),并将该尺寸返回给上层View
。我们可以选择适配或者填充这个建议尺寸,我们也可以决定是要指定一个高宽比,还是将高宽比留给子View
去做决定。
let image = Image(systemName: "ellipsis")
HStack {
image
image.resizable()
image.resizable().aspectRatio(contentMode: .fit)
}
Path
Path
类型代表了一组 2D
的绘制指令 (和Cocoa
中的 CGPath
类似),它总会将建议的尺寸作为实际尺寸返回,如果所建议的某个方向的值为 nil
,那么它返回默认值10
。
Path { p in
p.move(to: CGPoint(x: 50, y: 0))
p.addLines([
CGPoint(x: 100, y: 75),
CGPoint(x: 0, y: 75),
CGPoint(x: 50, y: 0)
])
}
可能上面的话你不太理解,那么我在写的详细点,下面的这段代码你能想到为什么是这个效果吗?
var body: some View {
VStack(spacing:0) {
Text("hello there, this is a long line that won't fit parent's size.")
.frame(width: 200, height:30)
.border(Color.green)
.font(.title)
.padding(0)
Path { p in
p.move(to: CGPoint(x: 50, y: 0))
p.addLines([
CGPoint(x: 100, y: 75),
CGPoint(x: 0, y: 75),
CGPoint(x: 50, y: 0)
])
}
}
.border(Color.red)
}
布局流程解析:
- 首先
VStack
会将整个屏幕尺寸建议给它的子Views
,这里就是Text
和Path
, -
Text
在显示完自己的内容后上报自己所需要的尺寸,Path
就比较特别,上面我们已经说到它总会将建议的尺寸作为实际尺寸返回,所以这里它会返回建议尺寸,那么建议尺寸是什么?其实就是当初VStack
建议的尺寸减去Text
所用的尺寸后的尺寸,因为VStack
是尽可能占用更多的空间的,所以VStack
收到了Text
和Path
上报的尺寸后就确定了自己的尺寸,上图中红色边框显示的区域。
如果Path所建议的某个方向的值为nil,那么它返回的默认值为10,采用.fixedSize可以把建议尺寸设置为nil
var body: some View {
VStack(spacing:0) {
Text("hello there, this is a long line that won't fit parent's size.")
.frame(width: 200, height:30)
.border(Color.green)
.font(.title)
.padding(0)
Path { p in
p.move(to: CGPoint(x: 50, y: 0))
p.addLines([
CGPoint(x: 100, y: 75),
CGPoint(x: 0, y: 75),
CGPoint(x: 50, y: 0)
])
}
.fixedSize(horizontal: false, vertical: true)
}
.border(Color.red)
}
上面的代码只是利用fixedsize
忽略了Path
竖直方向的建议尺寸,那么Path
在此方向的上报尺寸则会忽略父View
的建议尺寸和直接返回10
,所以VStack
收到的Path
的高度尺寸为10
,所以红色边框的区域在高度方向会如下所示,Path
绘制在了VStack
的外面。
Shape
和Path
一样 Shape
也总会将建议的尺寸进行返回,在某个方向上建议尺寸为nil
时返回默认值10
,Shape
会尽可能的将自身绘制在建议尺寸甚至填满建议尺寸,像是 Rectangle
、Circle
、Ellipse
和 Capsule
这些内建的形状,会将它们自身绘制在建议尺寸中。那些没有高宽比约束的形状,像是 Rectangle
,会选择填满整个可用的空间,在布局过程中,Shape
会接收到 path(in:)
的调用,其中的 rect
参数所包含的尺寸正是建议尺寸。
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
return Path { p in
p.move(to: CGPoint(x: rect.midX, y: rect.minY))
p.addLines([
CGPoint(x: rect.maxX, y: rect.maxY),
CGPoint(x: rect.minX, y: rect.maxY),
CGPoint(x: rect.midX, y: rect.minY)
])
}
}
}
Frame的使用
方式一
在使用
Frame
的过程中很容易以为Frame
设置的多大,View
就会占据多少空间,其实Frame
改变的只是建议尺寸,具体View
的实际尺寸是要看View
怎么来绘制自己的,上面我们已经介绍了基础的View
,可以发现基础View
在收到建议尺寸时会有不同的表现形式,有的是尊重建议尺寸,有的则填充建议尺寸,还有的甚至可以在建议尺寸外面绘制自己。
Frame
一共有二个初始化的方法:
func frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center)
func frame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center)
在使用时参数是可变的,可以只设置width
或者height
,也可以同时设置,其中关于aligment
的内容请看我的另外一篇文章。
struct ExampleView: View {
@State private var width: CGFloat = 50
var body: some View {
VStack {
SubView()
.frame(width: self.width, height: 120)
.border(Color.blue, width: 2)
Text("Offered Width \(Int(width))")
Slider(value: $width, in: 0...200, step: 1)
}
}
}
struct SubView: View {
var body: some View {
GeometryReader { proxy in
Rectangle()
.fill(Color.yellow.opacity(0.7))
.frame(width: max(proxy.size.width, 120), height: max(proxy.size.height, 120))
}
}
}
代码解读
- 首先
VStack
会将整个屏幕尺寸建议给它的子View
,其中包括SubView
,Text
和Slider
,当然这里涉及到分配原则,比如有多的空间和空间不足时该如何分配的问题,这里我们先不谈(后面会出文章专门聊这个)。 -
SubView
经过.frame
修饰器修饰过后收到的建议宽度尺寸为width
变量,当我们滑动Slider
时会改变width
变量的值。 - 当
width
变量的值小于120
时,在SubView
内部利用GeometryReader
读取了这个width
变量的建议宽度,由于采用max
取的是最大值,所以SubView
内部的Rectangle
会收到建议尺寸的宽度是120
。 - 上面我们已经讲过,对于
Shape
来说,Rectangle
会在指定了建议尺寸时会填满建议尺寸的,所以就算当width变量的值小于120
,由于Rectangle
填满了宽度120
的尺寸,并将当初建议的宽度120
尺寸直接返回了,所以SubView
的宽度也为120
了。 - 当
width
变量大于120
时,也很好理解,Rectangle
依然填满建议尺寸,所以SubView
会跟着变宽。
上面的整个布局流程是一层层由外到内,然后由内到外确定的,这种思想和
UIKit
是有很大的不同的,只有清理的知道SwiftUI
内部的布局流程,才能不会对有些实际出来的布局效果和自己所想有所出入时感到惊讶,上面的例子请好好体会,因为接下来会进阶到更难的。
方式二
上面的例子使用的是Frame
的初始化方法1
,对于初始化方法2
则是指定最小,理想和最大尺寸,其中我们在传值时minimum
,ideal
和maximum
必须按照升序的方式传入,不然会报错,我们可以将任意参数留空,这样它们会使用默认的nil
值,某个方向上的最小值和最大值将作为建议尺寸和返回尺寸的钳位,举例来说,当我们对最大宽度进行了配置时,.frame
修饰器会去检查被建议的宽度,如果这个被建议的宽度超过了设置的最大宽度,那么它只会将最大宽度建议给它的子 view
。类似地,如果子view
返回了一个比最大宽度更大的宽度,那么这个结果也将被钳至最大宽度值,当在某个方向设置了fixedsize
时,此时会采用ideal
尺寸进行布局。
struct ExampleView: View {
@State private var width: CGFloat = 150
@State private var fixedSize: Bool = true
var body: some View {
GeometryReader { proxy in
VStack {
Spacer()
VStack {
LittleSquares(total: 7)
.border(Color.green)
.fixedSize(horizontal: self.fixedSize, vertical: false)
}
.frame(width: self.width)
.border(Color.primary)
.background(MyGradient())
Spacer()
Form {
Slider(value: self.$width, in: 0...proxy.size.width)
Toggle(isOn: self.$fixedSize) { Text("Fixed Width") }
}
}
}.padding(.top, 140)
}
}
struct LittleSquares: View {
let sqSize: CGFloat = 20
let total: Int
var body: some View {
GeometryReader { proxy in
HStack(spacing: 5) {
ForEach(0..<self.maxSquares(proxy), id: \.self) { _ in
RoundedRectangle(cornerRadius: 5).frame(width: self.sqSize, height: self.sqSize)
.foregroundColor(self.allFit(proxy) ? .green : .red)
}
}
.border(Color.orange).frame(height:proxy.size.height)
}.frame(idealWidth: (5 + self.sqSize) * CGFloat(self.total), maxWidth: (5 + self.sqSize) * CGFloat(self.total))
}
func maxSquares(_ proxy: GeometryProxy) -> Int {
return min(Int(proxy.size.width / (sqSize + 5)), total)
}
func allFit(_ proxy: GeometryProxy) -> Bool {
return maxSquares(proxy) == total
}
}
struct MyGradient: View {
var body: some View {
LinearGradient(gradient: Gradient(colors: [Color.red.opacity(0.1), Color.green.opacity(0.1)]), startPoint: UnitPoint(x: 0, y: 0), endPoint: UnitPoint(x: 1, y: 1))
}
}
上面这个例子是一个综合的例子,模拟的是类似Text
的效果,当空间足够时则显示7
个小方格,当空间不够时小方格尽可能的显示,并将颜色变成红色,下面是效果图,建议再看代码截图前,自己尝试理下上述代码的布局流程。
代码解读
- 当
Toggle
没有打开时,fixedSize
这个变量为false
,装着LittleSquares的VStack
容器通过.frame
给LittleSquares
传递了指为变量width
的宽度建议尺寸。 - 由于
fixedSize
为false
, 所以不会启动ideal
尺寸参与布局,在LittleSquares
的内部通过GeometryReader
拿到的proxy
就是上面的变量width
的值,同时把这个值传递给了maxSquares
方法来计算能最大显示方格的数量,封顶数量是7
个,allFit
方法则简单根据显示的数量是否为7
个改变了方格的颜色。 - 注意到
GeometryReader
设置了frame
,当它收到的建议尺寸宽度为nil
时会将对内部HStack
的建议尺寸用idealWidth
代替,此时由于fixedSize
为false
,所以不会使用idealWidth
的值建议给内部的HStack
,而是采用maxWidth
的值,当它收到的建议尺寸的宽度超过了这个maxWidth
的时候它将建议尺寸的宽度钳至maxWidth
的值传递给内部的HStack
,如果小于maxWidth
,则直接把收到的建议宽度尺寸传递给内部的HStack
,同时在收到内部HStack
上报返回的尺寸时,也会利用这个规则,如果超过了maxWidth
则也会被钳至maxWidth
这个值。 - 所以当滑动
Slider
使红色的矩形框变大时,矩形也只会最多显示7
个,但是由于没有设置minWidth
,所以当红色的矩形框变小时则不足以显示7
个,会根据收到的建议尺寸空间尽量的显示更多的方格。 - 当
Toggle
没有打开时,fixedSize
这个变量为true
,GeometryReader
将idealWidth
传递给了内部的HStack
,所以始终显示7
个方格。
总结
本文主要是介绍了SwiftUI
的布局思想,并介绍了几种常见的基本View
的布局原则,同时介绍了.frame
修饰器的内部实现原理,同时利用实际例子进行了演示,只有理解SwiftUI
的布局思想,才不会对意想不到的布局效果感到意外。
网友评论