SwiftUI 初次实战—布局总结

1. HStack和LazyHStack

  1. 这两者的区别从名字可以看出来后者是懒加载的,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)
  1. 其他场景下不能使用 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可以撑开?首先记住以下法则(这个法则各文章都有说,一定要理解并牢记,后续写布局代码时或遇到任何布局问题时就想一下法则):

布局法则

  1. 父 view 为子 view 提供一个建议的 size,询问子视图的大小
  2. view 根据自身的特性,返回一个 size
  3. 父 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 的对齐行为。

  1. 给可以一组 Stack 中的某个 View 做特殊对齐:

.alignmentGuide 闭包中的 dim 是 ViewDimensions 类型,可以获取这个 View 的信息,例如宽、高、.leading 等。

  1. 跨层级的 Stack 中的 某个 View 自定义对齐行为:

处理前:

处理后:

最后:布局总结

  1. 每个 HStack、VStack、ZStack 看作一个**整体**;
  2. 使用 leading、trailing、top、bottom、Spacer,做**填充布局**;
  3. 时时牢记心法------布局法则
  4. 使用 .position、.offset 调整位置;
  5. modifier 先后 顺序 是有逻辑的;
  6. 获取 View 信息;
  7. 跨层级对齐。
相关推荐
今天也想MK代码3 天前
在Swift开发中简化应用程序发布与权限管理的解决方案——SparkleEasy
前端·javascript·chrome·macos·electron·swiftui
東三城8 天前
【ios】---SwiftUI开发从入门到放弃
ios·swiftui·swift·1024程序员节
今天也想MK代码10 天前
基于swiftui 实现3D loading 动画效果
ios·swiftui·swift
胖虎111 天前
SwiftUI(五)- ForEach循环创建视图&尺寸类&安全区域
ios·swiftui·swift·foreach·安全区域
zyosasa17 天前
SwiftUI 精通之路 11: 栅格布局
前端·swiftui·swift
小溪彼岸21 天前
【iOS小组件实战】灵动岛实时进度通知
swiftui·swift
提笔忘字的帝国25 天前
【ios】SwiftUI 混用 UIKit 的 Bug 解决:UITableView 无法滚动到底部
swiftui·bug·xcode
zyosasa25 天前
SwiftUI 精通之路 09:ForEach 视图构造器的基础应用
swiftui·swift
提笔忘字的帝国1 个月前
【ios】在 SwiftUI 中实现可随时调用的加载框
ios·swiftui·xcode·swift
小溪彼岸1 个月前
【iOS小组件】小组件App ID、Group ID、描述文件
swiftui·swift