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. 跨层级对齐。
相关推荐
struggle202515 小时前
Ollmao (OH-luh-毛程序包及源码) 是一款原生 SwiftUI 应用程序,它与 Ollama 集成,可在 Mac 上本地运行强大的 AI 模型
ios·swiftui·swift
货拉拉技术1 个月前
货拉拉用户端SwiftUI踩坑之旅
ios·swiftui·swift
ZacJi1 个月前
巧用 allowsHitTesting 自定义 SignInWithAppleButton
ios·swiftui·swift
刘争Stanley1 个月前
SwiftUI 是如何改变 iOS 开发游戏规则的?
ios·swiftui·swift
1024小神1 个月前
在swiftui中使用Alamofire发送请求获取github仓库里的txt文件内容并解析
ios·github·swiftui
大熊猫侯佩1 个月前
SwiftUI 撸码常见错误 2 例漫谈
swiftui·xcode·tag·tabview·preview·coredata·fetchrequest
东坡肘子2 个月前
肘子的 Swift 周报 #063|异种肾脏移植取得突破
swiftui·swift·apple
恋猫de小郭2 个月前
什么?Flutter 可能会被 SwiftUI/ArkUI 化?全新的 Flutter Roadmap
flutter·ios·swiftui
靴子学长2 个月前
iOS + watchOS Tourism App(含源码可简单复现)
mysql·ios·swiftui
hxx2212 个月前
iOS swift开发系列--如何给swiftui内容视图添加背景图片显示
ios·swiftui·swift