Mastering the containerRelativeFrame Modifier in SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

At the WWDC 2023, Apple introduced the containerRelativeFrame view modifier to SwiftUI. This modifier simplifies some layout operations that were previously difficult to achieve through conventional methods. This article will delve into the containerRelativeFrame modifier, covering its definition, layout rules, use cases, and relevant considerations. At the end of the article, we will also create a backward-compatible replica of containerRelativeFrame for older versions of SwiftUI, further enhancing our understanding of its functionalities.

Definition

According to Apple’s official documentation, the functionality of containerRelativeFrame is described as follows:

Positions this view within an invisible frame with a size relative to the nearest container.

Use this modifier to specify a size for a view’s width, height, or both that is dependent on the size of the nearest container. Different things can represent a “container” including:

  • The window presenting a view on iPadOS or macOS, or the screen of a device on iOS.
  • A column of a NavigationSplitView
  • A NavigationStack
  • A tab of a TabView
  • A scrollable view like ScrollView or List

The size provided to this modifier is the size of a container like the ones listed above subtracting any safe area insets that might be applied to that container.

In addition to the above definition, the official documentation contains several example codes to help readers better understand the use of this modifier. To further elucidate its working mechanism, I will re-describe the functionality of this modifier based on my understanding:

The containerRelativeFrame modifier starts from the view it is applied to and searches up the view hierarchy for the nearest container that fits within the list of containers. Based on the transformation rules set by the developer, it calculates the size provided by that container and uses this as the proposed size for the view. In a sense, it can be seen as a special version of the frame modifier that allows for custom transformation rules.

Constructors

containerRelativeFrame offers three constructor methods, each catering to different layout needs:

  1. Basic Version: Using this constructor method, the modifier does not transform the size of the container in any way. Instead, it directly takes the size provided by the nearest container as the suggested size for the view.

    Swift
    public func containerRelativeFrame(_ axes: Axis.Set, alignment: Alignment = .center) -> some View
  2. Preset Parameters Version: With this method, developers can specify the division of size, the number of columns or rows to span, and the spacing to consider, thus appropriately transforming the size along specified axes. This method is particularly suited for configuring the size of a view in proportion to the size of the container.

    Swift
    public func containerRelativeFrame(_ axes: Axis.Set, count: Int, span: Int = 1, spacing: CGFloat, alignment: Alignment = .center) -> some View
  3. Fully Customizable Version: This constructor offers the maximum flexibility, allowing developers to customize the calculation logic based on the size of the container. It is suitable for highly customized layout requirements.

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

These constructor methods provide developers with powerful tools to achieve complex layout designs that meet diverse interface requirements.

Keyword Explanation

To gain a deeper understanding of the containerRelativeFrame modifier’s functionalities, we will conduct a thorough analysis of several key terms mentioned in its definition.

Containers in the Container List

In SwiftUI, typically, child views directly receive their proposed size from their parent views. However, when we apply a frame modifier to a view, the child view ignores the parent’s proposed size and uses the dimensions specified by the frame as the proposed dimension for the specified axis.

Swift
VStack {
  Rectangle()
    .frame(width: 200, height: 200)
    // other views
    ...
}
.frame(width: 400, height: 500)

For example, when running on an iPhone, if we want the height of the Rectangle to be half the available height of the screen, we can use the following logic:

Swift
var screenAvailableHeight: CGFloat // Obtain the available height of the screen by some means

VStack {
  Rectangle()
    .frame(width: 200, height: screenHeight / 2)
    // other views
    ...
}
.frame(width: 400, height: 500)

Before the advent of containerRelativeFrame, we had to use methods like GeometryReader or UIScreen.main.bounds to obtain the screen dimensions. Now, we can achieve the same effect more conveniently:

Swift
@main
struct containerRelativeFrameDemoApp: App {
  var body: some Scene {
    WindowGroup {
      VStack {
        Rectangle()
          // Divide the vertical dimension by two and return
          .containerRelativeFrame(.vertical){ height, _ in height / 2}
      }
      .frame(width: 400, height: 500)
    }
  }
}

Or

Swift
@main
struct containerRelativeFrameDemoApp: App {
  var body: some Scene {
    WindowGroup {
      VStack {
        Rectangle()
          // Divide the vertical dimension into two equal parts, no spanning, no consideration for spacing
          .containerRelativeFrame(.vertical, count: 2, span: 1, spacing: 0)
      }
      .frame(width: 400, height: 500)
    }
  }
}

In the code above, Rectangle() ignores the 400 x 500 proposed size provided by the VStack and instead looks directly upward in the view hierarchy for a suitable container. In these examples, the appropriate container is the screen of an iPhone.

This means that containerRelativeFrame provides a way to access container dimensions across view hierarchies. However, it can only access dimensions provided by specific containers listed in the container list (like a window, ScrollView, TabView, NavigationStack, etc.).

Nearest

Within the view hierarchy, if multiple containers meet the criteria, containerRelativeFrame will select the container nearest to the current view and use its dimensions for calculations. For example, in the following code snippet, the final height of the Rectangle is 100 because it uses the height of the NavigationStack (200) divided by 2, rather than half of the available screen height.

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
    }
  }
}

This demonstrates that when using the containerRelativeFrame modifier, it searches upward from its position to find the nearest container and obtains the dimensions it provides. Special attention should be given to this behavior when designing reusable views, as the same code may result in different layout effects depending on its location.

Moreover, special care is needed when using containerRelativeFrame in overlay or background views that correspond to a container listed in the container list. In such cases, containerRelativeFrame will ignore the current container while searching for the nearest container. This behavior differs from the typical behavior of overlay and background views.

Typically, a view and its overlay or background are considered to have a master-slave relationship. To learn more, read In-Depth Exploration of Overlay and Background Modifiers in SwiftUI.

Consider the following example where an overlay that contains a Rectangle using containerRelativeFrame to set its height is applied to a NavigationStack. In this case, containerRelativeFrame will not use the height of the NavigationStack, but will instead seek the dimensions of a higher-level container—in this case, the available size of the screen.

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
      )
    }
  }
}

Transformation Rules

In the constructors offered by containerRelativeFrame, there are two methods that allow dynamic transformation of dimensions. The third method provides the most flexibility:

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

The length closure in this method is applicable to two different axes, allowing different dimension calculations based on the axis. For example, in the following code, the width of the Rectangle is set to two-thirds of the nearest container’s available width, and the height to half of the available height:

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

For axes not specified in the axes parameter of the constructor, containerRelativeFrame will not set the dimensions for that axis (it retains the proposed dimension given by the parent view).

Swift
struct TransformsDemo: View {
  var body: some View {
    VStack {
      Rectangle()
        .containerRelativeFrame(.horizontal) { length, axis in
          if axis == .vertical {
            return length / 2 // This line will not execute because .vertical is not set in axes
          } else {
            return length * (2 / 3)
          }
        }
    }.frame(height: 100)
  }
}

In the above code, the width of the Rectangle is set to two-thirds of the nearest container’s available width, while the height remains at 100 (consistent with the height of the parent VStack).

A detailed explanation of the second constructor method will be discussed in the next section.

Size Provided by the Container (Available Container Size)

The official documentation describes the size used by the containerRelativeFrame modifier as follows: “The size provided to this modifier is the size of a container like the ones listed above subtracting any safe area insets that might be applied to that container”. This description is fundamentally correct, but there are some important details to note when implementing it with different containers:

  • When used within a NavigationSplitView, containerRelativeFrame receives the dimensions of the current column (SideBar, Content, Detail). In addition to considering the reduction due to safe areas, the height of the toolbar (navigationBarHeight) must also be subtracted in the top area. However, when used in a NavigationStack, the toolbar height is not subtracted.
  • When using containerRelativeFrame in a TabView, the calculated height is the total height of the TabView minus the height of the safe area above and the TabBar below.
  • In a ScrollView, if the developer has added padding through safeAreaPadding, then containerRelativeFrame will also subtract these padding values.
  • In environments supporting multiple windows (iPadOS, macOS), the root container size corresponds to the available dimensions of the window in which the view is currently displayed.
  • Although the official documentation states that containerRelativeFrame can be used with List, according to its actual performance in Xcode Version 15.3 (15E204a), this modifier is not yet capable of correctly calculating dimensions in List.

Usage Examples

After mastering the principles of the containerRelativeFrame modifier, developers can use this modifier to achieve many layout operations that were previously impossible or difficult to accomplish. In this section, we will showcase several representative examples.

Creating Proportional Galleries Based on Scroll Area Dimensions

This is a common scenario often highlighted in articles discussing the use of the containerRelativeFrame. Consider the following requirement: we need to build a horizontal scrolling gallery layout, similar to the style of the App Store or Apple Music, where each child view (image) is one-third the width of the scrollable area and two-thirds the height of its width.

Typically, if not using containerRelativeFrame, developers might use the method introduced in SwiftUI geometryGroup() Guide: From Theory to Practice, which involves adding a background to ScrollView to obtain its dimensions, and then somehow passing this dimension information to set the specific sizes of the child views. This means we cannot achieve this solely by manipulating the child views; we must first obtain the dimensions of the ScrollView.

Using the second constructor method of containerRelativeFrame, this requirement can be easily met:

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)
            // Horizontally divide into thirds, no span, no spacing considered
            .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 0)
        }
      }
    }
  }
}

image-20240505181749569

Astute readers may notice that since the HStack itself has spacing: 10, the third view (on the far right) will be incompletely displayed, with a small part not immediately visible in the scrolling area. If you wish to consider the spacing: 10 of the HStack when setting the width of the child views, then you would need to also account for this spacing factor in containerRelativeFrame. With this adjustment, although each child view’s width is less than one-third of the ScrollView’s visible area width, considering the spacing, we can perfectly see three complete views on the initial screen.

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)
            // Include spacing consideration in calculations
            .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 10)
        }
      }
    }
  }
}

In containerRelativeFrame, the spacing parameter differs from the spacing in layout containers like VStack or HStack. It does not directly add space but is used in the second constructor method to add a spacing factor to the transformation rules.

The official documentation explains in detail the roles of count, span, and spacing in the transformation rules, taking width calculations as an example:

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

It is important to note that due to the layout characteristics of ScrollView (it uses all the proposed dimension in the scrolling direction, while in the non-scrolling direction, it depends on the required dimensions of the child views), when using containerRelativeFrame in a ScrollView, the axes parameter should at least include dimension handling in the scrolling direction (unless the child views have provided specific demand dimensions). Otherwise, it might lead to anomalies. For example, the following code might cause the application to crash in most cases:

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)
            // Calculation direction inconsistent with scrolling direction
            .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 0)
        }
      }
    }
    .border(.red)
  }
}

Note: Due to the differences in layout logic between LazyHStack and HStack, using LazyHStack instead of HStack can cause the ScrollView to occupy all available space, which may not align with the expected layout (official documentation examples use LazyHStack). In scenarios where LazyHStack is necessary, a better choice might be to use GeometryReader to obtain the width of the ScrollView and calculate the height accordingly to ensure the layout meets expectations.

Setting Sizes Proportionally

When the required size proportions are irregular, the third constructor method that allows for complete customization of transformation rules is more suitable. Consider the following scenario: we need to display a piece of text within a container (such as a NavigationStack or TabView) and set a background composed of two colors, blue on the top and orange on the bottom, with the division point at the container’s golden ratio (0.618).

Without using containerRelativeFrame, we might implement this as follows:

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

With containerRelativeFrame, our implementation logic would be entirely different:

Swift
struct SplitDemo: View {
  var body: some View {
    NavigationStack {
      Text("Hello World")
        .font(.title)
        .foregroundStyle(.yellow)
        .background(
          Color.blue
            // Blue occupies the entire available space of the container
            .containerRelativeFrame([.horizontal, .vertical])
            .overlay(alignment: .bottom) {
              Color.orange
                // Orange height is the container height x (1 - 0.618), aligned with blue at the bottom
                .containerRelativeFrame(.vertical) { length, _ in
                  length * (1 - 0.618)
                }
            }
        )
    }
  }
}

If you want the blue and orange backgrounds to extend beyond the safe area, you can achieve this by adding the ignoresSafeArea modifier:

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)
            }
        }
    )
}

In the article GeometryReader: Blessing or Curse?, we explored how to use GeometryReader to place two views at a specific ratio within a given space. Although containerRelativeFrame only supports obtaining dimensions of specific containers, we can still employ certain techniques to meet similar layout requirements.

Here is an example of implementing this with 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)
            }
        }
    }
}

In a version using containerRelativeFrame, we can utilize a ScrollView to provide dimensions without actually enabling its scrolling feature:

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) // Using ScrollView solely as a dimension provider, scrolling disabled
  }
}

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

Obtaining Container Dimensions as a Subview Across View Hierarchies

An important feature of containerRelativeFrame is its ability to directly obtain the available dimensions of the nearest suitable container within the view hierarchy. This capability is particularly suited for constructing subviews or view modifiers that contain independent logic and need to be aware of the dimensions of their containers, but do not want to disrupt the current view layout as GeometryReader might.

The following example demonstrates how to build a ViewModifier called ContainerSizeGetter, whose purpose is to obtain and pass on the available dimensions of its container (which is part of the container list):

Swift
// Store the retrieved dimensions to prevent updates during the view refresh cycle
class ContainerSize {
  var width: CGFloat? {
    didSet {
      sendSize()
    }
  }

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

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

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

// Retrieve and pass the available dimensions of the nearest container
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))
  }
}

This ViewModifier utilizes containerRelativeFrame to measure and update the dimensions of the container, and uses a PassthroughSubject to notify the externally bound size property of any dimension changes. The advantage of this method is that it does not disrupt the original layout of the view, serving merely as a tool for dimension monitoring and transmission.

Building a Replica Version of containerRelativeFrame

In my blog articles related to layout, I often attempt to build replica versions of layout containers. This practice not only helps deepen the understanding of the layout mechanisms of containers but also allows for testing hypotheses about certain layout logics. Additionally, where feasible, these replicas can be applied to earlier versions of SwiftUI (such as iOS 13+).

To simplify the effort of replication, the current version supports only iOS. The complete code can be viewed here.

Identifying the Nearest Container

The official containerRelativeFrame may obtain the dimensions of the nearest container in one of two ways:

  • By letting containers send their dimensions downward through the environmental values system.
  • By allowing containerRelativeFrame to autonomously search upwards for the nearest container and obtain its dimensions.

Considering that the first method can increase system load (as containers would continuously send size changes even when containerRelativeFrame is not used) and that it is challenging to design precise dimension-passing logic for different containers, our replica version opts for the second method—autonomously searching upwards for the nearest container.

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)
}

By adding an extension method findRelevantContainer to UIView, we can identify the specific container that is closest to the current view (UIView).

Calculating the Dimensions Provided by the Container

After identifying the nearest container, it is necessary to adjust for the safe area insets, TabBar height, navigationBarHeight, and other dimensions based on the type of container. This is done by monitoring changes in the frame property to dynamically respond to changes in dimensions:

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)

    // ...
  }
}

Building ViewModifier and View Extension

We encapsulate the above logic into a SwiftUI view using UIViewRepresentable and apply it to the view, ultimately using the frame modifier to apply the transformed dimensions to the view:

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)
  }
  
  ...
}

Through the above operations, we obtain a replica version that matches the functionality of the official containerRelativeFrame and validate hypotheses about details not mentioned in the official documentation.

The results show that containerRelativeFrame can indeed be viewed as a special version of the frame modifier that allows for custom transformation rules. Therefore, in this article, I did not specifically discuss the use of the alignment parameter, as it aligns completely with the logic of the frame.

Considerations:

  • On iOS versions below 17, if the replica version modifies the dimensions on two axes simultaneously, ScrollView might behave incorrectly.
  • Compared to the official version, the replica version provides more accurate dimension retrieval for List.
  • Currently, the replica version is only capable of observing frame changes in versions below iOS 17. In iOS 17 and above, the replica version cannot dynamically respond to changes in container dimensions.

Conclusion

Through the in-depth discussions and examples presented in this article, you should now have a comprehensive understanding of the containerRelativeFrame modifier in SwiftUI, including its definition, usage, and considerations. We have not only mastered how to use this powerful view modifier to optimize and innovate our layout strategies, but also learned how to expand its application to older versions of SwiftUI by replicating existing layout tools, thereby enhancing our understanding of SwiftUI’s layout mechanisms. I hope this content inspires your interest in SwiftUI layout and proves useful in your development practices.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed