Nested Grid Layout Anomaly: Analysis Approach and Resolution Strategies for SwiftUI Layout Issues

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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:

Swift
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.

image-20240730085931207

However, when the code is actually run, the result is that the outer Grid fails to completely fill the given space.

image-20240730090056910

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:

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

image-20240730090859326

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

Swift
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.

Swift
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:

Shell
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:

  1. Minimized Mode: The inner Grid returns a puzzling required size of (8.0, 16.0), which doesn’t meet expectations.
  2. Maximized Mode: The required size (inf, inf) returned by the inner Grid meets expectations.
  3. Explicit Size Mode: The horizontal dimension 167 provided by the outer Grid to the inner Grid 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:

  1. Using frame to adjust the required size of the inner Grid in Minimized Mode:
Swift
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)

image-20240730115413994

  1. Using frame to adjust the required size of the inner Grid + gridCellColumns composite view in Minimized Mode:
Swift
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

image-20240730114701938

  1. Replacing the inner Grid with Color:
Swift
LayoutMonitor {
  Color.red
}
.gridCellColumns(2)

image-20240730114752752

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:

Swift
Color.clear
  .overlay(
    Grid {
      GridRow {
        ColorView(.cyan)
        ColorView(.yellow)
      }
      ColorView(.mint)
      ColorView(.green)
    }
  )
  .gridCellColumns(2)

image-20240730115923133

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:

  1. 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.
  2. 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.

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