了解 SwiftUI 布局行为

1,784 阅读21分钟

译文:defagos.github.io/understandi…

SwiftUI 布局系统比 UIKit 布局系统更可预测、更容易理解。但这并不意味着它的工作原理是完全简单的。

对于对 Apple 平台上布局历史上如何工作没有先入之见的新手来说,有关 SwiftUI 布局系统的官方文档可能不完整或晦涩难懂。视图和修改器的数量以及它们的各种行为可能相当令人难以承受。即使对于经验丰富的 UIKit 开发人员来说,也很难弄清楚 SwiftUI 布局系统是如何工作的,因为它的核心原理与 UIKit 众所周知的自动布局约束、弹簧和支柱概念有很大不同。

本文探讨了 SwiftUI 布局系统的基本规则和行为,并解释了您应该如何推理它。它还引入了一种形式主义,有助于描述视图及其一般的大小调整行为。它最终提供了大多数 SwiftUI 内置视图的大小调整行为列表。

查看类别

SwiftUI 视图都属于以下类别之一:

  • 不采用另一个视图(或视图构建器)作为参数的简单视图Text,例如或Image
  • 采用另一个视图或视图构建器作为参数的组合视图VStack,例如或Toggle

布局本身是通过在任意复杂的层次结构中组装简单视图和组合视图来创建的。

SwiftUI 布局过程

当父母必须列出其子视图之一时,它分三个步骤进行,Alex GrebenyukPaul Hudson的文章对此进行了很好的解释:

  1. 父级为子视图提供一定的大小。
  2. 子视图决定它需要的尺寸,最终考虑父尺寸提供(子视图可以完全忽略的提示)。然后它将所需的大小返回给其父级。
  3. 父母将孩子放在某个地方,严格遵守孩子要求的尺寸。

例子

尺寸优惠和子视图展示位置根据预期结果而有所不同,例如:

  • APainting可能会在其边缘周围绘制一些夸张的画框,并将其余部分提供给以它为中心的单个子视图。
  • ABorder可能会将其整个大小提供给单个子视图,并在其顶部添加 1 像素边框。
  • APlane可能由两个坐标轴组成,将其大小的四分之一平均提供给四个子视图,每个子视图绘制在一个象限中。子视图以不同的对齐方式放置在各自的象限中(第一象限为左下角,第二象限为右下角,第三象限为右上角,第四象限为左上角),以便它们的一个角与原点重合。

尺寸行为

在布局过程的第 2 步中,子级必须先决定所需的大小,然后再将其传达给父​​级。我们将大小调整行为1称为视图可以决定其所需大小的各种可能性。请注意,视图在水平和垂直方向上可能具有不同的大小调整行为。了解视图采用哪种大小调整行为以及它如何影响布局过程对于理解 SwiftUI 如何为视图分配大小和位置至关重要。

通常,视图会表现出以下两种具体的相反大小行为之一:

  • Expanding(exp):视图努力匹配其父级提供的大小。
  • Hugging(hug):视图选择适合其内容的最佳尺寸,而无需咨询其父级提供的尺寸。 如果视图在单个方向上扩展,则它必须与其父级在该方向上提供的大小相匹配,以便它可以在父级扩展时进行缩放。但是,如果一个视图同时在水平和垂直方向上扩展,它必须至少在一个方向上满足其父级提供的尺寸。2子视图最终负责单独决定他们想要的大小。如果它们在所有方向上扩展,它们仍然必须至少在一个方向上匹配父级提供的大小(以便它们可以在父级扩展时缩放),但如果不这样做,它们仍然可以自由选择另一个方向的大小想要伸展。3

必须为组合视图引入第三个抽象行为,其行为取决于其子级行为:

  • Neutral(neu):视图根据其子级的行为调整其大小行为,当且仅当其所有子级都有拥抱行为时才采用拥抱行为,否则采用扩展行为。 这三种大小调整行为描述了视图的内在属性,这意味着它们适用于单独考虑的视图。但在具体情况下,视图是布局层次结构的一部分。当视图是层次结构的一部分时,最终仅适用扩展和拥抱行为。因此,中立行为必须被视为扩展或拥抱行为的行为占位符,而不存在于具体的层次结构中。 在下文中,我们有时可能会使用 h-exp、v-exp、h-hug、v-hug、h-neu 和 v-neu 分别作为水平(h)和垂直(v)方向上所有可能的内在行为的简写。

装饰器视图和修改器

包含单个子视图的组合视图很特殊。也就是说,它们的行为类似于装饰器,可能会改变或保留装饰视图的行为:

  • 在某个方向上具有拥抱行为的组合视图为其子视图提供了该方向上的固定内容建议。
  • 在某个方向上具有扩展行为的组合视图为其子视图提供了它在该方向上可以承受的最大内容建议。
  • 在某个方向上具有中性行为的组合视图会透明地采用其子视图在同一方向上的行为。

SwiftUI 广泛使用装饰器来定义修饰符。每个修饰符都有一个关联的私有组合视图包装器,从视图修饰符作为不透明类型返回。因此,修饰语主要是语法糖,包括标志性的例子,如View/frame(width:height:)View/aspectRatio(_:contentMode:)

确定视图的固有大小调整行为

您可以探测视图的大小调整行为,以确定其内在的大小调整行为,即使您无权访问其实现。要遵循的过程取决于视图所属的类别。

探索简单视图的内在大小调整行为

要确定简单视图的大小调整行为,请使用足够大的画布,将边框附加到简单视图,并观察边框的显示位置:

struct SimpleView_Previews: PreviewProvider {
    static var previews: some View {
        SimpleView(...)
            .border(Color.blue, width: 3)
            .previewLayout(.fixed(width: 1000, height: 1000))
    }
}

如果边界在某个方向上靠近简单视图,则它在该方向上具有拥抱行为,否则具有扩展行为。 通过该方法可以验证Text在各个方向上具有拥抱行为,同时Color在各个方向上具有扩展行为:

探索组合视图的内在大小调整行为

要确定组合视图的行为,请使用足够大的画布,将边框附加到组合视图,并观察当组合视图分别包裹展开子视图或拥抱子视图时边框的显示位置。TextColor理想的儿童候选者,因为它们让我们同时探究水平和垂直行为:

struct ComposedView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ComposedView(...) {
                Color.red
                    .border(Color.green, width: 3)
            }
            ComposedView(...) {
                Text("Test")
                    .border(Color.green, width: 3)
            }
        }
        .border(Color.blue, width: 3)
        .previewLayout(.fixed(width: 1000, height: 1000))
    }
}

如果当其子视图在某个方向上扩展或拥抱时,观察到组合视图的扩展或拥抱行为,则这意味着组合视图在该方向上具有中性行为,因为它采用了其子视图的行为。

另一方面,如果组合视图在某个方向上忽略其子行为,则它必须在该方向上具有扩展或拥抱行为。在这种情况下,只需应用简单视图的过程即可确定内在的组合视图行为。

使用此方法可以验证包装Toggle某些View标签:

struct ComposedView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            Toggle(isOn: .constant(true)) {
                Color.red
                    .border(Color.green, width: 3)
            }
            Toggle(isOn: .constant(true)) {
                Text("Option")
                    .border(Color.green, width: 3)
            }
        }
        .border(Color.blue, width: 3)
        .previewLayout(.fixed(width: 1000, height: 1000))
    }
}

具有水平扩展行为,但垂直方向具有中性行为:

组合视图

探测修饰符

修改器返回不透明的组合视图(因为它们旨在增强它们所应用的视图)。因此,探测修饰符的方式与组合视图相同:

struct Modifier_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            Color.red
            Text("Test")
        }
        .border(Color.green, width: 3)
        .modifier(...)
        .border(Color.blue, width: 3)
        .previewLayout(.fixed(width: 1000, height: 1000))
    }
}

通过此方法,可以验证应用frame(maxWidth: .infinity)修改器会创建具有中性垂直行为的扩展水平框架:

框架修改器

View/frame(minWidth:idealWidth:maxWidth:minWeight:idealHeight:maxHeight:alignment:)通过探测maxWidth和的其他值的行为来彻底探测修饰符maxHeight。观察到的行为在“视图分类”部分中进行了表征。

不明确的布局

与自动布局相比,SwiftUI 的最大优势之一是布局永远不会中断。每个经验丰富的 UIKit 开发人员都经历过当布局过度或不确定时自动布局失败的情况,产生不可预测的结果并将可怕的消息记录到控制台。SwiftUI 永远不会发生这种情况。

但这并不意味着 SwiftUI 布局中不存在歧义。当 SwiftUI 布局引擎遇到歧义时,它只是分配一个神奇的值 10 来查看它无法正确确定的尺寸。布局成功并且没有报告任何问题,尽管获得的结果当然不是预期的结果。

当父视图想要根据其子视图的大小调整自身大小,但这些子视图表现出扩展行为并因此想要匹配父视图大小时,通常会发生这种情况。这就产生了先有鸡还是先有蛋的问题,SwiftUI 通过用 10 替换未确定的大小来解决这个问题,如下面简单的代码所示:

struct Undetermined_Size_Previews: PreviewProvider {
    static var previews: some View {
        Color.red
            .fixedSize()
    }
}

由于Color具有 h-exp 和 v-exp 行为,View/fixedSize()修改器无法计算出它需要应用的内在大小,使用 10 作为两个方向的后备。

因此,当您看到某个大小为 10 的值由于未知原因在布局中的某个位置弹出时,这通常意味着所涉及的视图及其父级存在类似的先有鸡还是先有蛋的问题。没有什么会破坏或引发异常,但您仍然应该首先看看为什么问题存在并消除相关的歧义,例如通过应用框架修改器来为子级提供明确定义的大小。

视图层次结构中的大小调整行为

布局是通过将具有各种大小调整行为的简单视图和组合视图组装在一起来创建的。不过,仅与组合视图相关,中性大小调整行为是扩展或拥抱行为的占位符。因此,在您了解某些布局在实践中如何工作之前,您必须根据其子级的行为确定某些中性行为会转换为哪种行为。

寻找中立行为的真实本质通常是通过自上而下/自下而上的方式实现的。从具有未确定的中性行为的组合视图开始,您考虑其子级的行为,当您遇到具有组合行为的另一个视图时,递归地应用相同的策略。一旦知道组合视图中包含的所有子视图的行为,就可以确定父视图本身的行为。

这个过程可能看起来很麻烦,但幸运的是在大多数情况下都是理论上的。由于 SwiftUI 视图通常是小型可重用单元,其行为是已知的或可以快速确定,因此上述过程实际上应该只涉及简要查看某些组合视图的子视图以猜测其整体行为。

为了加快识别中性行为的过程,在代码中记录自定义视图可能仍然有用,以便可以从文档中快速猜测出它们的行为,例如:

/// Intrinsic sizing behavior: h-exp, v-exp
struct CalendarView: View {
    // ...
}

/// Intrinsic sizing behavior: h-exp, v-hug
struct SegmentedControl: View {
    // ...
}

// Tag is a "Dual-Category View", see corresponding paragraph in the View Taxonomy section
struct Tag<Label: View>: View {
    /// Intrinsic sizing behavior: h-neu, v-neu
    init(@ViewBuilder label: @escaping () -> Label) {
        // ...
    }
    
    /// Intrinsic sizing behavior: h-hug, v-hug
    init(_ titleKey: LocalizedStringKey) where Label == Text {
        // ...
    }
}

extension View {
    /// Intrinsic sizing behavior: h-neu, v-neu
    func ornatePictureFrame() -> some View {
        // ...
    }
}

改变尺寸行为

当 SwiftUI 视图的行为不是您想要的行为时,您无法直接更改其属性,因为视图本身是值类型,因此是不可变的。相反,您可以将行为不令人满意的视图包装到另一个视图中以获得所需的行为。这通常是使用一些公共组合视图(例如堆栈)或应用修饰符来实现的。

您可以参考“视图分类”部分来帮助您确定哪个修饰符有助于实现所需的行为。

布局优先级

布局优先级不会改变视图的大小调整行为。它们仅被组合的父视图用来决定应该首先为哪个子视图提出尺寸。

因此,本文将不再进一步讨论布局优先级。您可以阅读John Sundell 的专门文章,了解有关此主题的更多信息。

调整 SwiftUI 视图的大小

如果您在 UIKit 中包装 SwiftUI 视图,您可能有兴趣了解 SwiftUI 视图需要的大小,具体取决于它可以提供的空间。例如,如果视图包含可变数量的文本,并且您想要提前为集合布局提供匹配的大小,这会很有帮助。

幸运的是,UIHostingController提供了一种sizeThatFits(in:)计算视图固有大小的方法,实际上该UIView/sizeThatFits(:)方法将该方法应用于其关联的视图。

请注意,如果您的 SwiftUI 布局取决于尺寸类,您应该在计算尺寸时将尺寸类注入到环境中,以便您探测的视图采用正确的行为:

extension View {
    func adaptiveSizeThatFits(in size: CGSize, for horizontalSizeClass: UIUserInterfaceSizeClass) -> CGSize {
        let hostController = UIHostingController(rootView: self.environment(.horizontalSizeClass, UserInterfaceSizeClass(horizontalSizeClass)))
        return hostController.sizeThatFits(in: size)
    }
}

您可以使用它UIView.layoutFittingExpandedSize来计算某些 SwiftUI 视图所需的大小。例如,SomeView对于常规尺寸类别,您将如何计算水平限制为 800px 的高度:

let fittingSize = CGSize(width: 800, height: UIView.layoutFittingExpandedSize.height)
let height = SomeView().adaptiveSizeThatFits(in: fittingSize, for: .regular).height

如果SomeView有拥抱行为,则返回匹配的高度。但是,如果视图具有扩展行为,则返回的高度将等于提供的尺寸UIView.layoutFittingExpandedSize.height

评论

您可以使用和来探测视图大小调整行为,而不是上述的视觉方法。我更喜欢视觉方法,但这里有一个粗略的想法,如何使用 Swift Playground 检查 a 在所有方向上是否具有中性行为:UIHostingController``sizeThatFits(in:)``VStack

struct ComposedViewExpandingTest: View {
    var body: some View {
        VStack {
            Color.red
        }
    }
}

struct ComposedViewHuggingTest: View {
    var body: some View {
        VStack {
            Text("Test")
        }
    }
}

extension View {
    func probedSize() -> CGSize {
        let hostController = UIHostingController(rootView: self)
        return hostController.sizeThatFits(in: UIView.layoutFittingExpandedSize)
    }
}

let expandingSize = ComposedViewExpandingTest().probedSize()
let huggingSize = ComposedViewHuggingTest().probedSize()

let hNeutral = huggingSize.width != UIView.layoutFittingExpandedSize.width && expandingSize.width == UIView.layoutFittingExpandedSize.width
let vNeutral = huggingSize.height != UIView.layoutFittingExpandedSize.height && expandingSize.height == UIView.layoutFittingExpandedSize.height

UIViewRepresentable 和 UIViewControllerRepresentable

UIViewRepresentable在考虑使用或实现的视图的大小调整行为时,需要特别考虑UIViewControllerRepresentable。这些考虑因素超出了本文的范围,将在另一篇文章中讨论。

布局建议

创建布局时,您应该考虑所涉及视图的大小调整行为,并根据需要更改它们,例如使用框架修改器。您可以参考本文末尾的视图分类法来预先猜测结果的行为。

为了避免混乱的布局,我建议:

  • 通过记录的大小调整行为将视图层次结构分解为更小的视图。
  • 避免使用几何阅读器,只有在没有其他可能性来实现您想要的目标时才应考虑几何阅读器。通常可以使用框架修改器以更简洁的方式进行视图定位。
  • 避免使用固定Spacers 在堆栈中插入间距。一般来说,您可以使用填充和嵌套堆栈以及适当的间距设置来获得更好的结果。

长话短说

SwiftUI 引入了一个强大的布局系统,依赖于父视图和子视图之间的大小协商。孩子们最终会根据三种可能的内在尺寸行为来选择他们想要的尺寸:扩展、拥抱和中性,水平和垂直方向可能不同。

不过,层次结构中涉及的视图实际上仅表现出扩展或拥抱行为。布局系统或您在创建或检查布局时有责任确定中性行为最终如何转化为层次结构中的扩展或拥抱行为。

为了更快地分析视图层次结构并了解向现有层次结构添加视图可能如何影响整体结果,记录自定义视图以便可以快速读取其内在行为可能会有所帮助。虽然必须对自定义视图执行此过程,但可以对 SwiftUI 内置视图执行此过程,以提供其各自行为的概述。

查看分类法

以下分类列出了大多数 SwiftUI 内置视图的固有大小调整行为,可在检查或构建布局时用作参考。

每个视图均使用本文中概述的过程之一进行探测。结果被合并并呈现在几个表格中,对具有相似目的的视图进行分组。

请注意,表不仅列出了公共视图类型(如 )的行为Text,还列出了由修饰符(如Image/resizable(capInsets:resizingMode:)或)返回的不透明类型的行为View/frame(width:height:)

双类别视图

某些视图可以是简单视图,也可以是组合视图,具体取决于它们的实例化方式。使用此类视图时应该特别小心,因为简单地向它们添加尾随闭包可能会将简单视图更改为具有完全不同布局行为的组合视图(通常从拥抱行为切换到中性行为)。

双类别视图最著名的是LabelLinkProgressViewSliderStepperToggle

基础的组件

构建任何类型的布局时通常使用以下视图。

类型类别水平的垂直的
ColorSimpleexpexp
DividerSimpleexphug
ImageSimplehughug
Image来自resizable(capInsets:resizingMode:)修饰符Simpleexpexp
SecureFieldSimpleexphug
TextSimplehughug
TextEditorSimpleexpexp
TextFieldSimpleexphug

按钮

可以使用修饰符控制按钮样式Button/buttonStyle(_:),从而产生不同的行为。

类型类别水平的垂直的评论
Button(default)Composedneuneu没有风格或DefaultButtonStyle。对应于PlainButtonStyleiOS 和BorderedButtonStyletvOS
Button(plain)ComposedneuneuPlainButtonStyle。tvOS 上未应用任何内容插入
Button(有边框bordered,仅限 tvOS)ComposedneuneuBorderedButtonStyle。应用了一些内容插图
Button(仅限card、tvOS)ComposedhughugCardButtonStyle。⚠️ 此按钮调用View/fixedSize(horizontal:vertical:)其内容

Link

可以使用标题String或自定义标签视图创建链接。

类型类别水平的垂直的
LinkSimplehughug
LinkLabel: ViewComposedneuneu

Label

可以使用标题String和图像/系统图像,或使用标题和图标的两个自定义视图来创建标签。

类型类别水平的垂直的
LabelSimplehughug
LabelTitle: ViewIcon: ViewComposedneuneu

Stack

Stack是水平和垂直排列视图的重要组件。

类型类别水平的垂直的
HStackComposedneuneu
VStackComposedneuneu
ZStackComposedneuneu
LazyHStackComposedhugexp
LazyVStackComposedexphug

请注意,惰性堆栈与标准堆栈的大小调整行为非常不同。因此,当您用惰性堆栈替换堆栈时,应该特别小心,因为这会改变其大小调整行为,并且可能需要进行一些布局调整。

Slider

可以使用或不使用关联的自定义标签视图来创建滑块。

类型类别水平的垂直的评论
SliderSimpleexphug 
SliderLabel: ViewComposedexphug标签仅用于可访问性;不参与布局

ProgressView

可以使用或不使用关联的自定义标签视图来创建进度视图。他们的风格可以用修饰符控制View/progressViewStyle(_:),从而产生不同的行为。

类型类别水平的垂直的评论
ProgressView(linear线性)Simpleexphug没有风格,DefaultProgressViewStyle或者LinearProgressViewStyle
ProgressView(linear线性)与Label: ViewComposedexphug没有风格,DefaultProgressViewStyle或者LinearProgressViewStyle。标签仅用于可访问性;不参与布局
ProgressView(circular圆形)SimplehughugCircularProgressViewStyle
ProgressView(circular圆形)与Label: ViewComposedneuneuCircularProgressViewStyle。标签显示在下方

Stepper

可以使用 titleString或标题的自定义视图来创建Stepper。

类型类别水平的垂直的
StepperSimpleexphug
StepperLabel: ViewComposedexpneu

Toggle

可以使用 titleString或标题的自定义视图创建Toggle。

类型类别水平的垂直的
ToggleSimpleexphug
ToggleLabel: ViewComposedexpneu

形状

有几种内置形状可用。可以通过实施该协议来创建自定义形状Shape。所有人都有向各个方向扩展的行为。

类型类别水平的垂直的
CapsuleSimpleexpexp
CircleSimpleexpexp
EllipseSimpleexpexp
RectangleSimpleexpexp
RoundedRectangleSimpleexpexp
Shape(custom)Simpleexpexp

Spacer

Spacer始终很灵活,并且只能在堆叠内使用。您可以使用修改器创建固定大小的Spacer,但通常View/frame(width:height:)最好避免这样做。

类型类别水平的垂直的
SpacerSimpleexpexp

View

修改View/frame(width:height:alignment:)器用于限制在任何或所有方向上为其接收者提供的空间。

width/height争论获得水平/垂直方向的行为
Omitted 省略neu
Finite value 有限值hug

如果省略某个方向的参数,则帧包装器会透明地采用与该方向接收器相同的行为。

max

修改View/frame(minWidth:idealWidth:maxWidth:minWeight:idealHeight:maxHeight:alignment:)器用于约束空间或在某个方向上创建不可见的最大框架,让各种对齐方式应用于其中绘制的视图。

maxWidth/maxHeight争论获得水平/垂直方向的行为
Omitted 省略neu
Finite value 有限值hug
.infinityexp

如果省略某个方向的参数,则帧包装器会透明地采用与该方向接收器相同的行为。

请注意,只有最大参数才会改变框架的大小调整行为。仅当框架具有拥抱行为(有限最大参数)时才考虑最小参数和理想参数以选择最佳可能尺寸。

aspectRatio

可以使用修改器将视图强制为给定的纵横比View/aspectRatio(_:contentMode:)。此修改器不会更改接收器的大小行为,但如果接收器在所有方向上扩展,它保证它适合或填充父视图,同时调整另一个方向以满足所需的宽高比和内容模式。

类型类别水平的垂直的
View/aspectRatio(_:contentMode:)Composedneuneu

纵横比是可选参数。如果省略,则使用接收器的固有宽高比。

评论

接收器的固有纵横比是根据其固有尺寸计算的。正如模糊布局部分中所讨论的,如果无法确定接收器的某些固有尺寸,SwiftUI 会将其替换为神奇值 10。

固定尺寸

View/fixedSize(horizontal:vertical:)可以使用任意方向的修改器将视图强制为其固有大小,采用拥抱行为。如果不是,视图行为将保留在相应的方向上。

horizontal/vertical争论获得水平/垂直方向的行为
true 真的hug
false 错误的neu

正如模糊布局部分中所讨论的,如果无法确定接收器的某些固有尺寸,SwiftUI 会将其替换为神奇值 10。

常用装饰器

这些视图在各个方向上采用接收者的行为并对其进行装饰。

类型类别水平的垂直的
View/border(_:width:)Composedneuneu
View/background(_:alignment:)Composedneuneu
View/overlay(_:alignment:)Composedneuneu
View/offset(_:)View/offset(x:y:)Composedneuneu