揭秘 .ignoredByLayout():让视觉变换“隐形”于布局之外

发表于

在 SwiftUI 的众多 API 中,.ignoredByLayout() 算是一位“低调的成员”。相关资料稀少,应用场景也不常见,其名称本身就容易引发困惑。它似乎暗示着某种对布局的“忽略”,但这与我们熟知的 offsetscaleEffect 等修饰符默认不影响父布局的行为有何不同? ignoredByLayout 究竟在什么时机工作?它到底“忽略”或“隐瞒”了什么?本文将为你揭开这个 SwiftUI 布局机制中微妙 API 的面纱。

令人困惑的官方文档

让我们先看看苹果关于 ignoredByLayout官方文档 是如何介绍的:

Returns an effect that produces the same geometry transform as this effect, but only applies the transform while rendering its view.

Use this method to disable layout changes during transitions. The view ignores the transform returned by this method while the view is performing its layout calculations.

返回一个效果,该效果产生与此效果相同的几何变换,但仅在其视图的渲染期间应用该变换。

使用此方法以在过渡(transitions)期间禁用布局更改。视图在执行其布局计算时,会忽略此方法(指 .ignoredByLayout() 所作用的效果)返回的变换。

这段描述,尤其是关于“禁用布局更改”的部分,可能会让不少开发者感到迷茫。我们通常理解,在 SwiftUI 中,像 offsetscaleEffectrotationEffect 这类修饰符,虽然改变了视图的视觉呈现,但默认并不会扰乱其容器的布局结果。那么,ignoredByLayout 是不是实现这一默认行为的核心机制呢?

答案是:并非如此

实践证明,无论是否显式使用 .ignoredByLayout,或者直接调用 GeometryEffect 的底层实现,像 scaleEffect 这样的变换并不会影响父视图的布局。例如,以下三种方式在影响父布局方面表现一致(即都不影响)

Swift
// 方式一:标准修饰符
Rectangle()
    .scaleEffect(3)

// 方式二:直接使用 scaleEffect 对应的底层 GeometryEffect 实现
Rectangle()
	  .modifier(_ScaleEffect(scale: .init(width: 3, height: 3)))

// 方式三:在底层实现上添加 ignoredByLayout
Rectangle()
    .modifier(_ScaleEffect(scale: .init(width: 3, height: 3)).ignoredByLayout())

既然如此,.ignoredByLayout 中“ignored”的真正目标是什么?它的精确作用又体现在哪里?

理解视图的视图尺寸 (View Size) 与其在布局系统中占用的尺寸 (Layout Size) 的区别至关重要。推荐阅读 SwiftUI 布局 —— 尺寸 一文,以深入了解这一概念。

ignoredByLayout 的真面目

要揭示 ignoredByLayout 的真实作用,最直接的方法莫过于观察:在应用基础几何变换时,使用与不使用它,会导致哪些具体的差异。

下面的代码创建了两个矩形,都应用了平移和旋转。上面的矩形使用标准的 offsetrotationEffect。下面的矩形则使用了对应的底层 GeometryEffect 实现 (_OffsetEffect, _RotationEffect),并附加了 .ignoredByLayout()。我们还为每个矩形添加了一个 GeometryInfo 视图,用 GeometryReader 来实时显示其报告的几何信息(全局坐标和尺寸)。

Swift
struct IgnoredByLayout: View {
    @State var degree: Double = 0
    @State var x: Double = 0
    var body: some View {
        Slider(value: $degree, in: 0 ... 360)
            .padding()
        Slider(value: $x, in: -100 ... 100)
            .padding()

        Rectangle()
            .frame(width: 200, height: 200)
            .overlay(GeometryInfo())
            .rotationEffect(.degrees(degree))
            .offset(x: x)

        Rectangle()
            .frame(width: 200, height: 200)
            .overlay(GeometryInfo())
            .modifier(_RotationEffect(angle: .degrees(degree)).ignoredByLayout())
            .modifier(_OffsetEffect(offset: .init(width: x, height: 0)).ignoredByLayout())
    }
}

// 显示几何信息
struct GeometryInfo: View {
    var body: some View {
        GeometryReader { proxy in
            Color.clear
                .overlay(
                    VStack {
                        // 通过 GeometryReader 获取到的 minX
                        Text("x = \(Int(proxy.frame(in: .global).minX))")
                            .foregroundColor(.white)
                        // 通过 GeometryReader 获取到的尺寸
                        Text(
                            "\(Int(proxy.frame(in: .global).size.width)), \(Int(proxy.frame(in: .global).size.height))")
                            .foregroundColor(.white)
                    }
                    .font(.headline))
        }
    }
}

对于下方应用了 .ignoredByLayout() 的矩形,尽管它的视觉位置和角度同样随着滑块变化,但通过 GeometryReader 获取到的几何信息(全局坐标 minXsize始终保持不变。这与上方矩形形成了鲜明对比,后者的报告坐标和尺寸(特别是旋转后的尺寸,反映了包围盒)会随变换而改变。

重要的是,即使附加了 .ignoredByLayout()_RotationEffect_OffsetEffect 对矩形产生的视觉变换效果丝毫未减

基于此观察,我们可以给出关于 .ignoredByLayout 更为精准的解释:

  • 适用前提: .ignoredByLayout 必须且仅能应用于遵循 GeometryEffect 协议的类型实例上。
  • 视觉渲染:不会改变该 GeometryEffect 对视图产生的最终视觉渲染效果。视图在屏幕上看起来仍然是经过几何变换(如旋转、缩放)之后的样子。
  • 核心机制 (布局交互): 当 SwiftUI 的布局系统在布局计算阶段通过例如 GeometryReader 这样的手段查询应用了此效果的视图的几何信时,ignoredByLayout 会介入。其作用是:确保布局系统在进行这些计算时,忽略由这个特定的 GeometryEffect 所引入的几何变换。因此,该视图向布局系统报告的几何属性(尺寸、边界框等)将反映其在应用此特定 GeometryEffect 之前的原始状态。

总结:

.ignoredByLayout() 的本质是修改视图与系统进行几何信息沟通的方式。它强制视图在收到例如 GeometryReader 的查询向布局系统“描述”自身时,使用的是其未受该 GeometryEffect 视觉变换影响的原始布局蓝图,从而有效隔离了该视觉效果对布局计算流程的潜在干扰。

ignoredByLayout 的实战场景

虽然大多数日常开发可能不会直接触及 ignoredByLayout,或者遇到了相关问题却未意识到其根源。但在理解了它的本质后,当你发现应用几何变换导致布局行为(如对齐、尺寸读取)与预期不符时,.ignoredByLayout 就成了一个值得考虑的解决方案。以下是两个典型的应用案例:

案例一:修复因变换导致的锚点信息失真

在复杂的自定义布局中,我们常常需要通过 PreferenceKey (例如 anchorPreference) 将子视图的几何锚点信息向上传递,父视图再依据这些信息进行精确布局。然而,如果子视图本身应用了某些几何变换(特别是旋转或缩放),直接读取其 Anchor 并转换为 CGRect 时,可能会得到变换后包围盒的信息,而非原始精确边界,导致布局错乱。

下面的代码演示了这个问题:你会发现红色背景矩形的尺寸和位置会随着旋转而“变形”,因为它基于的是旋转后 Text 的包围盒信息而绘制的。

Swift
struct SimpleFrameData: Equatable {
    var anchor: Anchor<CGRect>? = nil
}

struct SimpleFramePreferenceKey: PreferenceKey {
    typealias Value = [SimpleFrameData]
    static var defaultValue: Value = []

    static func reduce(value: inout Value, nextValue: () -> Value) {
        value.append(contentsOf: nextValue())
    }
}


struct SimplifiedRotationDistortion: View {
    @State private var rotationAngle: Double = 0

    var body: some View {
        VStack(spacing: 30) {

            Slider(value: $rotationAngle, in: 0...45)
                .padding()

            VStack {
                Text("Item to Track")
                    .padding(15)
                    .background(Color.blue.opacity(0.7))
                    .border(Color.cyan, width: 2)
                    // 使用 anchorPreference 报告边界
                    .anchorPreference(
                        key: SimpleFramePreferenceKey.self,
                        value: .bounds
                    ) { anchorValue in
                        // 将 Anchor<CGRect> 包装在我们的数据结构中
                        return [SimpleFrameData(anchor: anchorValue)]
                    }
            }
            .backgroundPreferenceValue(SimpleFramePreferenceKey.self) { preferences in
                GeometryReader { geometryProxy in
                    // 获取我们刚刚报告的 Anchor
                    if let itemAnchor = preferences.first?.anchor {
                        // 将 Anchor 转换为 CGRect
                        // 当父视图旋转时,这里的坐标转换会受影响
                        // 它会返回旋转后视图的轴对齐包围盒 (bounding box) 的 frame
                        let itemBounds = geometryProxy[itemAnchor]

                        // 根据计算出的 bounds 绘制一个背景矩形
                        Rectangle()
                            .fill(Color.red.opacity(0.5))
                            .frame(width: itemBounds.width, height: itemBounds.height)
                            .offset(x: itemBounds.minX, y: itemBounds.minY)
                            .border(Color.orange, width: 2)
                            .overlay(Text("BG").font(.caption))
                    }
                }
            }
            .border(Color.gray)
            .rotationEffect(.degrees(rotationAngle))
            .frame(width: 250, height: 150)
            
            Spacer()
        }
    }
}

rotationEffect 替换为底层的 _RotationEffect 并附加 .ignoredByLayout()。这样,即使视图在视觉上旋转了,传递给 backgroundPreferenceValueAnchor 在通过 GeometryReader 解析时,依然能获得旋转前的原始精确几何信息,从而使背景绘制保持正确。

Swift
// .rotationEffect(.degrees(rotationAngle))
.modifier(_RotationEffect(angle: .degrees(rotationAngle), anchor: .center).ignoredByLayout())

注意: .ignoredByLayout 可以应用于任何实现了 GeometryEffect 的类型,无论是系统预置的(通常带 _ 前缀)还是开发者自定义的。

布局结构越复杂,应用的几何变换越多,且越依赖子视图精确几何信息时,就越有可能遇到此类问题,此时 .ignoredByLayout() 便能派上用场。

案例二:规避因渲染层位移导致的安全区域意外扩展

SwiftUI 的 .background 修饰符在使用 ShapeStyle 作为填充物时有一个特性:如果视图靠近安全区域边缘,背景默认会延伸到安全区域。开发者可以通过 ignoresSafeAreaEdges 参数来控制这一行为。

然而,ignoresSafeAreaEdges 参数似乎并不总是能完全按预期工作,特别是当视图的位置是通过渲染层的变换(如 offset)来改变时。在下面的代码中,尽管我们设置了 ignoresSafeAreaEdges: .all,试图阻止背景扩展,但当使用 .offset 将视图移动到屏幕底部靠近安全区域时,背景仍然意外地溢出了。

Swift
struct OffsetView: View {
    @State var y: CGFloat = 0
    var body: some View {
        Slider(value: $y, in: -500 ... 500)
        Color.clear
            .background(.orange,ignoresSafeAreaEdges: .all)
            .frame(width: 100, height: 100)
            .offset(y: y)
    }
}

再次地,我们将 .offset(y: y) 替换为对应的 _OffsetEffect 并附加 .ignoredByLayout()

Swift
// .offset(y: y)
.modifier(_OffsetEffect(offset: .init(width: 0, height: y)).ignoredByLayout())

理解了 ignoredByLayout 的作用后,原因就显而易见了。background 修饰符在判断是否需要扩展以填充安全区域时,很可能依赖于其所应用的视图报告的几何位置信息。附加了 ignoredByLayout 后,_OffsetEffect 产生的位移被布局系统“忽略”,background 接收到的是视图应用位移之前的原始位置信息。由于原始位置不在安全区域附近,因此背景就不会触发意外的扩展。

这也从侧面印证了 ignoresSafeAreaEdges 参数在处理渲染层的位置变化时可能存在局限性(至少在当前 SwiftUI 版本中),或许未来版本会对此行为进行调整。

好 API 更需好文档

某些特定场景下,.ignoredByLayout 是解决复杂布局问题的关键,甚至是唯一途径。它赋予了开发者精细控制视觉效果与布局计算解耦的能力,无疑是一个强大而有用的工具。

然而,由于官方文档的表述不够清晰,且缺乏直观有效的示例,使得这个 API 长期处于“少有人知、鲜有人用”的状态,未能发挥其应有的价值,这不能不说是一种遗憾。

SwiftUI 虽已发布多年,但在文档的清晰度、完整性和深度方面仍有提升空间。完善的文档是开发者深入理解和精通框架的基石。衷心希望未来我们能看到 SwiftUI 文档质量的持续改善,让更多像 .ignoredByLayout 这样的“隐藏宝石”能够被开发者充分理解和利用。

"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"

每周精选 Swift 与 SwiftUI 精华!