iOS 16 中,SwiftUI 增加了一个新的自习惯布局容器 ViewThatFits。正如其名称所示,它的作用是在给定的多个视图中找出最适宜的视图并运用。关于大多数人来说,这是一个简略易用的容器。不过,本文打算对其进行彻底的分析,包括规矩细节、抱负尺度的意义、运用示例等。终究,咱们将创立一个复刻版别的 ViewThatFits,以加深对其的认识和了解。

原文宣布在我的博客wwww.fatbobman.com 。 因为技术文章需求不断的迭代,当时耗费了不少的精力在不同的平台之间来保持文章的更新。故从 2024 年起,新的文章将只发布在我的博客上

ViewThatFits 详解

界说

SwiftUI 的官方文档中,对 ViewThatFits 的界说如下:

A view that adapts to the available space by providing the first child view that fits.

一个能够习惯可用空间的视图,它供给的是第一个能够习惯的子视图

public struct ViewThatFits<Content> : View where Content : View {
    public init(in axes: Axis.Set = [.horizontal, .vertical], @ViewBuilder content: () -> Content)
}

ViewThatFits evaluates its child views in the order you provide them to the initializer. It selects the first child whose ideal size on the constrained axes fits within the proposed size. This means that you provide views in order of preference. Usually this order is largest to smallest, but since a view might fit along one constrained axis but not the other, this isn’t always the case. By default, ViewThatFits constrains in both the horizontal and vertical axes.

ViewThatFits 依照你供给给初始化器的次序评估其子视图。它挑选在受限轴上抱负尺度习惯主张尺度的第一个子视图。这意味着你依照优先级次序供给视图。通常这个次序是从最大到最小,但因为一个视图或许在一个受限轴上习惯但在另一个轴上不习惯,所以这并不总是如此。默许状况下,ViewThatFits 在水平缓笔直轴上都进行束缚。

我知道,经过示例代码并调查其运行成果能让你对 ViewThatFits 取得更好的感性认识,但请不要着急,让咱们首要对 ViewThatFits 的判别和出现逻辑进行更多的分析。

ViewThatFits 的判别和出现逻辑

已然 ViewThatFits 是从给定的视图中挑选出最适宜的那个,那么它的判别依据是什么呢?判别的次序怎么?终究又怎么出现呢?

  1. 首要,ViewThatFits 需求获取它所能运用的空间,也便是其父视图给出的主张尺度。
  2. 判别次序依据 ViewBuilder 闭包中的次序,从上至下逐一对子视图进行。
  3. ViewThatFits 向子视图查询其抱负尺度(依据未指定主张尺度回来的需求尺度)。
  4. 依据受限轴的设置,在挑选的受限轴上,比较子视图的抱负尺度和 ViewThatFits 的父视图给出的主张尺度。
  5. 假如在一切设置的受限轴上,抱负尺度都小于等于主张尺度,那么挑选该子视图,并停止对后续子视图进行判别。
  6. 假如一切的子视图都不满意条件,则挑选闭包中的终究一个子视图。
  7. ViewThatFits 将父视图给出的主张尺度作为自己的主张尺度传递给挑选的子视图,并取得该子视图在明晰主张尺度下的需求尺度。
  8. ViewThatFits 将上一步取得的需求尺度作为自己的需求尺度回来给父视图。

一个 ViewThatFits 终究会挑选那个子视图,取决于以下几个要素:

  • ViewThatFits 可用的空间(它的父视图给它的主张尺度)
  • ViewThatFits 设定的受限轴
  • 子视图的在受限轴上的抱负尺度
  • 子视图的摆放次序

任何一个要素发生变化,终究出现的成果都或许会不同。

比方,关于下面的代码,用更符合开发者片面目标的言语来描述便是:

ViewThatFits(in: .horizontal) {
    Text("Hello Beautiful World")
    Text("Hello World")
    Text("Hi")
}

ViewThatFits 会挑选闭包中首个在其给定的宽度内用不折行的方法完好显现的 Text 视图。

因而,当咱们将上述代码放置在不同的上下文中时,它终究出现的子视图(挑选的子视图)或许会有所不同。

ViewThatFits(in: .horizontal) {
    Text("Hello Beautiful World") // 100 < width < 200
    Text("Hello World") //  20 < width < 100
    Text("Hi") // 10 < width < 20
}
.border(.blue) // required size of ViewThatFits
.frame(width:100)
.border(.red) // proposed size from parent View

把握 ViewThatFits

在宽度只要 100 的状况下,终究显现的是 Text("Hello World")。当宽度调整为 200 时,它将显现为 Text("Hello Beautiful World")

把握 ViewThatFits

让咱们增加一点难度,运用 .frame(width:10) 将 ViewThatFits 的可用尺度(父视图给它的主张尺度)设置为 10。依据代码中注释标注的不同 Text 的宽度,终究的出现会是什么姿态呢?

ViewThatFits(in: .horizontal) {
    Text("Hello Beautiful World") // 100 < width < 200
    Text("Hello World") //  20 < width < 100
    Text("Hi") // 10 < width < 20
}
.border(.blue) // required size of ViewThatFits
.frame(width:10)
.border(.red) // proposed size from parent View

把握 ViewThatFits

咱们开始的意图是挑选一个适宜给定尺度而且不会主动换行的文本。为什么终究会变成这个姿态呢?

首要,ViewThatFits 经过逐一比对,发现闭包中没有任何一个 Text 的抱负尺度宽度不大于 10 ,因而它挑选了终究一个 Text("Hi") 。此刻 Text("Hi") 只取得了宽度为 10 的主张尺度。依据 Text 的默许显现规矩(显现不下就折行),它用了两行才干将 Hi 悉数显现完。

由此能够看出,ViewThatFits 本身在终究出现时,并不对子视图施加抱负尺度的约束。它只在检查阶段运用子视图的抱负尺度进行判别,在终究出现阶段,它将向子视图提交有值的主张尺度,并运用子视图的需求尺度作为本身的需求尺度。

为了应对这种极端状况(文字折行),咱们需求对子视图进行特别的设定,例如经过 fixedSize 强制展现完好内容(终究的显现尺度或许会超越父视图给出的主张尺度):

Text("Hi")
    .fixedSize(horizontal: true, vertical: false)

把握 ViewThatFits

或者能够运用 lineLimit 来约束其在笔直方向上只能运用 1 行的空间,但无法保证彻底显现悉数内容:

Text("Hi")
    .lineLimit(1)

把握 ViewThatFits

好吧,我承认,我是成心将问题杂乱化的。要真实正确地运用 ViewThatFits,咱们有必要充沛了解它的判别、出现逻辑,而且把握“抱负尺度”的概念。不然,很或许会面临与预期不共同的状况。

抱负尺度( Ideal Size )

在 SwiftUI 中,相较于主张尺度,很多开发者对抱负尺度触摸的较少,了解的也不太深化。

就布局而言,”抱负尺度”指的是当父视图以未指定的形式供给主张尺度时,视图回来的需求尺度。

用更简单了解的言语来说,抱负尺度便是一个视图在不给其任何尺度约束(抱负的外部环境)的状况下,其最抱负的出现成果所占用的尺度。

关于不同种类的视图,它们的抱负出现处理规矩是不同的

例如:

  • Rectangle:在抱负状况的轴上只运用 10(一切 Shape 都遵从该规矩)。
  • Text:在抱负状况的轴上占用尽或许多的空间,展现悉数文本(不进行任何截取)。
  • ScrollView:假如抱负状况的轴与翻滚方向共同,则在翻滚方向上一次性展现一切的子视图而无视父视图的主张尺度。
  • VStack、HStack、ZStack:一切子视图在抱负状况下的整体出现。

在 SwiftUI 中,咱们能够经过 fixedSize 来强制一个视图以抱负尺度进行出现:

struct IdealSizeDemo: View {
    var body: some View {
        VStack {
            Text("GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
                .fixedSize()
            Rectangle().fill(.orange)
                .fixedSize()
            Circle().fill(.red)
                .fixedSize()
            ScrollView(.horizontal) {
                HStack {
                    ForEach(0 ..< 50) { i in
                        Rectangle().fill(.blue).frame(width: 30, height: 30)
                            .overlay(Text("(i)").foregroundStyle(.white))
                    }
                }
            }
            .fixedSize()
            VStack {
                Text("GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
                Rectangle().fill(.yellow)
            }
            .fixedSize()
        }
    }
}

把握 ViewThatFits

从截图中能够看出,Text、Shape 和 ScrollView 的“抱负出现”都比较简单猜测,与咱们上面的描述共同。仅有有些奇怪的是 VStack:

VStack {
    Text("GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
    Rectangle().fill(.yellow)
}
.fixedSize()

关于这种视图,其“抱负出现”是一个复合的状况:

  • 宽度:VStack 将逐一问询子视图的抱负尺度,运用其间宽度的最大值作为它的需求尺度,并在终究布局时(placeSubviews)将其作为主张尺度传递给子视图。
  • 高度:VStack 将一切子视图的抱负尺度高度和 Spacing 的和作为自己的需求尺度。

SwiftUI 供给了两个版别的 fixedSize ,咱们当时运用的版别要求视图在水平缓笔直两个轴向上都运用抱负尺度,而另一个版别答应咱们对单个轴向进行约束。

struct IdealSizeDemo2: View {
    var body: some View {
        Text("GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
            .fixedSize(horizontal: false, vertical: true)
            .border(.red, width: 2)
            .frame(width: 100, height: 100)
            .border(.blue, width: 2)
    }
}

fixedSize(horizontal: false, vertical: true) 表示,咱们要求 Text 在 vatical 轴向上出现抱负状况,在 horizontal 轴向上继续运用具有明晰数值的主张尺度宽度( 100 )。用易懂的描述便是,在有明晰宽度约束的状况下,要求 Text 显现悉数的文本内容。

把握 ViewThatFits

从上图中能够看出,因为 fixedSize 的存在,Text 疏忽了其父视图给出的 100 x 100 的主张尺度高度,充沛利用了笔直方向上的空间,将完好的文本内容出现出来。

这种对抱负尺度在单个轴向上的约束与 ViewThatFits 结构方法中的受限轴设置彻底对应。经过设置,咱们能够让 ViewThatFits 只在特定轴向上对子视图的抱负尺度进行判别。

struct IdealSizeDemo3: View {
    var body: some View {
        HStack {
            // ViewThatFits result
            ViewThatFits(in: .vertical) {
                Text("1: GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
                Text("2: In addition, some views believe that:")
            }
            .border(.blue)
            .frame(width: 200, height: 100, alignment: .top)
            .border(.red)
            // Text1's ideal size ,only vetical fixed
            Text("1: GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
                .fixedSize(horizontal: false, vertical: true)
                .border(.blue)
                .frame(width: 200, height: 100, alignment: .top)
                .border(.red)
            // Text2's ideal size ,only vetical fixed
            Text("2: In addition, some views believe that:")
                .fixedSize(horizontal: false, vertical: true)
                .border(.blue)
                .frame(width: 200, height: 100, alignment: .top)
                .border(.red)
        }
    }
}

上面这段代码明晰地展现了 ViewThatFits 挑选第二个 Text 的判别依据。当 Text1 在笔直轴上被单独约束为抱负尺度时,它的高度超越了 ViewThatFits 可供给的高度 100(蓝色边框高度大于红色边框)。而 Text2 的高度符合 ViewThatFits 的要求。

把握 ViewThatFits

实践上,即使 Text2 的抱负高度大于 ViewThatFits 供给的高度,依据 ViewThatFits 的判别规矩,在一切子视图都不满意条件的状况下,它也会默许挑选终究一个子视图(Text2)。不过,终究的出现会是怎样的呢?

ViewThatFits(in: .vertical) {
    Text("1: GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
    Text("2: In addition, some views believe that:")
}
.border(.blue)
.frame(width: 200, height: 30, alignment: .top)
.border(.red)

把握 ViewThatFits

开发者有必要清楚,ViewThatFits 是基于抱负尺度来进行判别,但在终究出现时,被挑选的子视图并不是依照抱负状况来出现的。因为 ViewThatFits 能够供给的高度只要 30,在 Text2 终究出现时,它将依据其默许显现规矩对文字进行截断处理。

在 SwiftUI 中,咱们能够经过 frame 来修正视图在抱负状况下的出现。

struct SetIdealSize: View {
    @State var useIdealSize = false
    var body: some View {
        VStack {
            Button("Use Ideal Size") {
                useIdealSize.toggle()
            }
            .buttonStyle(.bordered)
            Rectangle()
                .fill(.orange)
                .frame(width: 100, height: 100)
                .fixedSize(horizontal: useIdealSize ? true : false, vertical: useIdealSize ? true : false)
            Rectangle()
                .fill(.cyan)
                .frame(idealWidth: 100, idealHeight: 100)
                .fixedSize(horizontal: useIdealSize ? true : false, vertical: useIdealSize ? true : false)
            Rectangle()
                .fill(.green)
                .fixedSize(horizontal: useIdealSize ? true : false, vertical: useIdealSize ? true : false)
        }
        .animation(.easeInOut, value: useIdealSize)
    }
}

.frame(width: 100, height: 100).frame(idealWidth: 100, idealHeight: 100) 之间的不同在于前者在任何场景下(抱负状况或非抱负状况)均被视为视图的需求尺度,后者仅在抱负状况下作为需求尺度。

假如你想进一步了解更多有关抱负尺度和主张尺度的内容,请阅读 SwiftUI 布局 —— 尺度( 上 ) 一文。

示例

一切的理论知识都是为实践使用而服务的。在本节中,咱们将经过几个示例来展现 ViewThatFits 的功用。

自习惯翻滚

经过下面的代码,咱们能够完成在内容宽度超越给定宽度时,主动进入可翻滚状况。

struct ScrollViewDemo: View {
    @State var step: CGFloat = 3
    var count: Int {
        Int(step)
    }
    var body: some View {
        VStack(alignment:.leading) {
            Text("Count: (count)")
            Slider(value: $step, in: 3 ... 20, step: 1)
            ViewThatFits {
                content
                ScrollView(.horizontal,showsIndicators: true) {
                    content
                }
            }
        }
        .frame(width: 300)
        .border(.red)
    }
    var content: some View {
        HStack {
            ForEach(0 ..< count, id: .self) { i in
                Rectangle()
                    .fill(.orange.gradient)
                    .frame(width: 30, height: 30)
                    .overlay(
                        Text(i, format: .number).foregroundStyle(.white)
                    )
            }
        }
    }
}

把握 ViewThatFits

假如 content 的宽度超越了 ViewThatFits 答应的宽度(300),则 ViewThatFits 会挑选终究一个运用 ScrollView 的子视图。在这个示例中,虽然 ScrollView 在抱负状况下,出现的宽度也超越了 ViewThatFits 答应的宽度,但因为它是终究一个子视图,因而终究挑选了它。这也是一个典型的判别和出现不共同的状况。

挑选适宜长度的文本

这也是 ViewThatFits 最常被运用的场景,从供给的一组文本中,找出最适宜当时空间的那个。

struct TextDemo: View {
    @State var width: CGFloat = 100
    var body: some View {
        VStack {
            Slider(value: $width, in: 30 ... 300)
                .padding()
            ViewThatFits {
                Text("Fatbobman's Swift Weekly")
                Text("Fatbobman's Weekly")
                Text("Fat's Weekly")
                Text("Weekly")
                    .fixedSize()
            }
            .frame(width: width)
            .border(.red)
        }
    }
}

为了保证即使在空间有限的状况下,仍然能够完好显现文本,咱们对终究一个 Text 运用了 fixedSize

有些开发者或许会运用以下代码(相同的内容,不同的字体尺度),为 ViewThatFits 供给不同尺度的子视图:

ViewThatFits {
    Text("Fatbobman's Swift Weekly")
        .font(.body)
    Text("Fatbobman's Swift Weekly")
        .font(.subheadline)
    Text("Fatbobman's Swift Weekly")
        .font(.footnote)
}

把握 ViewThatFits

然后,关于内容相同但尺度不同的需求,ViewThatFits 或许并不是最优解决方案。下面的代码能够带来更好的作用:

Text("Fatbobman's Swift Weekly")
    .lineLimit(1)
    .font(.body)
    .minimumScaleFactor(0.3)
    .frame(width: width)
    .border(.red)

把握 ViewThatFits

ViewThatFits 更擅长为不同的空间供给不同的备选内容。

自习惯横竖布局

在给定的空间中,主动挑选适宜的布局方法:

var logo: some View {
    Rectangle()
        .fill(.orange)
        .frame(idealWidth: 100, maxWidth: 200, idealHeight: 100)
        .overlay(
            Image(systemName: "heart.fill")
                .font(.title)
                .foregroundStyle(.white)
        )
}
var title: some View {
    Text("Hello World")
        .fixedSize()
        .font(.headline).bold()
        .frame(maxWidth: 120)
}
struct LayoutSwitchDemo: View {
    @State var width: CGFloat = 100
    var body: some View {
        VStack {
            ViewThatFits(in: .horizontal) {
                HStack(spacing: 0) {
                    logo
                    title
                }
                VStack(spacing: 0) {
                    logo
                    title
                }
            }
            .frame(maxWidth: width, maxHeight: 130)
            .border(.blue)
            Spacer()
            Slider(value: $width, in: 90 ... 250).padding(50)
        }
    }
}

在这个示例中,咱们利用了 ViewThatFits 的特性,在判别子视图和终究出现时选用不同的主张尺度形式,以保证终究出现的子视图一直能够充溢 ViewThatFits 视图。关于 logo 和 title,咱们没有给出明晰的尺度。经过为 Rectangle 设置抱负尺度,供 ViewThatFits 用来挑选适宜的子视图。选定了子视图后,子视图中的 logo 会依据 ViewThatFits 供给的尺度,在终究的出现时调整自己的尺度。

创立 ViewThatFits 的复刻版别

在学习 SwiftUI 的进程中,我经常测验复刻一些布局容器和修饰符。经过这个进程,除了验证我的一些猜想外,还能更深化地了解和把握它们。在本节中,咱们将创立一个符合 Layout 协议的布局容器,来完成对 ViewThatFits 的复刻。

咱们已经在第一个章节中详细论述了 ViewThatFits 的完成细节(判别规矩、出现逻辑),因而运用 Layout 协议来完成十分方便。

struct _MyViewThatFitsLayout: Layout {
    let axis: Axis.Set
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Int?) -> CGSize {
        // 没有子视图,回来 zero
        guard !subviews.isEmpty else { return .zero }
        // 一个子视图,回来该子视图的需求尺度
        guard subviews.count > 1 else {
            cache = subviews.endIndex - 1
            return subviews[subviews.endIndex - 1].sizeThatFits(proposal)
        }
        // 从第一个到倒数第二个子视图逐一在约束的轴向上获取其抱负尺度进行判别
        for i in 0..<subviews.count - 1 {
            let size = subviews[i].dimensions(in: .unspecified)
            switch axis {
            case [.horizontal, .vertical]:
                if size.width <= proposal.replacingUnspecifiedDimensions().width && size.height <= proposal.replacingUnspecifiedDimensions().height {
                    cache = i
                    // 满意判别条件,回来该子视图的需求尺度( 用正常的主张尺度问询 )
                    return subviews[i].sizeThatFits(proposal)
                }
            case .horizontal:
                if size.width <= proposal.replacingUnspecifiedDimensions().width {
                    cache = i
                    return subviews[i].sizeThatFits(proposal)
                }
            case .vertical:
                if size.height <= proposal.replacingUnspecifiedDimensions().height {
                    cache = i
                    return subviews[i].sizeThatFits(proposal)
                }
            default:
                break
            }
        }
        // 上述都不满意,则运用终究一个子视图
        cache = subviews.endIndex - 1
        return subviews[subviews.endIndex - 1].sizeThatFits(proposal)
    }
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Int?) {
        for i in subviews.indices {
            if let cache, i == cache {
                subviews[i].place(at: bounds.origin, anchor: .topLeading, proposal: proposal)
            } else {
                // 将不需求显现的子视图,放置在一个无法显现的位置
                subviews[i].place(at: .init(x: 100_000, y: 100_000), anchor: .topLeading, proposal: .zero)
            }
        }
    }
    func makeCache(subviews _: Subviews) -> Int? {
        nil
    }
}
public struct MyViewThatFitsByLayout<Content>: View where Content: View {
    let axis: Axis.Set
    let content: Content
    public init(axis: Axis.Set = [.horizontal, .vertical], @ViewBuilder content: @escaping () -> Content) {
        self.axis = axis
        self.content = content()
    }
    public var body: some View {
        _MyViewThatFitsLayout(axis: axis) {
            content
        }
    }
}

经过检验,咱们的复刻版别与 ViewThatFits 的作用彻底共同。

把握 ViewThatFits

你能够在 此处 获取本文的悉数代码。

总结

正如咱们所看到的,ViewThatFits 是 SwiftUI 东西箱中的一个强壮而灵敏的组件,它能够帮助开发者优雅地解决多种布局应战,提升使用程序的用户体验和界面习惯性。可是,与任何强壮的东西一样,能否发挥期作用来自于深化了解其运用方法和约束。

在本文中,咱们对 SwiftUI 中的 ViewThatFits 容器进行了深化的探究。从基本界说到杂乱的布局机制,咱们企图揭示这个强壮东西背面的逻辑和潜力。经过对抱负尺度和布局习惯性的详细分析,咱们展现了 ViewThatFits 怎么在多样化的使用场景中发挥作用。

虽然 ViewThatFits 在处理多种视图和布局应战方面十分有用,但它并不是全能的。在某些杂乱的布局需求下,开发者或许需求更精细的操控或选用其他布局战略。因而,了解它的内部工作原理和约束是至关重要的,这样开发者才干充沛利用它的优势,同时避免潜在的布局问题。

希望这篇文章能为你在运用 SwiftUI 进行布局设计时供给有价值的见解。

订阅我的电子周报 Fatbobman’s Swift Weekly,你将每周及时获取有关 Swift、SwiftUI、CoreData 和 SwiftData 的最新文章和资讯。

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

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