嵌套 Grid 布局异常:遇到 SwiftUI 布局问题时的分析思路与解决策略

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

历经六个版本的迭代,SwiftUI 已不再是一个新兴框架。然而,开发者在使用过程中仍然会不时遇到由框架代码 Bug 引发的各种奇怪问题。本文将通过剖析一个 Grid 布局异常的案例,探讨在日常 SwiftUI 开发中遇到问题时的分析思路和解决策略。

Grid 布局中的一个反常现象

近日,一位网友在我的 Discord 社区中分享了他在使用 Grid 进行布局时遇到的一个与预期不符的情况。

这位开发者的目标是创建一个单行的 Grid,包含三个元素,其中第三个元素是一个跨两列的内嵌 Grid。以下是相关代码:

Swift
struct GridBug: View {
  var body: some View {
    Grid {
      GridRow {
        ColorView(.orange)
        ColorView(.indigo)

        // 内嵌 Grid
        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)
  }
}

struct ColorView: View {
  let color: Color
  init(_ color: Color) {
    self.color = color
  }

  var body: some View {
    color
  }
}

理想情况下,布局应该呈现如下效果:外部 Grid 填满指定的尺寸空间,而内嵌的 Grid 占据其中的一半宽度。

image-20240730085931207

然而,实际运行代码后,得到的结果却是:外部的 Grid 未能完全填充给定的空间。

image-20240730090056910

乍看之下,代码中并没有明显的错误声明。那么,问题究竟出在哪里呢?

定位问题源头

面对这种情况,相信许多开发者和我一样,会首先采用注释法来定位问题所在。

当我们注释掉 .gridCellColumns(2) 修饰符后,布局结果与预期一致:外部 Grid 完全填充了给定空间,并均匀地分为三列。以下是修改后的代码:

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

经过反复注释测试和使用其他视图替换内嵌 Grid 的实验,我们最终锁定了问题的源头:内嵌 GridgridCellColumns 修饰符的组合使用。

换言之,当我们在一个 Grid内嵌另一个跨列Grid 时,外层 Grid 在计算内部列宽时出现了异常。

问题排查思路

复杂布局中嵌套多个 Grid 的需求显然不容忽视。除了寻找当前问题的解决方案,如果能够精确定位问题并向苹果反馈,很可能会加速 SwiftUI 开发团队修复这一问题。

不久前,我在 AdventureX 上进行了名为《探索 SwiftUI 尺寸的秘密》的演讲,深入讲解了 SwiftUI 布局系统的底层逻辑,以及各种布局容器如何确定子视图和自身尺寸。

作为布局容器之一,Grid 自然同样遵循 SwiftUI 的布局约定,并有其特有的布局规则。

我们可以将 Grid 的布局逻辑概括如下:

  • 拥有多列的行需使用 GridRow 声明
  • 整个 Grid 的列数由最多列的行决定
  • 未用 GridRow 声明的行视为跨所有列
  • 单列宽度由该列中最宽单元格决定
  • Grid 总宽度为所有列宽加上设定的间距之和

可以说,确定列数后,单行 Grid 的布局与 HStack 在接收到明确建议尺寸时颇为相似,它会询问子视图在最小和最大建议尺寸下的需求尺寸,据此判断子视图特征,最终决定布局尺寸。

假设我们的推断正确(即 Grid 向子视图提案的思路),我们可以通过观察 Grid 与子视图间的布局协商数据来验证原因。

SwiftUI 在 iOS 16 中引入的 Layout 协议非常适合这项工作。我们可以创建一个简单的 Layout 实现来一探究竟:

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

这段代码中的 LayoutMonitor 只接收一个子视图。它将父视图的建议尺寸(Proposed Size)传递给子视图,并将子视图的需求尺寸(Required Size)传回父视图。通过控制台,我们可以观察到这些尺寸信息。LayoutMonitor 是我日常分析布局容器底层实现的主要工具。

建议阅读 SwiftUI 布局 —— 尺寸 一文,深入了解 SwiftUI 中的尺寸概念和底层协商逻辑。

分析问题成因

让我们使用 LayoutMonitor 包装内嵌的 Grid,观察它与外层 Grid 之间的布局协商过程。

Swift
LayoutMonitor { // 只包装 Grid, LayoutMonitor 仍然需要跨两列
  Grid {
    GridRow {
      ColorView(.cyan)
      ColorView(.yellow)
    }
    ColorView(.mint)
    ColorView(.green)
  }
}
.gridCellColumns(2)

运行后,控制台输出如下:

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)

分析输出结果,我们发现外层 Grid 与内部 Grid 进行了三次通信:

  1. 最小建议尺寸模式:内部 Grid 返回了一个令人费解的需求尺寸 (8.0, 16.0),这与预期不符。
  2. 最大建议尺寸模式:内部 Grid 返回的需求尺寸 (inf, inf) 符合预期。
  3. 明确建议尺寸模式:外部 Grid 给内部 Grid 提供的横向尺寸 167 是正确的(350/2 - 8,其中 8 是默认间距)。

除了最小建议尺寸模式下的异常返回值,其他模式下的返回值都正常。那么,问题是否出在这个 (8.0, 16.0) 的需求尺寸上?更准确地说,是否 Grid 在应用 gridCellColumns 后,在最小建议尺寸模式下返回了错误的需求尺寸?

为进一步验证这一猜测,我又进行了以下实验:

  1. 使用 frame 调整内部 Grid 在最小建议尺寸下的需求尺寸:
Swift
LayoutMonitor {
  Grid {
    GridRow {
      ColorView(.cyan)
      ColorView(.yellow)
    }
    ColorView(.mint)
    ColorView(.green)
  }
  .frame(minWidth: 0, minHeight: 0) // 使用 frame 调整 Grid 在最小建议尺寸下的需求尺寸
}
.gridCellColumns(2)

image-20240730115413994

  1. 使用 frame 调整内部 Grid + gridCellColumns 组合视图在最小建议尺寸下的需求尺寸:
Swift
LayoutMonitor {
  Grid {
    GridRow {
      ColorView(.cyan)
      ColorView(.yellow)
    }
    ColorView(.mint)
    ColorView(.green)
  }
}
.gridCellColumns(2)
.frame(minWidth: 0, minHeight: 0) // 调整 Grid + gridCellColumns 的复合视图

image-20240730114701938

  1. 将内部 Grid 替换为 Color
Swift
LayoutMonitor {
  Color.red
}
.gridCellColumns(2)

image-20240730114752752

这些测试的结果均符合预期,且在最小建议尺寸模式下返回的需求尺寸正常((0,0))。

通过这些实验,我们可以确定当前的布局问题源自内部 Grid + gridCellColumns 在最小建议尺寸模式下返回的异常需求尺寸。至此,我们就确定了问题的成因。

至于这个异常返回值如何影响了最终布局结果,由于无法查看 Grid 的内部实现代码,我们无从得知。不过,我已将这一情况反馈给苹果( FB14556654 ),希望他们能借此线索尽早修复这个错误。

优化解决方案

尽管我们可以通过 frame 修饰符调整最小建议尺寸模式下的需求尺寸来解决当前问题,但这种方法并不直观。这种解决方案虽然针对问题本质,却过于具体,难以成为一种通用的开发范式来从根本上避免类似问题。

鉴于问题源自 GridgridCellColumns 的组合使用,我们应该寻找替代方案来构建代码。在这里,我推荐使用 Color 配合 overlay 修饰器的方式来满足当前需求:

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

image-20240730115923133

在我的博客中,多篇文章都运用了类似的技巧。使用 Color.clear 作为占位视图不仅确保了布局的准确性,还能通过 overlay 为内部视图( 此处为内嵌 Grid )提供正确的建议尺寸。此外,overlay 修饰器允许我们设置对齐指南,为布局提供了更大的灵活性。

要深入了解 overlay 在布局中的应用技巧,推荐阅读 深入探索 SwiftUI 中的 Overlay 和 Background 修饰器 一文。

总结与启示

本文探讨了一个特定场景下的 Grid 布局异常问题。虽然一些经验丰富的开发者可能能够凭直觉找到解决方案,但通过本文介绍的系统分析方法,我们不仅解决了问题,还揭示了其背后的原因。

这种分析的过程具有双重价值:

  1. 问题解决:我们不仅找到了解决方案,还理解了问题的根源,这有助于在未来遇到类似问题时更快速、更有效地应对。
  2. 知识积累:通过分析过程,我们加深了对 SwiftUI 布局系统的理解。这种理解不仅限于 Grid,还涉及到 SwiftUI 的整体布局机制,包括尺寸协商、布局优先级等概念。

此外,这种分析方法本身就是一种宝贵的技能。它教会我们如何系统地思考、假设、验证,以及如何利用现有手段(如 LayoutMonitor)来调试复杂的布局问题。

作为开发者,我们应该保持好奇心和探索精神,不满足于简单的”能用就行”,而是追求对技术的深入理解。这样不仅能解决眼前的问题,还能在长远上提升我们的技术水平和问题解决能力。

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam