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:
-
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.
Swiftpublic func containerRelativeFrame(_ axes: Axis.Set, alignment: Alignment = .center) -> some View
-
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.
Swiftpublic func containerRelativeFrame(_ axes: Axis.Set, count: Int, span: Int = 1, spacing: CGFloat, alignment: Alignment = .center) -> some View
-
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.
Swiftpublic 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.
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:
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:
@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
@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.
@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
orbackground
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.
@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:
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:
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).
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 aNavigationStack
, the toolbar height is not subtracted. - When using
containerRelativeFrame
in aTabView
, the calculated height is the total height of theTabView
minus the height of the safe area above and theTabBar
below. - In a
ScrollView
, if the developer has addedpadding
throughsafeAreaPadding
, thencontainerRelativeFrame
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 withList
, according to its actual performance in Xcode Version 15.3 (15E204a), this modifier is not yet capable of correctly calculating dimensions inList
.
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:
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)
}
}
}
}
}
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.
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:
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:
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:
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)
}
}
}
}
With containerRelativeFrame
, our implementation logic would be entirely different:
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:
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
:
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:
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)
}
}
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):
// 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.
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:
@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:
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 theframe
modifier that allows for custom transformation rules. Therefore, in this article, I did not specifically discuss the use of thealignment
parameter, as it aligns completely with the logic of theframe
.
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.