After six iterations, SwiftUI is no longer a nascent framework. However, developers still occasionally encounter various peculiar issues stemming from bugs in the framework’s code during its use. This article will analyze a case of abnormal Grid layout, exploring the analytical approach and problem-solving strategies when encountering issues in everyday SwiftUI development.
An Anomaly in Grid Layout
Recently, a fellow developer in my Discord community shared an unexpected situation he encountered while using Grid for layout.
This developer’s goal was to create a single-row Grid containing three elements, with the third element being a nested Grid spanning two columns. Here’s the relevant code:
struct GridBug: View {
var body: some View {
Grid {
GridRow {
ColorView(.orange)
ColorView(.indigo)
// Nested Grid
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
.gridCellColumns(2) // Spans two columns
}
}
.border(.red, width: 2)
.frame(width: 350, height: 350)
.border(.blue, width: 2)
}
}
struct ColorView: View {
let color: Color
init(_ color: Color) {
self.color = color
}
var body: some View {
color
}
}Ideally, the layout should appear as follows: the outer Grid filling the specified size space, with the nested Grid occupying half of its width.
However, when the code is actually run, the result is that the outer Grid fails to completely fill the given space.
At first glance, there are no apparent errors in the code declaration. So, where exactly does the problem lie?
Pinpointing the Source of the Problem
When faced with this situation, I believe many developers, like myself, would first resort to the commenting method to locate the issue.
After commenting out the .gridCellColumns(2) modifier, the layout result aligns with our expectations: the outer Grid completely fills the given space and is evenly divided into three columns. Here’s the modified code:
struct GridBug: View {
var body: some View {
Grid {
GridRow {
ColorView(.orange)
ColorView(.indigo)
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
// .gridCellColumns(2)
}
}
.border(.red, width: 2)
.frame(width: 350, height: 350)
.border(.blue, width: 2)
}
}
After repeated commenting tests and experiments replacing the nested Grid with other views, we finally pinpointed the root of the problem: the combined use of the nested Grid and the gridCellColumns modifier.
In other words, when we nest another Grid that spans columns within a Grid, the outer Grid encounters an anomaly when calculating the internal column widths.
Problem Investigation Approach
The need for nesting multiple Grids in complex layouts is clearly significant. Besides finding a solution to the current problem, if we can precisely locate the issue and report it to Apple, it could potentially accelerate the SwiftUI development team’s efforts to fix this problem.
Recently, I gave a talk titled “Exploring the Secrets of SwiftUI Dimensions” at AdventureX, delving into the underlying logic of SwiftUI’s layout system and how various layout containers determine the sizes of their child views and themselves.
As one of the layout containers, Grid naturally follows SwiftUI’s layout conventions while having its own specific layout rules.
We can summarize Grid’s layout logic as follows:
- Rows with multiple columns need to be declared using
GridRow - The number of columns for the entire
Gridis determined by the row with the most columns - Rows not declared with
GridRoware considered to span all columns - The width of a single column is determined by the widest cell in that column
- The total width of the
Gridis the sum of all column widths plus the set spacing
It can be said that after determining the number of columns, the layout of a single-row Grid is quite similar to an HStack when receiving an explicit proposed size. It will ask child views for their required sizes under minimum and maximum proposed size mode, judge the characteristics of the child views based on this, and finally decide on the layout size.
Assuming our inference is correct (i.e., the approach of Grid proposing to child views), we can verify the cause by observing the layout negotiation data between Grid and its child views.
The Layout protocol introduced in SwiftUI for iOS 16 is very suitable for this task. We can create a simple Layout implementation to investigate:
struct LayoutMonitor: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize {
guard subviews.count == 1, let subView = subviews.first else { fatalError() }
print("Proposal Mode:", proposal)
let result = subView.sizeThatFits(proposal)
print("Required Size:", result)
return result
}
func placeSubviews(in _: CGRect, proposal _: ProposedViewSize, subviews _: Subviews, cache _: inout ()) {}
}The LayoutMonitor in this code only accepts one child view. It passes the parent view’s proposed size to the child view and returns the child view’s required size to the parent view. Through the console, we can observe this size information. LayoutMonitor is my main tool for analyzing the underlying implementation of layout containers in daily work.
It’s recommended to read the article SwiftUI Layout: The Mystery of Size to gain a deeper understanding of size concepts and underlying negotiation logic in SwiftUI.
Analysis of the Problem’s Root Cause
Let’s use LayoutMonitor to wrap the nested Grid and observe the layout negotiation process between it and the outer Grid.
LayoutMonitor { // Only wrapping Grid, LayoutMonitor still needs to span two columns
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
}
.gridCellColumns(2)After running, the console output is as follows:
Proposal Mode: ProposedViewSize(width: Optional(0.0), height: Optional(0.0))
Required Size: (8.0, 16.0)
Proposal Mode: ProposedViewSize(width: Optional(inf), height: Optional(inf))
Required Size: (inf, inf)
Proposal Mode: ProposedViewSize(width: Optional(167.0), height: Optional(350.0))
Required Size: (167.0, 350.0)Analyzing the output, we find that the outer Grid communicates with the inner Grid three times:
- Minimized Mode: The inner
Gridreturns a puzzling required size of(8.0, 16.0), which doesn’t meet expectations. - Maximized Mode: The required size
(inf, inf)returned by the innerGridmeets expectations. - Explicit Size Mode: The horizontal dimension
167provided by the outerGridto the innerGridis correct (350/2 - 8, where 8 is the default spacing).
Except for the anomalous return value in the minimized mode, the return values in other modes are normal. So, does the problem lie in this required size of (8.0, 16.0)? More precisely, does Grid return an incorrect required size in minimized mode after applying gridCellColumns?
To further verify this hypothesis, I conducted the following experiments:
- Using
frameto adjust the required size of the inner Grid in Minimized Mode:
LayoutMonitor {
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
.frame(minWidth: 0, minHeight: 0) // Using frame to adjust Grid's Required Size in Minimized Mode
}
.gridCellColumns(2)
- Using
frameto adjust the required size of the innerGrid+gridCellColumnscomposite view in Minimized Mode:
LayoutMonitor {
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
}
.gridCellColumns(2)
.frame(minWidth: 0, minHeight: 0) // Adjusting the composite view of Grid + gridCellColumns
- Replacing the inner
GridwithColor:
LayoutMonitor {
Color.red
}
.gridCellColumns(2)
The results of these tests all meet expectations, and the required size returned in Minimized Mode is normal ((0,0)).
Through these experiments, we can confirm that the current layout issue stems from the anomalous required size returned by the inner Grid + gridCellColumns in Minimized Mode. At this point, we have identified the root cause of the problem.
As for how this anomalous return value affects the final layout result, we cannot know due to the inability to view Grid’s internal implementation code. However, I have reported this situation to Apple (FB14556654), hoping they can use this clue to fix this error as soon as possible.
Optimized Solution
Although we can solve the current problem by adjusting the Required Size in Minimized Mode using the frame modifier, this method is not intuitive. While this solution targets the essence of the problem, it’s too specific and difficult to become a universal development pattern that fundamentally avoids similar issues.
Given that the problem stems from the combined use of Grid and gridCellColumns, we should seek alternative approaches to construct our code. Here, I recommend using Color in conjunction with the overlay modifier to meet the current requirements:
Color.clear
.overlay(
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
)
.gridCellColumns(2)
In my blog, several articles have employed similar techniques. Using Color.clear as a placeholder view not only ensures layout accuracy but also provides the correct proposed size to the internal view (in this case, the nested Grid) through overlay. Furthermore, the overlay modifier allows us to set alignment guides, offering greater flexibility in layout.
To gain a deeper understanding of
overlayapplication techniques in layouts, I recommend reading the article In-Depth Exploration of Overlay and Background Modifiers in SwiftUI.
Summary and Insights
This article explored a Grid layout anomaly in a specific scenario. While some experienced developers might intuitively find a solution, the systematic analysis method introduced in this article not only solved the problem but also revealed its underlying causes.
This analytical process offers dual value:
- Problem Solving: We not only found a solution but also understood the root cause of the problem, which will help us respond more quickly and effectively when encountering similar issues in the future.
- Knowledge Accumulation: Through the analysis process, we deepened our understanding of SwiftUI’s layout system. This understanding is not limited to
Gridbut extends to SwiftUI’s overall layout mechanism, including concepts such as size negotiation and layout priorities.
Moreover, this analytical method itself is a valuable skill. It teaches us how to think systematically, hypothesize, validate, and how to use existing tools (like LayoutMonitor) to debug complex layout issues.
As developers, we should maintain curiosity and a spirit of exploration, not settling for simple “it works” solutions, but striving for a deep understanding of technology. This approach not only solves immediate problems but also enhances our technical skills and problem-solving abilities in the long run.