视图布局

183 阅读9分钟

默认情况下,SwiftUI 的视图仅占用所需的空间,但是如果要更改视图的大小,可以使用 frame() 修饰符告诉 SwiftUI 您想要的大小范围。

例如,您可以创建一个具有 200x200 可点击区域的按钮,如下所示:

Button {
    print("Button tapped")
} label: {
    Text("Welcome")
        .frame(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 200)
        .font(.largeTitle)
}

或者,您可以通过指定一个框架,使其最小宽度和高度为零,并为最大宽度和高度,使其无穷大,从而使文本视图填充整个屏幕(减去安全区域),如下所示:

Text("Please log in")
    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
    .font(.largeTitle)
    .foregroundColor(.white)
    .background(Color.red)

如何使用 padding 来控制各个视图之间的间距?

SwiftUI 允许我们使用 padding() 修饰符在视图周围设置单独的填充,从而将视图放置在距离其邻居更远的地方。

如果不使用此参数,则所有方面都会得到系统默认的填充,如下所示:

VStack {
    Text("SwiftUI")
        .padding()
    Text("rocks")
}

但是,您也可以自定义要应用的填充量和位置。 因此,您可能只想将系统填充应用于一侧:

Text("SwiftUI")
    .padding(.bottom)

或者,您可能想控制对所有边应用多少填充:

Text("SwiftUI")
    .padding(100)

或者,您可以将两者结合起来,在视图的一侧添加一定数量的填充:

Text("SwiftUI")
    .padding(.bottom, 100)

如何使用 GeometryReader 提供相对大小?

尽管通常最好让 SwiftUI 使用堆栈执行自动布局,但也可以使用 GeometryReader 提供相对于其容器的视图尺寸。 例如,如果您希望两个视图占用屏幕上可用宽度的一半,则无法使用硬编码值,因为我们无法提前知道屏幕的宽度。

为了解决这个问题,GeometryReader 为我们提供了一个输入值,告诉我们可用的宽度和高度,然后我们可以将其用于需要的任何计算中。 因此,如果我们有两个视图,并且我们希望其中一个占据屏幕的三分之一,而另一个占据三分之二,则可以这样写:

GeometryReader { geometry in
    HStack(spacing: 0) {
        Text("Left")
            .font(.largeTitle)
            .foregroundColor(.black)
            .frame(width: geometry.size.width * 0.33)
            .background(Color.yellow)
        Text("Right")
            .font(.largeTitle)
            .foregroundColor(.black)
            .frame(width: geometry.size.width * 0.67)
            .background(Color.orange)
    }
}
.frame(height: 50)

注意:GeometryReader 不考虑视图层次结构中更远的偏移量或间距,这就是为什么 HStack 上没有间距的原因–如果我们在其中允许间距,则视图对于可用空间会有点太大。

image.png

如何将内容放置在安全区域之外?

默认情况下,您的 SwiftUI 视图将大部分留在安全区域内 - 它们将进入屏幕底部,但不会靠近设备顶部的任何凹口。

如果您要更改此设置 – 如果您希望视图真正全屏显示,即使这意味着部分被切口或其他硬件切口所遮盖 – 则应使用 ignoresSafeArea() 修饰符。

例如,这将创建一个红色文本视图,要求填充所有可用空间,然后将其设置为忽略任何安全区域,以使其真正边缘到边缘。

Text("Hello World")
    .frame(minWidth: 100, maxWidth: .infinity, minHeight: 100, maxHeight: .infinity)
    .background(Color.red)
    .ignoresSafeArea()

如何返回不同的视图类型?

任何 SwiftUI 的 body 属性都可以自动返回不同的视图,这要归功于名为 @ViewBuilder 的特殊属性。 这是使用 Swift 的结果生成器系统实现的,它了解如何根据我们应用的状态显示两种不同的视图。

但是,并不是所有地方都自动具有相同的功能,这意味着您创建的任何自定义属性都必须返回相同的视图类型。

有四种方法可以解决此问题。 第一种选择是将输出包装在一个组中,这样,无论您发回图像还是文本视图,它们都将在一个组中返回:

struct ContentView: View {
    var tossResult: some View {
        Group {
            if Bool.random() {
                Image("laser-show")
                    .resizable()
                    .scaledToFit()
            } else {
                Text("Better luck next time")
                    .font(.title)
            }
        }
        .frame(width: 400, height: 300)
    }

    var body: some View {
        VStack {
            Text("Coin Flip")
                .font(.largeTitle)

            tossResult
        }
    }
}

第二种是使用可以返回的类型擦除包装器 AnyView

struct ContentView: View {
    var tossResult: some View {
        if Bool.random() {
            return AnyView(Image("laser-show").resizable().scaledToFit())
        } else {
            return AnyView(Text("Better luck next time").font(.title))
        }
    }

    var body: some View {
        VStack {
            Text("Coin Flip")
                .font(.largeTitle)

            tossResult
                .frame(width: 400, height: 300)                
        }
    }
}

如果您还没有听说过这个概念,它会有效地迫使 Swift 忘记 AnyView 内部的特定类型,让他们看起来像是同一个人。 不过,这会降低性能,因此请不要经常使用。

尽管 Group 和 AnyView 的布局效果均相同,但在两者之间通常最好使用 Group,因为 SwiftUI 效率更高。

第三种选择是自己将 @ViewBuilder 属性应用于需要它的任何属性,如下所示:

struct ContentView: View {
    @ViewBuilder var tossResult: some View {
        if Bool.random() {
            Image("laser-show")
                .resizable()
                .scaledToFit()
        } else {
            Text("Better luck next time")
                .font(.title)
        }
    }

    var body: some View {
        VStack {
            Text("Coin Flip")
                .font(.largeTitle)

            tossResult
                .frame(width: 400, height: 300)                
        }
    }
}

如何使用 ForEach 循环创建视图?

通常,您会发现要遍历序列以创建视图,而在 SwiftUI 中,这是使用 ForEach 完成的。

重要提示:很容易查看 ForEach 并认为它与 Swift 序列上的 forEach() 方法相同,但事实并非如此。

SwiftUI 中的 ForEach 本身就是一个视图结构,这意味着您可以根据需要直接从视图主体中返回它。 您为它提供了一组项目,还可能需要告诉 SwiftUI 它如何唯一地标识每个项目,以便它知道在值更改时如何更新它们。 您还向它传递了一个函数,该函数要运行以为循环中的每个项目创建一个视图。

对于范围上的简单循环,您可以将范围直接传递到 ForEach 中,并告诉 Swift 将每个数字用作项目的唯一标识符。 例如,此计数从 10 减少到 1,然后在末尾添加一条消息:

VStack(alignment: .leading) {
    ForEach((1...10).reversed(), id: .self) {
        Text("($0)…")
    }

    Text("Ready or not, here I come!")
}

.id(:.self) 部分是必需的,以便 SwiftUI 可以唯一地标识数组中的每个元素–这意味着,如果添加或删除一项,SwiftUI 会确切知道哪一个。

您可以使用这种方法来创建任何类型的循环。 例如,此代码创建一个由三种颜色组成的数组,将它们全部循环,并使用每种颜色名称和颜色值创建文本视图:

struct ContentView: View {
    let colors: [Color] = [.red, .green, .blue]

    var body: some View {
        VStack {
            ForEach(colors, id: .self) { color in
                Text(color.description.capitalized)
                    .padding()
                    .background(color)
            }
        }
    }
}

使用 .self 告诉 Swift,每个项目都使用其自己的值进行唯一标识。 因此,如果您有数组 [1、2、3] 并用 .self 标识每个值,则意味着第一项具有标识符 1,第二个 2 和第三个标识符。

如果数组中有自定义类型,则应将id与类型内的任何属性一起使用,以对其进行唯一标识。 例如,您可以创建一个结构,其中id属性是UUID,这意味着它可以保证是唯一的-对于我们的目的而言是完美的。 我们可以创建一个这样的结构,然后像这样使用它:

struct SimpleGameResult {
    let id = UUID()
    let score: Int
}

struct ContentView: View {
    let results = [
        SimpleGameResult(score: 8),
        SimpleGameResult(score: 5),
        SimpleGameResult(score: 10)
    ]

    var body: some View {
        VStack {
            ForEach(results, id: .id) { result in
                Text("Result: (result.score)")
            }
        }
    }
}

这告诉 SwiftUI 它可以通过查看它们的 id 属性来区分 ForEach 内部的视图。

或者,如果您创建的结构符合 Identifiable 协议,则只需编写 ForEach(results)。 遵守该协议意味着添加一个 id 属性,该属性唯一地标识每个对象,在我们的例子中,我们已经拥有了它。 因此,此代码实现了相同的结果:

struct IdentifiableGameResult: Identifiable {
    var id = UUID()
    var score: Int
}

struct ContentView: View {
    let results = [        IdentifiableGameResult(score: 8),        IdentifiableGameResult(score: 5),        IdentifiableGameResult(score: 10)    ]

    var body: some View {
        VStack {
            ForEach(results) { result in
                Text("Result: (result.score)")
            }
        }
    }
}

如何使两个视图具有相同的宽度或高度?

通过将 frame() 修饰符与 fixedSize() 结合使用,SwiftUI 可以轻松地创建两个相同大小的视图,而无论您想要的是相同的高度还是相同的宽度–不需要 GeometryReader 或类似对象。

在iOS上,关键是为要缩放的每个视图提供无限的最大高度或宽度,这将自动使其拉伸以填充所有可用空间。 然后,将 fixedSize() 应用于它们所在的容器,这告诉 SwiftUI 这些视图仅应占用它们所需的空间。 结果是 SwiftUI 找出了视图所需的最少空间,然后允许它们占用全部空间–不管它们包含什么,两个视图将始终匹配其大小。

这是一个示例,显示了如何使两个文本视图具有相同的高度,即使它们具有非常不同的文本长度。 多亏了 frame() 和 fixedSize() 的组合,两个文本视图的布局尺寸相同:

HStack {
    Text("This is a short string.")
        .padding()
        .frame(maxHeight: .infinity)
        .background(Color.red)

    Text("This is a very long string with lots and lots of text that will definitely run across multiple lines because it's just so long.")
        .padding()
        .frame(maxHeight: .infinity)
        .background(Color.green)
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxHeight: 200)

image.png

VStack {
    Button("Log in") { }
        .foregroundColor(.white)
        .padding()
        .frame(maxWidth: .infinity)
        .background(Color.red)
        .clipShape(Capsule())

    Button("Reset Password") { }
        .foregroundColor(.white)
        .padding()
        .frame(maxWidth: .infinity)
        .background(Color.red)
        .clipShape(Capsule())
}
.fixedSize(horizontal: true, vertical: false)

image.png

如何使用 foregroundStyle 提供视觉结构?

SwiftUI 提供了一个 foregroundStyle() 修饰符来同时控制文本、图像和形状的样式。 在最简单的形式中,这类似于将 foregroundColor() 与 .secondary 一起使用,但它不仅解锁了更多语义颜色 - .tertiary 和 .quaternary,它还增加了对任何符合 ShapeStyle 的支持。

因此,这是一个使用四元样式渲染的图像和一些文本的示例,这是内容四个重要性级别中最低的:

HStack {
    Image(systemName: "clock.fill")
    Text("Set the time")
}
.font(.largeTitle.bold())
.foregroundStyle(.quaternary)

image.png

就像我说的,任何符合 ShapeStyle 的东西也可以使用,这意味着我们可以使用相同的修饰符用红色到黑色的线性渐变渲染我们的 HStack

image.png