1. HStack和LazyHStack
- 这两者的区别从名字可以看出来后者是懒加载的,LazyHStack 是在 HStack 上做加载的优化。常用场景为 ScrollView + LazyHStack 控件:
- ScrollView + HStack:未滚动到的控件也全部加载了;
- ScrollView + LazyHStack:滚动到屏幕范围内的才会加载。
(VStack 和 LazyVStack 同理。)
示例代码(LazyHStack 改为 HStack,分别滚动时看下 print 输出):
scss
ScrollView(.horizontal) {
LazyHStack {
ForEach(1...10, id: \.self) { count in
Text("Count \(count)")
.onAppear {
print("LazyHStack count: \(count)")
}
}
}
}
.frame(height: 100)
.background(Color.green)
- 其他场景下不能使用 LazyHStack 代替 HStack,表现上还是有区别的。
例如以下想要 leading 20 的效果,LazyHStack 的表现仍然是居中:
改成使用 HStack 就是想要的效果了:
2. 自定义Shape
开发中常需要给 View 设置圆角。SwiftUI 现有的控件不能直接达到效果,使用方法如下:
swift
RoundedRectangle(cornerRadius: 20, style: .circular)
.fill(Color.yellow)
.frame(width: 200, height: 100)
但是我们常需要设置局部圆角,可以自定义如下形状:
swift
public struct PERoundedRectangle: Shape {
public var radius: CGFloat = .infinity
public var corners: UIRectCorner = .allCorners
public func path(in rect: CGRect) -> Path {
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
return Path(path.cgPath)
}
}
// 使用示例
PERoundedRectangle(radius: 20, corners: [.topLeft, .topRight])
.fill(Color.yellow)
.frame(width: 200, height: 100)
给 View 添加扩展方法更方便使用:
swift
extension View {
public func jk_cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(PERoundedRectangle(radius: radius, corners: corners))
}
}
// 使用示例
Rectangle()
.fill(Color.yellow)
.jk_cornerRadius(20, corners: [.topLeft, .topRight])
.frame(width: 200, height: 100)
再比如常用的画线:
php
public enum PELineDirection {
case horizontal, vertical
}
public struct PELine: Shape {
public var direction: PELineDirection
public init(direction: PELineDirection) {
self.direction = direction
}
public func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 0, y: 0))
if direction == .horizontal {
path.addLine(to: CGPoint(x: rect.width, y: 0))
} else {
path.addLine(to: CGPoint(x: 0, y: rect.height))
}
return path
}
}
// 使用示例
PELine(direction: .vertical)
.stroke(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundColor(.blue)
.frame(width: 1, height: 200)
3. 使用填充避免间距计算
例如这是我想要的效果:粉色区域内部 leading 20,trailing 10,剩下内容均分靠左
画四条线:
第一想法:既然内部是等间距的,那么使用 HStack(spacing: xSpacing) 填充:
swift
let data = [1, 2, 3, 4]
let yRange = 0..<data.count
let xSpacing = (UIScreen.main.bounds.width - 100 - 30) / CGFloat(data.count)
HStack(alignment: .top, spacing: xSpacing) { // 预想使用宽度为 spacing 撑开的大小
ForEach(yRange, id: \.self) { idx in
HStack {
PELine(direction: .vertical) // 在撑开的大小靠左画线
.stroke(style: idx == 0 ? StrokeStyle(lineWidth: 1) : StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundColor(.blue)
.frame(width: 1)
}
.background(Color(uiColor: .yellow))
}
}
.frame(width: UIScreen.main.bounds.width - 100, height: 200)
.padding(.leading, 20)
.padding(.trailing, 10)
.background(
Rectangle()
.fill(.pink)
)
图1:
swift
let data = [1, 2, 3, 4]
let yRange = 0..<data.count
HStack { // ① 整个大小
ForEach(yRange, id: \.self) { idx in
HStack {
PELine(direction: .vertical) // 线是撑不开的
.stroke(style: idx == 0 ? StrokeStyle(lineWidth: 1) : StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundColor(.blue)
.frame(width: 1)
Spacer() // ② 帮助撑开大小,线才能居该空间的左侧
}
.background(Color(uiColor: .yellow))
}
}
.frame(width: UIScreen.main.bounds.width - 100, height: 200)
.padding(.leading, 20) // 内部前后间距
.padding(.trailing, 10)
.background(
Rectangle()
.fill(.pink)
)
即想要的效果如下:
图2:
思路转换:划分区间等分画线 --> 设置区间 leading 和 trailing,往里添加可撑开的控件(见注释 ②)。
这里还要注意两次黄色区域大小的不同,为什么 Spacer可以撑开?首先记住以下法则(这个法则各文章都有说,一定要理解并牢记,后续写布局代码时或遇到任何布局问题时就想一下法则):
布局法则
- 父 view 为子 view 提供一个建议的 size,
询问子视图的大小
;子
view 根据自身的特性,返回一个 size
;- 父 view
根据子 view 返回的 size
为其进行布局。
根据法则来理解现象:
图1:HStack 中只有一个 PELine,父视图提供建议 size 并询问子视图大小,子视图根据自身返回一个 PELine
的宽度。因此图1的单个黄色区域大小就是 PELine 宽;
图2:HStack 中只有一个 PELine 和 Spacer,此时父视图提供的 size,子视图在 Spacer 的帮助下撑开了
该 size ,因此黄色区域是使用了父视图均分
的建议 size。
4. SwiftUI中的默认间距
VStack/HStack
精确高度时用 VStack(spacing: 0) {},撑开布局时用 VStack {}。
详细解释下,如例3中,Spacer() 是撑开布局,所以和 Line 无间隔,不用写 HStack(spacing: 0)。
(HStack 同理。)
.padding
.padding 是一个 View 对自己的内边距设置的 modifier (调节器,例如 .frame、.background 等都是 SwiftUI View 的 modifier):
5. 位置-offset和position
.offset 相对位置
- .offset 是将**
视图以及已经添加的修饰符
**都进行整体偏移
; - .offset 后的 background 修饰的是
原始位置
; - .offset 接收 CGPoint 或 CGSize 的效果是一样的,无区别。
.position 绝对位置
- .position 是将视图的**
中心
放置在父视图的分配区域(观察第二个 Text 位置)
**上; - .position 后的 background 指的是
分配区域
。
6. 初写时关注视图大小
看个例子:
如图:图1的数据情况下看起来没问题,图2的数据情况下就暴露出了问题,图3给当前视图加上灰色线框。灰色线框才是图表本身应该的大小,实际图1的绘制就发生了错误,超出了实际大小(原因见上述 Frame 的规则,问题出在 View 布局时子视图的大小超出了父视图)。
总之,开始使用的时候不熟悉会出现各种预料之外的 UI 结果,可以加线框或背景色来辅助我们刚开始的 SwiftUI 实践。
7. 获取父View大小-GeometryReader
以上的例子可以看到布局时主要使用填充式布局,尽量避免计算。但是有时候我们也会用到计算高度,比如例6中的柱形图,需要计算高度来表达当前进度。
SwiftUI 中提供了 GeometryReader(几何读取器)
来获取父 View 的大小:
swift
struct PEGroupBar: View {
var model: PEGroupModel
var maxValue: Int
var body: some View {
GeometryReader { geometry in
...
let maxHeight = geometry.size.height // 使用父View高度用来计算
VStack { // 第一个柱形
Spacer(minLength: 0)
Rectangle()
.frame(width: 8, height: model.progress / Double(maxValue) * maxHeight)
}
VStack { // 第二个柱形
Spacer(minLength: 0)
Rectangle()
.frame(width: 8, height: model.recommend / Double(maxValue) * maxHeight)
}
}
}
}
8. 跨层级获取某View信息-Preference
Preference(偏好)
提供了在父 View 中跨层级的获取子 View 或更深层级 View 的任何信息。
如图,子 View 中点击"切换Stack方向"按钮,子 View 中的"icon+Hello World!"的方向关系会从横向和纵向来回切换。父 View 在控制台会打印切换方向的结果。
第一步:定义子 View 给父 View 提供信息的类型
:
swift
struct BoolPreferenceKey: PreferenceKey {
typealias Value = Bool // ①例如我们这里定义了个Bool类型,也可以是一个size、一个class model等。根据实际场景需要
static var defaultValue = false // ②该类型的默认值
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = nextValue() // ③接收到新值后想做的操作,例如也可以是value+=nextValue()等
}
}
第二步:子 View 传递信息
:
scss
struct AnyLayoutView: View {
@State var isVertical = false
var body: some View {
let layout = isVertical ? AnyLayout(VStackLayout(spacing: 5)) : AnyLayout(HStackLayout(spacing: 10))
layout {
Group {
Image(systemName: "globe")
Text("Hello World!")
}
.font(.largeTitle)
}
.border(Color.blue)
.preference(key: BoolPreferenceKey.self, value: isVertical) // ①把当前的方向信息传递出去
Spacer().frame(height: 30)
Button("Toggle Stack") {
withAnimation(.easeInOut(duration: 1.0)) {
isVertical.toggle()
}
}
}
}
第三步:父 View 接收信息
:
swift
struct ContentView: View {
@State var isVertical = false
var body: some View {
VStack {
AnyLayoutView() // ①展示子View
}
.onPreferenceChange(BoolPreferenceKey.self) { // ②接收子View信息
print("AnyLayout is vertical: \($0)")
}
}
}
9. 跨层级对齐AlignmentGuide
AlignmentGuide(对齐指南):配合 Stack 使用,自定义某一个 View 的对齐行为。
- 给可以
一组 Stack 中的某个 View
做特殊对齐:
.alignmentGuide 闭包中的 dim 是 ViewDimensions 类型,可以获取这个 View 的信息,例如宽、高、.leading 等。
- 给
跨层级的 Stack 中的 某个 View 自定义
对齐行为:
处理前:
处理后:
最后:布局总结
- 每个 HStack、VStack、ZStack 看作一个**
整体
**;- 使用 leading、trailing、top、bottom、Spacer,做**
填充布局
**;- 时时牢记心法------
布局法则
;- 使用 .position、.offset 调整位置;
- modifier 先后 顺序 是有逻辑的;
- 获取 View 信息;
- 跨层级对齐。