精通 SwiftUI 的 containerRelativeFrame 修饰器

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

在 WWDC 2023 上,苹果公司为 SwiftUI 引入了 containerRelativeFrame 视图修饰器。这个修饰器使得一些以往难以通过常规方法实现的布局操作变得十分简单。本文将深入探讨 containerRelativeFrame 修饰器,内容涵盖定义、布局规则、使用场景以及相关注意事项。在文章的最后,我们还将创建一个兼容旧版本 SwiftUI 的 containerRelativeFrame 复刻版,通过这一实践加深对其功能的理解。

定义

根据苹果的 官方文档containerRelativeFrame 的功能描述如下:

将视图定位于一个不可见的框架内,该框架的大小与最近的容器相对应。

使用此修饰器可以为视图的宽度、高度或两者指定尺寸,尺寸依赖于最近的容器大小。有多种元素可以充当“容器”,包括:

  • 在 iPadOS 或 macOS 上展示视图的窗口,或 iOS 上的设备屏幕。
  • NavigationSplitView 的列
  • NavigationStack
  • TabView 的标签页
  • 可滚动视图,如 ScrollView 或 List

提供给此修饰器的尺寸是所述容器的尺寸减去可能适用的任何安全区域内嵌。

除了上述定义,官方文档中还包含多段示例代码,以帮助读者更好地理解该修饰器的使用。为了进一步阐述其工作原理,我将根据自己的理解重新对该修饰器的功能进行表述:

containerRelativeFrame 修饰器从其所作用的视图开始,沿视图层次结构向上寻找最近的符合容器列表中的容器。根据开发者设置的变换规则,对该容器提供的尺寸进行计算后,以此作为视图的建议尺寸。从某种意义上讲,它可以视为一个允许自定义变换规则的特殊版本 frame 修饰器。

构造方法

containerRelativeFrame 提供了三种构造方法,适用于不同的布局需求:

  1. 基础版: 使用此构造方法时,修饰器不对容器尺寸进行任何变换,直接将最近容器提供的尺寸作为视图的建议尺寸。

    Swift
    public func containerRelativeFrame(_ axes: Axis.Set, alignment: Alignment = .center) -> some View
  2. 预置参数版: 通过通过此方法,开发者可以指定尺寸的等分数、栏或列的跨越数以及所需考虑的间隔尺寸,从而在指定的轴向上对尺寸进行适当的变换。这种方法特别适合于根据容器的尺寸按特定比例配置视图大小。

    Swift
    public func containerRelativeFrame(_ axes: Axis.Set, count: Int, span: Int = 1, spacing: CGFloat, alignment: Alignment = .center) -> some View
  3. 完全自定义版: 提供最大灵活性的构造方法,允许开发者根据容器尺寸自定义计算逻辑。此方法适用于高度定制的布局需求。

    Swift
    public func containerRelativeFrame(_ axes: Axis.Set, alignment: Alignment = .center, _ length: @escaping (CGFloat, Axis) -> CGFloat) -> some View

这些构造方法为开发者提供了强大的工具,以实现复杂的布局设计,符合不同的界面需求。

关键字详解

为了深入理解 containerRelativeFrame 修饰器的功能,我们将对定义中提到的几个关键词进行详尽分析。

容器列表中的容器

在 SwiftUI 中,通常子视图会直接从父视图处获取建议尺寸。然而,当我们为视图应用 frame 修饰器时,子视图将无视父视图的建议尺寸,而采用 frame 所指定的尺寸作为特定轴向上的建议尺寸。

Swift
VStack {
  Rectangle()
    .frame(width: 200, height: 200)
    // 其他视图
    ...
}
.frame(width: 400, height: 500)

例如,在 iPhone 上运行时,如果我们希望 Rectangle 的高度是屏幕可用高度的一半,我们可以使用以下逻辑来实现:

Swift
var screenAvailableHeight: CGFloat // 通过某种手段获取屏幕的可用高度

VStack {
  Rectangle()
    .frame(width: 200, height: screenHeight / 2)
    // 其他视图
    ...
}
.frame(width: 400, height: 500)

containerRelativeFrame 出现之前,我们只能通过 GeometryReaderUIScreen.main.bounds 等方法获取屏幕的尺寸。现在,我们可以更简便地实现相同的效果:

Swift
@main
struct containerRelativeFrameDemoApp: App {
  var body: some Scene {
    WindowGroup {
      VStack {
        Rectangle()
          // 对纵轴数据除二并返回
          .containerRelativeFrame(.vertical){ height,_ in height / 2}
      }
      .frame(width: 400, height: 500)
    }
  }
}

或者

Swift
@main
struct containerRelativeFrameDemoApp: App {
  var body: some Scene {
    WindowGroup {
      VStack {
        Rectangle()
          // 对纵轴尺寸进行两等分,不跨行,无需考虑间隔
          .containerRelativeFrame(.vertical, count: 2, span: 1, spacing: 0)
      }
      .frame(width: 400, height: 500)
    }
  }
}

在上面的代码中,Rectangle() 将无视 VStack 提供的 400 x 500 的建议尺寸,而是直接向上在视图层次结构中寻找符合条件的容器。在上面的例子里,合适的容器是 iOS 设备上的屏幕

也就是说,containerRelativeFrame 提供了一种跨越视图层次来获取容器尺寸的能力。但是它只能获取存在于容器列表中特定容器所提供的尺寸( 窗口、ScrollViewTabViewNavigationStack 等 )。

最近

在视图层次结构中,如果存在多个符合条件的容器,containerRelativeFrame 将选择距当前视图最近的容器,并使用其尺寸进行计算。例如,在以下代码示例中,Rectangle 的最终高度为 100,这是因为它采用了 NavigationStack 的高度(200),并将其除以 2,而不是使用屏幕的可用高度的一半。

Swift
@main
struct containerRelativeFrameDemoApp: App {
  var body: some Scene {
    WindowGroup {
      NavigationStack {
        VStack {
          Rectangle() // height is 100
            .containerRelativeFrame(.vertical) { height, _ in height / 2 }
        }
        .frame(width: 400, height: 500)
      }
      .frame(height: 200) // NavigationStack's height is 200
    }
  }
}

这表明,使用 containerRelativeFrame 修饰器时,其将根据其所处位置向上查找最接近的容器,并获取其提供的尺寸。在设计可复用视图时,特别需要注意此行为,因为同一段代码可能因位置不同而导致不同的布局效果。

此外,在容器列表中的容器对应的 overlaybackground 视图中使用 containerRelativeFrame 时也需特别留心。此时,containerRelativeFrame 在向上寻找最近容器时会忽视当前容器。这种行为与一般的 overlaybackground 视图的行为有所不同。

通常情况下,视图及其 overlaybackground 视图被视为主从关系。欲了解更多,请阅读 深入探索 SwiftUI 中的 Overlay 和 Background 修饰器

考虑以下示例,我们在 NavigationStack 上应用了一个 overlay,该 overlay 包含一个 Rectangle 并使用 containerRelativeFrame 来设置其高度。此时,containerRelativeFrame 将不会使用 NavigationStack 的高度,而是寻找更上层的容器尺寸——在这个案例中是屏幕的可用大小。

Swift
@main
struct containerRelativeFrameDemoApp: App {
  var body: some Scene {
    WindowGroup {
      NavigationStack {
        VStack {
          Rectangle()
        }
        .frame(width: 400, height: 500)
      }
      .frame(height: 200) // navigationStack's height is 200
      .overlay(
        Rectangle()
          .containerRelativeFrame(.vertical) { height, _ in height / 2 } // screen available height / 2
      )
    }
  }
}

变换规则

containerRelativeFrame 提供的构造方法中,有两种方法允许进行尺寸的动态变换。其中,第三种构造方法提供了最大的灵活性:

Swift
public func containerRelativeFrame(_ axes: Axis.Set, alignment: Alignment = .center, _ length: @escaping (CGFloat, Axis) -> CGFloat) -> some View

该方法的 length 闭包将同时适用于两个不同的轴向,使得我们可以根据轴向的不同来返回不同的尺寸计算结果。例如,在下面的代码中,Rectangle 的宽度设置为最近容器可用宽度的 2/3,高度为可用高度的 1/2

Swift
Rectangle()
  .containerRelativeFrame([.horizontal,.vertical]) { length, axis in
    if axis == .vertical {
      return length / 2
    } else {
      return length * ( 2 / 3)
    }
  }

对于没有在构造方法的 axes 参数中指定的轴向,containerRelativeFrame 将不会对该轴向的尺寸进行设置( 保留原父视图给出的建议尺寸)。

Swift
struct TransformsDemo: View {
  var body: some View {
    VStack {
      Rectangle()
        .containerRelativeFrame(.horizontal) { length, axis in
          if axis == .vertical {
            return length / 2 // 此处代码不会执行,因为没有在 axes 中设置 .vertical
          } else {
            return length * (2 / 3)
          }
        }
    }.frame(height: 100)
  }
}

在上述代码中,Rectangle 的宽度设置为最近容器可用宽度的 2/3,而高度则为 100( 与父视图 VStack 的高度一致 )。

对于构造方法二的详细说明,我们将在下一节中进一步探讨。

容器提供的尺寸( 容器的可用尺寸 )

在官方文档中,对于由 containerRelativeFrame 修饰器使用的尺寸的描述是:“提供给此修饰器的尺寸是所述容器的尺寸,减去可能适用的任何安全区域内嵌”。这一描述在原则上是正确的,但在具体实施到不同容器时,存在一些需要特别注意的细节:

  • NavigationSplitView 中使用时,containerRelativeFrame 获得的尺寸是当前所处列(SideBarContentDetail)的尺寸。除了必须考虑安全区域的减除外,对于顶部区域,还需要去除工具栏的高度(navigationBarHeight)。然而,在 NavigationStack 中使用时,工具栏的高度则不会被去除。
  • TabView 使用 containerRelativeFrame 时,其计算的高度为 TabView 的总高度减去上方安全区域和下方 TabBar 的高度。
  • ScrollView 中使用 containerRelativeFrame 时,如果开发者已通过 safeAreaPadding 增加了 padding,则 containerRelativeFrame 也会将这些 padding 值去除。
  • 在支持多窗口的环境中( iPadOS、macOS ),根容器尺寸对应的是当前视图所在窗口的可用尺寸。
  • 虽然官方文档指出 containerRelativeFrame 可以用于 List,但根据其在 Xcode Version 15.3 (15E204a) 的实际表现,此修饰器尚无法在 List 中正确计算尺寸。

用法举例

在掌握了 containerRelativeFrame 的原理后,开发者可以利用此修饰器来实现许多以前无法或很难完成的布局操作。本章中,我们将展示几个具有代表性的示例。

按滚动区域的尺寸,创建等分的画廊

这是许多介绍 containerRelativeFrame 用法的文章最常展示的场景。考虑到下面的需求:我们需要构建一个横向滚动的画廊布局,类似于 App Store 或 Apple Music 的风格,希望每个子视图(图片)的宽度为可滚动区域的 1/3,高度则为宽度的 2/3

通常,如果不使用 containerRelativeFrame,开发者可能会采用在 SwiftUI geometryGroup() 指南:从原理到实践 中介绍的方法,通过给 ScrollView 添加 background 并在其中获取其尺寸,然后通过某种方式传递尺寸信息以设置子视图的具体尺寸。这意味着我们无法仅通过对子视图的操作来达到这一目的,而必须先获取 ScrollView 的尺寸。

使用 containerRelativeFrame 的第二种构造方法,可以轻松应对此需求:

Swift
struct ScrollViewDemo:View {
  var body: some View {
    ScrollView(.horizontal) {
      HStack(spacing: 10) {
        ForEach(0..<10){ _ in
          Rectangle()
            .fill(.purple)
            .aspectRatio( 3 / 2, contentMode: .fit)
             // 横向三等分,子视图不跨列( 也就是宽度的 1/3 ),无需考虑视图间隔 
            .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 0)
        }
      }
    }
  }
}

image-20240505181749569

细心的读者可能会发现,由于 HStack 本身设置了 spacing: 10,导致第三个视图(最右侧)会显示不完整,有一小部分不会直接显示在滚动区域中。如果希望在设置子视图的宽度时同时考虑 HStackspacing: 10,那么就需要在 containerRelativeFrame 中也设置相应的视图间隔因素。这样,尽管每个子视图的尺寸已经小于 ScrollView 可视区域宽度的 1/3 ,但考虑上 spacing,在首屏时我们恰好可以看到三个完整的视图。

Swift
struct ScrollViewDemo:View {
  var body: some View {
    ScrollView(.horizontal) {
      HStack(spacing: 10) {
        ForEach(0..<10){ _ in
          Rectangle()
            .fill(.purple)
            .aspectRatio( 3 / 2, contentMode: .fit)
            .border(.yellow,width: 3)
            // 增加在计算时需要考虑的 spacing 因素
            .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 10)
        }
      }
    }
  }
}

containerRelativeFrame 中,spacing 参数与 VStackHStack 等布局容器中的 spacing 不同。它并不直接添加间隔,而是用于在构造方法二中为变换规则增加需要考虑的 spacing 因素。

官方文档详细解释了 countspan、和 spacing 在变换规则中的作用,以宽度计算为例:

Swift
let availableWidth = (containerWidth - (spacing * (count - 1)))
let columnWidth = (availableWidth / count)
let itemWidth = (columnWidth * span) + ((span - 1) * spacing)

需要注意的是,由于 ScrollView 的布局特性(在滚动方向上会使用全部的建议尺寸,而在非滚动方向上则由子视图的需求尺寸决定其自身的需求尺寸),因此,在 ScrollView 中使用 containerRelativeFrame 时,axes 参数至少应包含滚动方向上的尺寸处理(除非子视图已提供了明确的需求尺寸)。否则,可能会导致异常。例如,下面的代码在大多数情况下可能会导致应用崩溃:

Swift
struct ScrollViewDemo:View {
  var body: some View {
    ScrollView {
      HStack(spacing: 10) {
        ForEach(0..<10){ _ in
          Rectangle()
            .fill(.purple)
            .aspectRatio( 3 / 2, contentMode: .fit)
            .border(.yellow,width: 3)
            // 计算方向与滚动方向不一致
            .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 0)
        }
      }
    }
    .border(.red)
  }
}

注意:由于 LazyHStackHStack 的布局逻辑存在差异,使用 LazyHStack 替代 HStack 会导致 ScrollView 占据所有可用空间,这可能与预期布局不符( 官方文档的示例代码使用 LazyHStack)。在需要使用 LazyHStack 的场景中,一个更好的选择可能是通过 GeometryReader 来获取 ScrollView 的宽度,并据此计算高度,以确保布局符合预期。

按比例设置大小

当需要的尺寸比例不规则时,允许完全自定义变换规则的第三种构造方法将更加适用。考虑以下场景:我们需要在一个容器中(如 NavigationStackTabView)显示一段文字,并为其设置一个由两种颜色组成的背景,上部是蓝色,下部是橙色,两者的分割点位于容器高度的黄金分割点(0.618)处。

如果不使用 containerRelativeFrame,我们可能会采用如下方式来实现这一效果:

Swift
struct SplitDemo:View {
  var body: some View {
    NavigationStack {
      ZStack {
        Color.blue
          .overlay(
            GeometryReader { proxy in
              Color.clear
                .overlay(alignment:.bottom){
                  Color.orange
                    .frame(height:proxy.size.height * ( 1 - 0.618))
                }
            }
          )
        Text("Hello World")
          .font(.title)
          .foregroundStyle(.yellow)
      }
    }
  }
}

image-20240505190535855

使用 containerRelativeFrame 后,我们的实现逻辑将完全不同:

Swift
struct SplitDemo: View {
  var body: some View {
    NavigationStack {
      Text("Hello World")
        .font(.title)
        .foregroundStyle(.yellow)
        .background(
          Color.blue
            // 蓝色占据全部容器的可用空间
            .containerRelativeFrame([.horizontal, .vertical])
            .overlay(alignment: .bottom) {
              Color.orange
                // 橙色的高度为 容器高度 x ( 1 - 0.618 ),且与蓝色在底部对齐
                .containerRelativeFrame(.vertical) { length, _ in
                  length * (1 - 0.618)
                }
            }
        )
    }
  }
}

若希望蓝色和橙色背景能扩展至安全区域外,可以通过添加 ignoresSafeArea 修饰符来实现:

Swift
NavigationStack {
  Text("Hello World")
    .font(.title)
    .foregroundStyle(.yellow)
    .background(
      Color.blue
        .ignoresSafeArea()
        .containerRelativeFrame([.horizontal, .vertical])
        .overlay(alignment: .bottom) {
          Color.orange
            .ignoresSafeArea()
            .containerRelativeFrame(.vertical) { length, _ in
              length * (1 - 0.618)
            }
        }
    )
}

GeometryReader:好东西还是坏东西? 一文中,我们探讨了如何使用 GeometryReader 按一定比例在特定空间中放置两个视图的方法。尽管 containerRelativeFrame 仅支持获取特定容器的尺寸,我们仍然可以通过一定的技巧使其满足类似的布局需求。

以下是使用 GeometryReader 实现的示例代码:

Swift
struct RatioSplitHStack<L, R>: View where L: View, R: View {
    let leftWidthRatio: CGFloat
    let leftContent: L
    let rightContent: R
    init(leftWidthRatio: CGFloat, @ViewBuilder leftContent: @escaping () -> L, @ViewBuilder rightContent: @escaping () -> R) {
        self.leftWidthRatio = leftWidthRatio
        self.leftContent = leftContent()
        self.rightContent = rightContent()
    }

    var body: some View {
        GeometryReader { proxy in
            HStack(spacing: 0) {
                Color.clear
                    .frame(width: proxy.size.width * leftWidthRatio)
                    .overlay(leftContent)
                Color.clear
                    .overlay(rightContent)
            }
        }
    }
}

而在使用 containerRelativeFrame 的版本中,我们可以利用一个 ScrollView 来提供尺寸,而不实际启用其滚动功能:

Swift
struct RatioSplitHStack<L, R>: View where L: View, R: View {
  let leftWidthRatio: CGFloat
  let leftContent: L
  let rightContent: R
  init(leftWidthRatio: CGFloat, @ViewBuilder leftContent: @escaping () -> L, @ViewBuilder rightContent: @escaping () -> R) {
    self.leftWidthRatio = leftWidthRatio
    self.leftContent = leftContent()
    self.rightContent = rightContent()
  }

  var body: some View {
    ScrollView(.horizontal) {
      HStack(spacing: 0) {
        Color.clear
          .containerRelativeFrame(.horizontal) { length, _ in length * leftWidthRatio }
          .overlay(leftContent)
        Color.clear
          .overlay(rightContent)
          .containerRelativeFrame(.horizontal) { length, _ in length * (1 - leftWidthRatio) }
      }
    }
    .scrollDisabled(true) // 使用 ScrollView 仅作为尺寸提供者,禁用滚动
  }
}

struct RatioSplitHStackDemo: View {
    var body: some View {
        RatioSplitHStack(leftWidthRatio: 0.25) {
            Rectangle().fill(.red)
        } rightContent: {
            Color.clear
                .overlay(
                    Text("Hello World")
                )
        }
        .border(.blue)
        .frame(width: 300, height: 60)
    }
}

image-20240505193128775

作为子视图跨视图层次获取容器尺寸的手段

containerRelativeFrame 的一个重要特性是能够直接获取视图层次中最近的符合条件的容器可用尺寸。这种能力特别适合于构建包含独立逻辑的子视图或视图修改器,这些组件需要感知其所在容器的可用尺寸,但并不希望像使用 GeometryReader 那样可能破坏当前的视图布局。

以下示例展示了如何构建一个 ViewModifier ContainerSizeGetter,其目的是获取并传递其所在容器(存在于容器列表中)的可用尺寸:

Swift
// 保存获取的尺寸,避免造成在视图刷新周期中更新数据
class ContainerSize {
  var width: CGFloat? {
    didSet {
      sendSize()
    }
  }

  var height: CGFloat? {
    didSet {
      sendSize()
    }
  }

  func sendSize() {
    if let width, let height {
      publisher.send(.init(width: width, height: height))
    }
  }

  var publisher = PassthroughSubject<CGSize, Never>()
}

// 获取最近容器的可用尺寸,并传递给调用者
struct ContainerSizeGetter: ViewModifier {
  @Binding var size: CGSize?
  @State var containerSize = ContainerSize()
  func body(content: Content) -> some View {
    content
      .overlay(
        Color.yellow
          .containerRelativeFrame([.vertical, .horizontal]) { length, axes in
            if axes == .vertical {
              containerSize.height = length
            } else {
              containerSize.width = length
            }
            return 0
          }
      )
      .onReceive(containerSize.publisher) { size in
        self.size = size
      }
  }
}

extension View {
  func containerSizeGetter(size: Binding<CGSize?>) -> some View {
    modifier(ContainerSizeGetter(size: size))
  }
}

这个 ViewModifier 利用 containerRelativeFrame 来测量并更新容器的尺寸,同时通过 PassthroughSubject 将尺寸变化通知给外部绑定的 size 属性。这种方法的优势在于其不破坏原有视图的布局,仅作为尺寸的监听和传递工具。

构建 containerRelativeFrame 的复刻版本

在博客的布局相关文章中,我经常会尝试构建布局容器的复刻版本。这种做法不仅有助于深入理解容器的布局机制,还能验证对某些布局逻辑的猜测。此外,在条件允许的情况下,这些复刻版本还可以应用于更低版本的 SwiftUI(如 iOS 13+)。

为了简化复刻的工作量,当前版本仅支持 iOS。完整的代码可在 此处 查看。

获取最近的容器

官方 containerRelativeFrame 可能通过以下两种方式获取最近容器的尺寸:

  • 通过环境值机制让容器向下发送自己的尺寸。
  • 允许 containerRelativeFrame 自主向上查找最近的容器并获得尺寸。

考虑到第一种方式会增加系统负担(即使没有使用 containerRelativeFrame,容器也会不断发送尺寸变化),并且我们难以为不同容器设计准确的尺寸传递逻辑,我们的复刻版本选择了第二种方法——自主向上查找最近的容器。

Swift
extension UIView {
  fileprivate func findRelevantContainer() -> ContainerType? {
    var responder: UIResponder? = self

    while let currentResponder = responder {
      if let viewController = currentResponder as? UIViewController {
        if let tabview = viewController as? UITabBarController {
          return .tabview(tabview) // UITabBarController
        }
        if let navigator = viewController as? UINavigationController {
          return .navigator(navigator) // UINavigationController
        }
      }
      if let scrollView = currentResponder as? UIScrollView {
        return .scrollView(scrollView) // UIScrollView
      }
      responder = currentResponder.next
    }

    if let currentWindow {
      return .window(currentWindow) // UIWindow
    } else {
      return nil
    }
  }
}

private enum ContainerType {
  case scrollView(UIScrollView)
  case navigator(UINavigationController)
  case tabview(UITabBarController)
  case window(UIWindow)
}

我们通过为 UIView 添加了一个扩展方法 findRelevantContainer ,就可以获得距离当前视图( UIView )最近的特定容器。

计算容器提供的尺寸

获取最近容器后,需要根据容器类型去除安全区域、TabBar 高度、navigationBarHeight 等尺寸,并通过监听 frame 属性来动态响应尺寸变化:

Swift
@MainActor
class Coordinator: NSObject, ObservableObject {
  var size: Binding<CGSize?>
  var cancellable: AnyCancellable?

  init(size: Binding<CGSize?>) {
    self.size = size
  }

  func trackContainerSizeChanges(ofType type: ContainerType) {
    switch type {
    case let .window(window):
      cancellable = window.publisher(for: \.frame)
        .receive(on: RunLoop.main)
        .sink(receiveValue: { [weak self] _ in
          guard let self = self else { return }
          let size = self.calculateContainerSize(ofType: type)
          self.size.wrappedValue = size
        })

    // ...
  }

  func calculateContainerSize(ofType type: ContainerType) -> CGSize {
    switch type {
    case let .window(window):
      let windowSize = window.frame.size
      let safeAreaInsets = window.safeAreaInsets
      let width = windowSize.width - safeAreaInsets.left - safeAreaInsets.right
      let height = windowSize.height - safeAreaInsets.top - safeAreaInsets.bottom
      return CGSize(width: width, height: height)

    // ...
  }
}

构建 ViewModifier 和 View Extension

我们通过 UIViewRepresentable 将以上逻辑封装成 SwiftUI 视图,并在视图上应用,最终使用 frame 修饰器将变换后的尺寸应用于视图:

Swift
private struct ContainerDetectorModifier: ViewModifier {
  let type: DetectorType
  @State private var containerSize: CGSize?
  func body(content: Content) -> some View {
    content
      .background(
        ContainerDetector(size: $containerSize)
      )
      .frame(width: result.width, height: result.height, alignment: result.alignment)
  }
  
  ...
}

通过上述操作,我们就获得了一个与官方 containerRelativeFrame 功能一致的复刻版本,并验证了对官方文档未提及细节的猜测。

结果表明,containerRelativeFrame 确实可以被视为允许自定义变换规则的特殊版 frame 修饰器。因此,在全文中,我并未特别介绍 alignment 参数的用法,因为它与 frame 的对齐逻辑完全一致。

注意事项:

  • 在 iOS 17 以下,如果在复刻版本中同时变换两个轴向的数据,ScrollView 可能会出现错误。
  • 相较于官方版本,复刻版本对于 List 的尺寸获取更符合预期。
  • 目前,仅在 iOS 17 以下版本中,复刻版本能够实现对 frame 变化的观察。在 iOS 17 及以上版本中,复刻版本无法动态响应容器尺寸的变化。

总结

通过本文的深入探讨和示例演示,相信你已经全面了解了 SwiftUI 中的 containerRelativeFrame 修饰器的定义、用法和注意事项。我们不仅掌握了如何利用这个强大的视图修饰器来优化和创新我们的布局策略,还学习了如何通过复刻既有的布局工具来拓展其应用到旧版 SwiftUI 中,增强了对 SwiftUI 布局机制的理解。希望这些内容能够激发你对 SwiftUI 布局的兴趣,并在你的开发实践中发挥实际作用。

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam