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 Grid
s 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
Grid
is determined by the row with the most columns - Rows not declared with
GridRow
are 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
Grid
is 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
Grid
returns a puzzling required size of(8.0, 16.0)
, which doesn’t meet expectations. - Maximized Mode: The required size
(inf, inf)
returned by the innerGrid
meets expectations. - Explicit Size Mode: The horizontal dimension
167
provided by the outerGrid
to the innerGrid
is 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
frame
to 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
frame
to adjust the required size of the innerGrid
+gridCellColumns
composite 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
Grid
withColor
:
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
overlay
application 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
Grid
but 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.