SwiftUI 中,尺度这一布局中极为重要的概念,似乎变得有些奥秘。无论是设置尺度仍是获取尺度都不是那么地契合直觉。本文将从布局的视点入手,为你揭开盖在 SwiftUI 尺度概念上面纱,了解并把握 SwiftUI 中很多尺度的含义与用法;并经过创立契合 Layout 协议的 frame 和 fixedSize 视图润饰器的复制品,让你对 SwiftUI 的布局机制有愈加深化地了解。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

尺度 —— 一个故意被淡化的概念

SwiftUI 是一个声明式结构,供给了强大的主动布局才能。开发者几乎可以在不触及尺度( 或很少触及 )这一概念的状况下创立出漂亮、精美、准确的布局效果。

但因为 SwiftUI 的视图并没有供给尺度这一属性,因而即便在 SwiftUI 诞生了数年后的今天,怎么获取视图的尺度仍然是网络上的抢手问题。一起关于不少的开发者来说,运用 frame 润饰器为视图设置尺度发生的成果也经常与他们的预期有所不同。

这并非意味着尺度在 SwiftUI 中不重要,事实恰恰相反,正是因为在 SwiftUI 中尺度是一个十分杂乱的概念,苹果将绝大大都有关尺度的装备和表述都隐藏到了引擎盖之下,故意对其进行了包装与淡化。

淡化尺度概念的初衷或许是出于以下两点:

  • 引导开发者转型到声明式编程逻辑,转变运用精准尺度的习惯
  • 掩盖 SwiftUI 中杂乱的尺度概念,削减初学者的困扰

但无论怎么淡化或掩盖,当触及愈加高级、杂乱、精准的布局时,尺度是一个一直无法绕开的环节。随着你对 SwiftUI 知道的提高,了解并把握 SwiftUI 中的很多尺度含义也势在必行。

SwiftUI 布局进程速览

SwiftUI 的布局就是布局体系经过为视图树上的节点供给必要的信息,最终核算出每个视图( 矩形 )所需的尺度以及摆放方位的行为。

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)

SwiftUI 布局 —— 尺寸( 上 )

视图尺度现已是布局完结之后的产品了,在没有 Layout 协议之前,开发者只能经过获取当时视图以及子视图的视图尺度来完结自界说布局。不仅功能较差,并且一旦规划有误或许会导致视图的循环改写,从而形成程序崩溃。经过 Layout 协议,开发者可以站在上帝的视角,利用主张尺度、需求尺度、布局尺度等信息沉着地进行布局。

抱负尺度

抱负尺度( ideal size )特指在主张尺度为未指定形式下回来的需求尺度。例如在上文中,SwiftUI 为一切的 Shape 设置的默认抱负尺度为 10 x 10 ,Text 默认的抱负尺度为单行完好显现悉数内容所需的尺度。

咱们可以运用 frame(idealWidth:CGFloat, idealHeight:CGFloat) 为视图设置抱负尺度,并运用 fixedSize 为视图的特定维度供给未指定形式的主张尺度,以使其在该维度上将抱负尺度作为其需求尺度。

在编撰本文之前,我发了个 推文,问询咱们对 fixedSize 的了解:

SwiftUI 布局 —— 尺寸( 上 )

SwiftUI 布局 —— 尺寸( 上 )

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

SwiftUI 布局 —— 尺寸( 上 )

在了解了抱负尺度之后,我想咱们应该可以推断出推文中以及上面代码的布局成果了吧。

尺度的运用

在上文中,咱们现已提及了不少在视图中设置或获取尺度的东西和手段,现做以下汇总:

  • frame(width: 50, height: 50)

    为子视图供给 50 x 50 的主张尺度,并将 50 x 50 作为需求尺度回来给父视图

  • fixedSize()

    为子视图供给未指定形式的主张尺度

  • frame(minWidth: 100, maxWidth: 300)

    将子视图的需求尺度控制在指定的范围中,并将调整后的尺度作为需求尺度回来给父视图

  • frame(idealWidth: 100, idealHeight: 100)

    假如当时视图收到为未指定形式的主张尺度,则回来 100 x 100 的需求尺度

  • GeometryReader

    将主张尺度作为需求尺度直接回来( 充满悉数可用区域 )

接下来

在上篇中,咱们对 SwiftUI 中的各种尺度概念做了介绍,在下篇中咱们将经过创立 frame、fixedSize 的复制品进一步提升咱们对 SwiftUI 不同尺度概念的了解和把握。

可在此处获取 下篇的代码,提早对内容有所了解。

希望本文可以对你有所协助。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

我正在参加技术社区创作者签约方案招募活动,点击链接报名投稿。