SwiftUI Layout - Dimensions (Top)

In SwiftUI, size, a very important concept in layout, seems to have become a bit mysterious. Neither setting the size nor getting the size is so intuitive. This article will start from the perspective of layout, lift the veil over the SwiftUI size concept for you, understand and master the meaning and usage of many sizes in SwiftUI; and by creating a replica of the frame and fixedSize view decorators that conform to the Layout protocol, let You have a deeper understanding of SwiftUI's layout mechanics.

The original text was published on my blog  wwww.fatbobman.com

Welcome to subscribe to my public account: [Elbow's Swift Notepad]

Size - a concept that has been deliberately downplayed

SwiftUI is a declarative framework that provides powerful auto-layout capabilities. Developers can create beautiful, beautiful, accurate layouts with almost no (or very little) involvement in the concept of size.

But since SwiftUI's views don't provide the property of size, how to get the view's size is still a hot issue on the web even today, a few years after SwiftUI was born. At the same time, for many developers, the results of using the frame decorator to set the size of the view are often different from their expectations.

This does not mean that size is not important in SwiftUI, on the contrary, it is precisely because size is a very complex concept in SwiftUI that Apple hides most of the configuration and representation of size under the hood. It is packaged and watered down.

The original intention of downplaying the concept of size may be due to the following two points:

  • Guide developers to transition to declarative programming logic and change the habit of using precise dimensions
  • Cover up the complex size concept in SwiftUI and reduce the confusion for beginners

But no matter how it is diluted or concealed, when it comes to more advanced, complex, and precise layouts, size is an inescapable link. As your knowledge of SwiftUI improves, it is imperative to understand and master the many dimensions in SwiftUI.

A Quick Look at the SwiftUI Layout Process

The layout of SwiftUI is the behavior of the layout system to finally calculate the required size and placement of each view (rectangle) by providing the necessary information for the nodes on the view tree.

struct ContentView: View {
    var body: some View {
        ZStack {
            Text("Hello world")
        }
    }
}
// ContentView
//     |
//     |———————— ZStack
//                 |
//                 |—————————— Text
复制代码

以上面的代码为例( ContentView 为应用的根视图 ),我们简述一下 SwiftUI 的布局过程( 当前设备为 iPhone 13 Pro ):

  1. SwiftUI 的布局系统为 ZStack 提供一个建议尺寸( 390 x 763 该尺寸为设备屏幕尺寸去掉安全区域的大小 ),并询问 ZStack 的需求尺寸

  2. ZStack 为 Text 提供建议尺寸( 390 x 763 ),并询问 Text 的需求尺寸

  3. Text 根据 ZStack 提供的建议尺寸,返回了自己的需求尺寸( 85.33 x 20.33 ,因为 ZStack 提供建议尺寸大于 Text 的实际需求,因此 Text 的需求尺寸为对文本不折行,不省略的完整显示尺寸)

  4. ZStack 向 SwiftUI 的布局系统返回了自己的需求尺寸( 85.33 x 20.33,因为 ZStack 中仅有 Text 一个子视图,因此 Text 的需求尺寸便是 ZStack 的需求尺寸 )

  5. SwiftUI 的布局系统将 ZStack 放置在了 152.33, 418.33 处,并为其提供了渲染尺寸( 85.33 x 20.33 )

  6. ZStack 将 Text 放置在了 152.33, 418.33 处,并为其提供了渲染尺寸( 85.33 x 20.33 )

布局过程基本上分为两个阶段:

  • 第一阶段 —— 讨价还价

    在这个阶段,父视图为子视图提供建议尺寸,子视图为父视图返回需求尺寸( 上方的 1-4 )。在 Layout 协议中,对应的是 sizeThatFits 方法。经过该阶段的协商,SwiftUI 将确定视图所在屏幕上的位置和尺寸。

  • 第二阶段 —— 安置子民

    在该阶段,父视图将根据 SwiftUI 布局系统提供的屏幕区域( 由第一阶段计算得出 )为子视图设置渲染的位置和尺寸( 上方的 5-6 )。在 Layout 协议中,对应的是 placeSubviews 方法。此时,视图树上的每个视图都将与屏幕上的具体位置联系起来。

讨价还价的次数与视图结构的复杂度成正比,整个的协商过程可能会反复出现多次甚至推倒重来的情况。

容器与视图

在阅读 SwiftUI 布局系列文章时,大家可能会对其中某些称谓产生困惑。一会儿父视图、一会儿布局容器,到底它们之间是什么关系,是不是同一个东西?

在 SwiftUI 中,只有符合 View 协议的 component 才能被 ViewBuilder 所处理。因此任何一种布局容器,最终都会被包装并以 View 的形式出现在代码中。

例如,下面是 VStack 的构造函数,content 被传递给了真正的布局容器 _VStackLayout 进行布局:

public struct VStack<Content>: SwiftUI.View where Content: View {
    internal var _tree: _VariadicView.Tree<_VStackLayout, Content>
    public init(alignment: SwiftUI.HorizontalAlignment = .center, spacing: CoreFoundation.CGFloat? = nil, @SwiftUI.ViewBuilder content: () -> Content) {
        _tree = .init(
            root: _VStackLayout(alignment: alignment, spacing: spacing), content: content()
        )
    }
    public typealias Body = Swift.Never
}
复制代码

除了我们熟悉的 VStack、ZStack、List 等布局视图外,在 SwiftUI 中,大量的布局容器是以视图修饰器的形式存在的。例如,下面是 frame 在 SwiftUI 中的定义:

public extension SwiftUI.View {
    func frame(width: CoreFoundation.CGFloat? = nil, height: CoreFoundation.CGFloat? = nil, alignment: SwiftUI.Alignment = .center) -> some SwiftUI.View {
        return modifier(
            _FrameLayout(width: width, height: height, alignment: alignment))
    }
}

public struct _FrameLayout {
    let width: CoreFoundation.CGFloat?
    let height: CoreFoundation.CGFloat?
    init(width: CoreFoundation.CGFloat?, height: CoreFoundation.CGFloat?, alignment: SwiftUI.Alignment)
    public typealias Body = Swift.Never
}
复制代码

_FrameLayout 被包装成 viewModifier ,作用于给定的视图。

Text("Hi")
    .frame(width: 100,height: 100)

// 可以被视为

_FrameLayou(width: 100,height: 100,alignment: .center) {
    Text("Hi")
}
复制代码

此时 _FrameLayout 即是 Text 的父视图,也是布局容器。

对于不包含子视图的视图来说( 例如 Text 这类的元视图 ),它们同样会提供接口供父视图来调用以向其传递建议尺寸并获取其需求尺寸。虽然当前 SwiftUI 中绝大多数的视图并不遵循 Layout 协议,但从 SwiftUI 诞生之始,其布局系统便是按照 Layout 协议提供的流程进行布局操作的,Layout 协议仅是将内部的实现过程包装成开发者可以调用的接口,以方便我们进行自定义布局容器的开发。

因此,为了简化文字,我们在文章中会将父视图与具备布局能力的容器等同起来。

不过需要注意的是,在 SwiftUI 中,有一类视图是会在视图树上显示为父视图,但并不具备布局能力。其中的代表有 Group、ForEach 等。这类视图的主要作用有:

  • 突破 ViewBuilder Block 的数量限制
  • 方便为一组视图统一设置 view modifier
  • 有利于代码管理
  • 其他特殊应用,如 ForEach 可支持动态数量的子视图等

例如在本文最初的例子中,SwfitUI 会将 ContentView 视作类似 Group 的存在。这类视图本身并不会参与布局,SwiftUI 的布局系统会在布局时自动将它们忽略,让其子视图与具备布局能力的祖先视图直接联系起来。

SwiftUI 中的尺寸

如上文中所示,在 SwiftUI 的布局过程中,在不同的阶段、出于不同的用途,尺寸这一概念是在不断地变化的。本节将结合 SwiftUI 4.0 中的 Layout 协议对布局过程涉及的尺寸做更详细的介绍。

即使你对 Layout 协议不了解或短时间无法使用 SwiftUI 4.0 ,并不会影响你对下文的阅读和理解。尽管 Layout 协议的主要用途是让开发者创建自定义布局容器,且在 SwiftUI 中仅有少数的视图符合该协议,但从 SwiftUI 1.0 开始,SwiftUI 视图的布局机制便基本与 Layout 协议所实现的流程一致。可以说 Layout 协议是一个用来观察和验证 SwiftUI 布局运作原理的优秀工具。

建议尺寸

SwiftUI 的布局是从外向内进行的。布局过程的第一个步骤便是由父视图为子视图提供建议尺寸( Proposal Size)。顾名思义,建议尺寸是父视图为子视图提供的建议,子视图在计算其需求尺寸时是否考虑建议尺寸完全取决于它自己的行为设定。

以子视图为符合 Layout 协议的自定义布局容器举例,父视图通过调用子视图的 sizeThatFits 方法提供建议尺寸。建议尺寸的类型为 ProposedViewSize,它的宽和高均为 Optional<CGFloat> 类型。而该自定义布局容器又会在它的 sizeThatFits 方法中通过调用其子视图代理( Subviews,子视图在 Layout 协议中的表现方式 )的 sizeThatFits 方法为子视图代理提供建议尺寸。建议尺寸在布局的两个阶段(讨价还价、安置子民)均会提供,但通常我们只需在第一个阶段使用它( 可以在第一阶段用 catch 保存中间的计算数据,减少第二阶段的计算量 )。

// 代码来自 My_ZStackLayout

// 容器的父视图(父容器)将通过调用容器的 sizeThatFits 获取容器的需求尺寸,本方法通常会被多次调用,并提供不同的建议尺寸
func sizeThatFits(
    proposal: ProposedViewSize, // 容器的父视图(父容器)提供的建议尺寸
    subviews: Subviews, // 当前容器内的所有子视图的代理
    cache: inout CacheInfo // 缓存数据,本例中用于保存子视图的返回的需求尺寸,减少调用次数
) -> CGSize {
    cache = .init() // 清除缓存
    for subview in subviews {
        // 为子视图提供建议尺寸,获取子视图的需求尺寸 (ViewDimensions)
        let viewDimension = subview.dimensions(in: proposal)
        // 根据 MyZStack 的 alignment 的设置获取子视图的 alignmentGuide
        let alignmentGuide: CGPoint = .init(
            x: viewDimension[alignment.horizontal],
            y: viewDimension[alignment.vertical]
        )
        // 以子视图的 alignmentGuide 为 (0,0) , 在虚拟的画布中,为子视图创建 CGRect
        let bounds: CGRect = .init(
            origin: .init(x: -alignmentGuide.x, y: -alignmentGuide.y),
            size: .init(width: viewDimension.width, height: viewDimension.height)
        )
        // 保存子视图在虚拟画布中的数据
        cache.subviewInfo.append(.init(viewDimension: viewDimension, bounds: bounds))
    }

    // 根据所有子视图在虚拟画布中的数据,生成 MyZtack 的 CGRect
    cache.cropBounds = cache.subviewInfo.map(\.bounds).cropBounds()
    // 返回当前容器的理想尺寸,当前容器的父视图将使用该尺寸在它的内部进行摆放
    return cache.cropBounds.size
}
复制代码

根据建议尺寸内容的不同,我们可以将建议尺寸细分为四种建议模式,在 SwiftUI 中,父视图会根据它的需求选择合适的建议模式提供给子视图。由于可以在宽度和高度上分别选择不同的模式,因此建议模式特指在一个维度上所提供的建议内容。

  • 最小化模式

    该维度的建议尺寸为 0 。ProposedViewSize.zero 表示两个维度都为最小化模式的建议尺寸。某些布局容器(比如 VStack、HStack ),会通过为其子视图代理提供最小化模式的建议尺寸以获取子视图在特定维度下的最小需求尺寸( 例如对视图使用了 minWidth 设定 )

  • 最大化模式

    该模式的建议尺寸为 CGFloat.infinity 。ProposedViewSize.infinity 表示两个维度都为最大化模式的建议尺寸。当父视图想获得子视图在最大模式下的需求尺寸时,会为其提供该模式的建议尺寸

  • 明确尺寸模式

    非 0 或 infinity 的数值。比如在上文的例子中,ZStack 为 Text 提供了 390 x 763 的建议尺寸。

  • 未指定模式

    nil,不设置任何数值。ProposedViewSize.unspecified 表示两个维度都为未指定模式的建议尺寸。

为子视图提供不同的建议模式的目的是获得在该模式下子视图的需求尺寸,具体使用哪种模式,完全取决于父视图的行为设定。例如:ZStack 会将其父视图提供给它的建议模式直接转发给 ZStack 的子视图,而 VStack、HStack 则会要求子视图返回全部模式下的需求尺寸,以判断子视图是否为动态视图( 在特定维度可以动态调整尺寸 )。

在 SwiftUI 中,通过设置或调整建议模式而进行二次布局的场景很多,比较常用的有:frame、fixedSize 等。比如,下面的代码中,frame 便是无视 VStack 提供建议尺寸,强行为 Text 提供了 50 x 50 的建议尺寸。

VStack {
    Text("Hi")
       .frame(width: 50,height: 50)
}
复制代码

需求尺寸

在子视图收到了父视图的建议尺寸后,它将根据建议模式和自身行为特点返回需求尺寸。需求尺寸的类型为 CGSize 。在绝大多数情况下,自定义布局容器( 符合 Layout 协议)在布局第一阶段最终返回的需求尺寸与第二阶段 SwiftUI 布局系统传递给它的屏幕区域( CGRect )的尺寸一致。

// 代码来自 FixedSizeLayout
// 根据建议尺寸返回需求尺寸
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    guard subviews.count == 1, let content = subviews.first else {
        fatalError("Can't use MyFixedSizeLayout directly")
    }
    let width = horizontal ? nil : proposal.width
    let height = vertical ? nil : proposal.height
    // 获取子视图的需求尺寸
    let size = content.sizeThatFits(.init(width: width, height: height))
    return size
}
复制代码

比如以下是 Rectangle() 在四种建议模式下返回的结果,以两个维度为同一种模式举例:

  • 最小化模式

    需求尺寸为 0 x 0

  • 最大化模式

    需求尺寸为 infinity * infinity

  • 明确尺寸模式

    需求尺寸为建议尺寸

  • 未指定模式

    需求尺寸为 10 x 10( 至于为什么是 10 x 10 ,下文中的理想尺寸将有更详细的说明 )

Text("Hello world") 在四种建议模式下计算需求尺寸的行为与 Rectangle 则大相径庭:

  • 最小化模式

    当任意维度为最小化模式时,需求尺寸为 0 x 0

  • 最大化模式

    需求尺寸为 Text 的实际显示尺寸( 文本不折行、不省略 ) 85.33 x 20.33( 上文例子中尺寸 )

  • 明确尺寸模式

    如果建议宽度大于单行显示的需要,则需求宽度返回单行实现显示尺寸的宽度 85.33 ;如果建议宽度小于单行显示的需要则需求宽度返回建议尺寸的宽度;如果建议高度小于单行显示的高度,则需求高度返回单行的显示高度 20.33;如果建议高度高于单行显示的高度且宽度大于单行显示的宽度,则需求高度返回单行显示的高度 20.33 ……

  • 未指定模式

    当两个维度均为未指定模式时,需求尺寸为单行完整显示所需的宽和高 85.33 x 20.33

不同的视图,在相同的建议模式及尺寸下会返回不同的需求尺寸这一事实既是 SwiftUI 的特色也是十分容易很让人困扰的地方。不过不用太紧张,需求尺寸总体上来说还是有规律可循的:

  • Shape

    除了未指定模式,其他均与建议尺寸一致

  • Text

    需求尺寸的计算规则较为复杂,需求尺寸取决于建议尺寸和实际完整显示尺寸

  • 布局容器( ZStack 、HStack、VStack 等)

    需求尺寸为容器内子视图按指定对齐指南对齐摆放后( 已处理动态尺寸视图 )的总尺寸,详情请参阅 SwiftUI 布局 —— 对齐

  • 其他控件例如 TextField、TextEditor、Picker 等

    需求尺寸取决于建议尺寸和实际显示尺寸

在 SwiftUI 中,frame(minWidth:,maxWidth:,minHeight:,maxHeight) 便是对子视图的需求尺寸进行调整的典型应用。

渲染尺寸

在布局的第二阶段,当 SwiftUI 的布局系统调用布局容器( 符合 Layout 协议 )的 placeSubviews 方法时,布局容器会将每个子视图放置在给定的屏幕区域( 尺寸通常与该布局容器的需求尺寸一致 )中,并为子视图设置渲染尺寸。渲染尺寸是父视图为子视图设置的用于渲染的建议尺寸。

// 代码来自 FixedSizeLayout
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    guard subviews.count == 1, let content = subviews.first else {
        fatalError("Can't use MyFixedSizeLayout directly")
    }

    // 设置渲染位置及渲染尺寸。
    content.place(at: .init(x: bounds.minX, y: bounds.minY), anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height))
}
复制代码

父视图将根据自身的行为特点以及参考子视图的需求尺寸计算子视图的渲染尺寸,例如:

  • 在 ZStack 中,ZStack 为子视图设置的渲染尺寸与子视图的需求尺寸一致
  • 在 VStack 中,VStack 将根据其父视图提供的建议尺寸、子视图是否为可扩展视图、子视图的视图优先级等信息,为子视图计算渲染尺寸。比如: 当固定高度的子视图的总高度已经超出了 VStack 获得的建议尺寸高度,那么 Spacer 就只能获得高度为 0 的渲染尺寸

多数情况下,渲染尺寸与子视图的最终显示尺寸( 视图尺寸 )一致,但并非绝对。

SwiftUI 没有提供可以在视图中直接处理渲染尺寸的方式( 除了 Layout 协议 ),通常我们会通过对建议尺寸以及需求尺寸的调整,来影响渲染尺寸。

视图尺寸

视图渲染后在屏幕上呈现的尺寸,也是热门提问 —— 如何获取视图的尺寸中所指的尺寸。

在视图中可以通过 GeometryReader 获取特定视图的尺寸及位置。

extension View {
    func printSizeInfo(_ label: String = "") -> some View {
        background(
            GeometryReader { proxy in
                Color.clear
                    .task(id: proxy.size) {
                        print(label, proxy.size)
                    }
            }
        )
    }
}

VStack {
    Text("Hello world")
        .printSizeInfo() // 打印视图尺寸
}
复制代码

另外,我们可以通过 border 视图修饰器更加直观地比对不同层级的视图尺寸:

VStack {
    Text("Hello world")
        .border(.red)
        .frame(width: 100, height: 100, alignment: .bottomLeading)
        .border(.blue)
        .padding()
}
.border(.green)
复制代码

image-20220711134423997

视图尺寸已经是布局完成之后的产物了,在没有 Layout 协议之前,开发者只能通过获取当前视图以及子视图的视图尺寸来实现自定义布局。不仅性能较差,而且一旦设计有误可能会导致视图的循环刷新,进而造成程序崩溃。通过 Layout 协议,开发者可以站在上帝的视角,利用建议尺寸、需求尺寸、渲染尺寸等信息从容地进行布局。

理想尺寸

理想尺寸( ideal size )特指在建议尺寸为未指定模式下返回的需求尺寸。例如在上文中,SwiftUI 为所有的 Shape 设置的默认理想尺寸为 10 x 10 ,Text 默认的理想尺寸为单行完整显示全部内容所需的尺寸。

我们可以使用 frame(idealWidth:CGFloat, idealHeight:CGFloat) 为视图设置理想尺寸,并使用 fixedSize 为视图的特定维度提供未指定模式的建议尺寸,以使其在该维度上将理想尺寸作为其需求尺寸。

在撰写本文之前,我发了个 推文,询问大家对 fixedSize 的了解:

image-20220711140418269

FW9GLjJVsAAmDXX

Text("Hello world")
    .border(.red)
    .frame(idealWidth: 100, idealHeight: 100)
    .fixedSize()
    .border(.green)
复制代码

image-20220711140000421

在了解了理想尺寸之后,我想大家应该能够推断出推文中以及上面代码的布局结果了吧。

尺寸的应用

In the above, we have mentioned a lot of tools and means to set or get the size in the view, and now make the following summary:

  • frame(width: 50, height: 50)

    Give the subview a suggested size of 50 x 50 and return 50 x 50 as the required size to the parent view

  • fixedSize()

    Provide suggested dimensions for subviews with unspecified mode

  • frame(minWidth: 100, maxWidth: 300)

    Control the required size of the subview within the specified range, and return the adjusted size to the parent view as the required size

  • frame(idealWidth: 100, idealHeight: 100)

    Returns the required size of 100 x 100 if the current view receives the suggested size as an unspecified mode

  • GeometryReader

    Return the suggested size directly as the required size (full available area)

next

In the first part, we introduced various size concepts in SwiftUI. In the next part, we will further improve your understanding and mastery of SwiftUI's different size concepts by creating copies of frame and fixedSize.

The code for the next article can be found here for an early look at the content.

Hope this article can help you.

The original text was published on my blog  wwww.fatbobman.com

Welcome to subscribe to my public account: [Elbow's Swift Notepad]

I am participating in the recruitment of the creator signing program of the Nuggets Technology Community, click the link to register and submit .

Guess you like

Origin juejin.im/post/7119263638036152350
top