使用 SwiftUI 实现多层画布布局

添加多图层下的安全边距

在构建类似多图层的应用时,我们可能需要确保所有内容均在背景层的安全区域内部,因此需要为背景层添加安全边距 —— 类似于 Canva 或 Figma 中的效果。

类似于下面这样的效果:

  • 设置背景层横向、纵向的 Padding
  • 确保子组件内容不超出 Padding

使用 SwiftUI 实现的方式非常简单:

  • 创建一个 VStack(或者使用 Group),在 VStack 上添加 Padding。不使用 ZStack
  • 如果要修改背景颜色,在 VStack 上使用 .background 进行填充。

需要特别注意的是,在 iOS 平台,可以使用固定的 Padding。但是在 Mac 平台上,因为窗口是动态变化的,因此推荐基于窗口宽度、高度动态计算 Padding,例如:

.padding(
    .vertical, canvas.height * PageConfig.Layout.verticalPadding
)
.padding(
    .horizontal, canvas.width * PageConfig.Layout.horizontalPadding
)

让画布中心对齐

// 添加 ZStack 作为主容器,确保内容居中
ZStack {
    // 背景色(可选)
    Color(uiColor: .systemGroupedBackground)
        .ignoresSafeArea()

    VStack(这是画布)
}

使用 AnyLayout 动态切换布局样式

如果需要根据条件判断,动态切换 HStack 和 VStack。

一种方式是使用 if/else 来判断,但这样有两个问题:

  • 造成代码冗余。
  • if else 会涉及视图的销毁和重建,这会导致不必要的性能开销,而且在动画过渡上也会有影响。

从 iOS16 开始,SwiftUI 新推出了 AnyLayout 组件。

Compose custom layouts with SwiftUI - WWDC22 - Videos - Apple Developer
SwiftUI now offers powerful tools to level up your layouts and arrange views for your app’s interface. We’ll introduce you to the Grid…
SwiftUI AnyLayout - smooth transitions between layout types | Sarunw
In iOS 16, SwiftUI got a new tool, AnyLayout, that makes it possible to transition between layouts while maintaining the identity of the views.
注意:使用 HStackLayoutVStackLayout,而不是 HStackVStack。否则 Xcode 无法编译通过。
let layout =
    isHorizontal
    ? AnyLayout(HStackLayout())
    : AnyLayout(VStackLayout())

使用 Layout 协议实现复杂的自定义布局

Layout 协议基础

Layout 协议是 SwiftUI 提供的一个强大的自定义布局协议,它允许我们创建完全自定义的布局行为。

主要包含两个必须实现的方法:

  • func sizeThatFits()

sizeThatFits 方法负责计算子视图所需的大小(宽度和高度),并返回该大小。它根据父视图传入的约束来决定如何调整子视图的大小。

  • func placeSubviews()

placeSubviews 方法负责在布局中根据给定的边界和计算的大小来放置子视图。它会使用 sizeThatFits 返回的大小,将每个子视图放置到适当的位置。

protocol Layout {
    // 1. 计算布局大小
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Self.Cache
    ) -> CGSize

    // 2. 放置子视图
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Self.Cache
    )
}

sizeThatFits 和 placeSubviews 的执行顺序和关系

sizeThatFits 先执行

  • 主要职责:确定整个布局的大小
  • 返回:布局所需的总体尺寸(CGSize)
  • 时机:在布局过程的计算阶段

placeSubviews 后执行

  • 主要职责:确定每个子视图的具体位置
  • 使用:使用 sizeThatFits 返回的尺寸作为可用空间
  • 时机:在实际渲染阶段

sizeThatFits()

sizeThatFits() 是 Layout 协议的一个必需方法,用于确定布局所需的尺寸

下面这个 sizeThatFits 方法,实际上实现了

func sizeThatFits(
      proposal: ProposedViewSize, subviews: Subviews, cache: inout ()
  ) -> CGSize {
      // 如果没有子视图(subviews 为空),直接返回 CGSize.zero(宽度和高度都为0)。这是一个安全检查,避免对空视图进行不必要的计算。
      guard !subviews.isEmpty else { return .zero }
      return proposal.replacingUnspecifiedDimensions()
  }

placeSubviews()

placeSubviews()方法,主要通过

  • subviews[0].place()方法来设置每个子视图的位置和大小。
  • 设置锚点(anchor)来控制对齐方式。

使用示例:指定比例分配空间

实现此目的的核心代码如下:

通过 bounds 获取父组件的尺寸和位置

  • bounds.width: 父组件提供的总宽度
  • bounds.height: 父组件提供的总高度
  • bounds.minX: 起始 X 坐标(通常是 0)
  • bounds.minY: 起始 Y 坐标(通常是 0)
  • bounds.midX: 水平中心点
  • bounds.midY: 垂直中心点

然后,通过 proposal 参数为每个 subview 分配 widthheight

使用示例:指定对齐方式

可以通过结合使用 atanchor 参数设置对齐方式:

使用示例:填满 subview 最大空间

在 Layout 中,实际上不存在所谓的——填充最大空间。

在下面这个代码中,我们通用设置 proposal 的 width 和 height 显式设置了容器的尺寸,但仍然需要在子容器中通过使用 .scaleToFit 这样的修饰器来让子组件填充。

动态调整字体大小

minimumScaleFactor

最小 Scale Factor (缩放因子) 为 0.5 的标签以实际字体的一半的字体大小绘制其文本(如果需要),以适合文本输入字段旁边的空间。

HStack {
    Text("This is a very long label:")
        .lineLimit(1)
        .minimumScaleFactor(0.5)
    TextField("My Long Text Field", text: $myTextField)
        .frame(width: 250, height: 50, alignment: .center)
}

dynamicTypeSize