让我们从上周结束的地方开始,继续探索SwiftUI布局系统以及它的各种api和概念是如何工作的。本周,我们将看看一些更高级的技术——比如我们如何将视图与动态尺寸对齐,以及如何读取视图周围的几何图形以构建完全自定义的布局。
Handling dynamic content
尽管应用程序UI的某些部分可能是相对静态的,在内容方面是可预测的,但我们将在任何给定应用程序中显示的大多数视图都将是高度动态的。
我们不仅要考虑编译时不知道的内容(例如从服务器下载的文本和图像),我们还必须确保我们的视图能够根据本地化字符串和其他资源很好地伸缩,这些资源可能会因应用运行的环境而不同。
值得庆幸的是,SwiftUI是围绕着这样一个事实设计的:大多数现代应用都是动态的,并且会根据内容自动调整我们声明的视图,它们的环境(考虑到当前设备大小和配色方案等因素)和其他因素。然而,有时我们可能需要做一些微调和调整,使SwiftUI缩放和定位我们的视图完全符合我们的要求。
作为一个例子,让我们继续处理上周的事件视图,在屏幕底部添加一行“信息徽章”——它将向用户显示当前事件的特定信息。 首先,我们将编写一个简单的EventInfoBadge视图,使用一些在第一部分中讨论过的布局技术——例如使用VStack垂直分组两个视图,以及渲染一个固定大小的系统图标:
struct EventInfoBadge: View {
var iconName: String
var text: String
var body: some View {
VStack {
Image(systemName: iconName)
.resizable()
.frame(width: 25, height: 25)
Text(text)
}
}
}
单独来看,上面的实现看起来非常好。然而,如果我们现在尝试在ContentView的底部渲染一个由三个EventInfoBadge实例组成的水平行,事情看起来就不会像我们预期的那样好:
struct ContentView: View {
var body: some View {
VStack {
EventHeader()
Spacer()
HStack {
EventInfoBadge(
iconName: "video.circle.fill",
text: "Video call available"
)
EventInfoBadge(
iconName: "doc.text.fill",
text: "Files are attached"
)
EventInfoBadge(
iconName: "person.crop.circle.badge.plus",
text: "Invites allowed"
)
}
}.padding()
}
}
我们有两个主要的问题(你可以通过上面的预览按钮看到)-首先,我们的图标缩放使用了不正确的长宽比,使它们看起来拉伸。第二,因为我们的每个信息徽章都渲染不同的字符串,他们最终会得到不同的宽度-这使我们的UI看起来相当不均匀。
让我们首先通过对图像应用aspectratio()修改器来解决图标拉伸的问题——告诉它在调整大小时将内容调整到边界,如下所示:
struct EventInfoBadge: View {
var iconName: String
var text: String
var body: some View {
VStack {
Image(systemName: iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 25, height: 25)
Text(text)
}
}
}
接下来,为了让我们的三个信息徽章在ContentView中占据相同的水平空间,我们需要让每个徽章在它的容器中占据尽可能多的空间。这将迫使父视图(本例中是底部的HStack)在每个子视图之间平均分配可用空间,而不是将最大的空间分给文本最长的子视图。
为了实现这一点,让我们为EventInfoBadge中的文本设置一个无限的最大宽度——这将使布局系统在将其分割成多行之前尽可能地在水平轴上缩放它:
struct EventInfoBadge: View {
var iconName: String
var text: String
var body: some View {
VStack {
Image(systemName: iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 25, height: 25)
Text(text)
.frame(maxWidth: .infinity)
}
}
}
有了上述两个修正,我们的视图现在看起来好多了——所以让我们把EventInfoBadge实现的文本居中对齐,并给它一些填充、背景颜色和圆角:
struct EventInfoBadge: View {
var iconName: String
var text: String
var body: some View {
VStack {
Image(systemName: iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 25, height: 25)
Text(text)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
.padding(.vertical, 10)
.padding(.horizontal, 5)
.background(Color.secondary)
.cornerRadius(10)
}
}
最后,让我们再次遵循在第一部分中所做的相同的做法,将我们的信息徽章列表从我们的ContentView中移到一个新的独立组件中——以防止我们的内容视图变得庞大:
struct EventInfoList: View {
var body: some View {
HStack {
EventInfoBadge(
iconName: "video.circle.fill",
text: "Video call available"
)
EventInfoBadge(
iconName: "doc.text.fill",
text: "Files are attached"
)
EventInfoBadge(
iconName: "person.crop.circle.badge.plus",
text: "Invites enabled"
)
}
}
}
我们现在可以简单地初始化EventInfoList的一个实例,而不是在我们的ContentView中内联创建上面的HStack,这样就可以了:
struct ContentView: View {
var body: some View {
VStack {
EventHeader()
Spacer()
EventInfoList()
}.padding()
}
}
另外,如果我们想在应用程序的其他地方渲染相同类型的列表,我们现在可以很容易地做到这一点。
Geometry, preferences, and layout dependencies
然而,我们的EventInfoBadge仍然存在一个问题。虽然我们目前的实现在宽度方面处理动态文本长度,但我们仍然需要解决的事实是,我们的徽章可能会以不同的高度结束——例如,如果我们让我们的文本稍微长一点:
struct EventInfoList: View {
var body: some View {
HStack {
EventInfoBadge(
iconName: "video.circle.fill",
text: "Video call available"
)
EventInfoBadge(
iconName: "doc.text.fill",
text: "Files are attached"
)
EventInfoBadge(
iconName: "person.crop.circle.badge.plus",
text: "Invites enabled, 5 people maximum"
)
}
}
}
上面的结果可能不是什么坏事,但如果我们能够给每个徽章相同的高度,我们的UI看起来会更好。 为了实现这一点,我们必须想出一种方法来通知EventInfoList它的子视图的最大高度,这样它就可以调整剩余子视图的大小,使其也占据相同的垂直空间。
因为这是一个我们可能想要在应用程序的不同部分(甚至在项目之间)重用的功能,让我们将其作为一个名为HeightSyncedRow的新独立视图来实现。首先,我们将使用@ViewBuilder函数builder属性,使我们的新视图能够使用SwiftUI内置容器和Stack使用的类似dsl的语法。然后,我们将为该DSL表达式的结果赋值一个childHeight,如下所示:
struct HeightSyncedRow<Content: View>: View {
private let content: Content
@State private var childHeight: CGFloat?
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
HStack {
content.frame(height: childHeight)
}
}
}
使用与swift内置视图相同的@ViewBuilder属性的好处是,我们现在可以回到EventInfoList,简单地用新的HeightSyncedRow替换它的HStack,而不需要做任何额外的更改:
struct EventInfoList: View {
var body: some View {
HeightSyncedRow {
EventInfoBadge(
iconName: "video.circle.fill",
text: "Video call available"
)
EventInfoBadge(
iconName: "doc.text.fill",
text: "Files are attached"
)
EventInfoBadge(
iconName: "person.crop.circle.badge.plus",
text: "Invites enabled, 5 people maximum"
)
}
}
}
接下来,让我们计算highsyncedrow将分配给它的每个子节点的childHeight值。为了做到这一点,我们将通过使用SwiftUI的Preferences system,让每个子节点在视图层次结构中向上报告当前的高度 — 这使我们能够将给定的值与子视图中的preference键关联起来,之后可以在父视图中读取该值。
这样做首先需要我们实现一个PreferenceKey,它既包括首选项的defaultValue,也包括一个将两个值(前一个值和后一个值)减少为一个值的方法——像这样:
private struct HeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat) {
value = nextValue()
}
}
接下来,我们将使用SwiftUI的GeometryReader类型——这是一个视图,它使我们能够读取当前视图容器的大小。通过嵌入一个GeometryReader作为给定视图的背景,我们可以在不影响视图布局的情况下进行这种读取 — 因为背景视图总是会占用与它所附加的视图相同的帧。
最后,我们将把所有这些功能打包到一个视图扩展中,使我们能够将任何视图的高度同步到给定的绑定属性包装器中——这给了我们以下实现:
extension View {
func syncingHeightIfLarger(than height: Binding<CGFloat?>) -> some View {
background(GeometryReader { proxy in
// We have to attach our preference assignment to
// some form of view, so we just use a clear color
// here to make that view completely transparent:
Color.clear.preference(
key: HeightPreferenceKey.self,
value: proxy.size.height
)
})
.onPreferenceChange(HeightPreferenceKey.self) {
height.wrappedValue = max(height.wrappedValue ?? 0, $0)
}
}
}
有了上面的步骤,我们现在可以回到我们的HeightSyncedRow,让它把我们新的syncingheight flarger修改器应用到它的内容视图 — 这又会使它的每个子节点都采用完全相同的高度:
struct HeightSyncedRow<Content: View>: View {
private let content: Content
@State private var childHeight: CGFloat?
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
HStack {
content.syncingHeightIfLarger(than: $childHeight)
.frame(height: childHeight)
}
}
}
然而,如果我们现在再次渲染我们的主ContentView,我们实际上不能告诉我们所有的信息徽章有相同的高度 ,因为我们在给每个信息徽章的背景颜色之后应用了.frame()修饰器。为了说明这个问题,我们可以再次使用经典的“红色背景颜色技巧”,就像我们在第一部分中所做的那样:
struct HeightSyncedRow<Content: View>: View {
private let content: Content
@State private var childHeight: CGFloat?
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
HStack {
content.syncingHeightIfLarger(than: $childHeight)
.frame(height: childHeight)
.background(Color.red)
}
}
}
现在,为了解决这个问题,让我们把background 赋值从EventInfoBadge移到HeightSyncedRow中。这样,我们将能够首先分配每个视图的帧,然后添加它的背景-这将给我们所有的背景视图正确的大小。为了让HeightSyncedRow仍然是一个可重用的组件,让我们在初始化器中添加一个Background视图的支持,然后把它赋值给每一个子组件,就像这样:
struct HeightSyncedRow<Background: View, Content: View>: View {
private let background: Background
private let content: Content
@State private var childHeight: CGFloat?
init(background: Background,
@ViewBuilder content: () -> Content) {
self.background = background
self.content = content()
}
var body: some View {
HStack {
content.syncingHeightIfLarger(than: $childHeight)
.frame(height: childHeight)
.background(background)
}
}
}
有了上面的内容,现在让我们回到EventInfoList,并在创建它的HeightSyncedRow时从EventInfoBadge传递后台视图——像这样:
struct EventInfoList: View {
var body: some View {
HeightSyncedRow(background: Color.secondary.cornerRadius(10)) {
EventInfoBadge(
iconName: "video.circle.fill",
text: "Video call available"
)
EventInfoBadge(
iconName: "doc.text.fill",
text: "Files are attached"
)
EventInfoBadge(
iconName: "person.crop.circle.badge.plus",
text: "Invites enabled, 5 people maximum"
)
}
}
}
现在剩下的就是从EventInfoBadge中删除backGround赋值,我们的实现就完成了:
struct EventInfoBadge: View {
var iconName: String
var text: String
var body: some View {
VStack {
Image(systemName: iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 25, height: 25)
Text(text)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
.padding(.vertical, 10)
.padding(.horizontal, 5)
}
}
在整个练习过程中,我们本质上必须处理的是布局依赖关系——当一个视图的布局以某种方式依赖于另一个视图。在我们的例子中,我们不能确定每个EventInfoBadge的最终帧之前,首先知道它们之间的最大高度。
尽管布局依赖关系应该尽可能避免(因为它们会使我们的视图非常紧密地耦合),但有时有必要在一组子视图和它们的父视图之间建立一个通信链 —— 如果我们能够通过通用抽象(如我们构建的HeightSyncedRow)做到这一点,那么我们通常能够找到一种方法来管理我们的布局依赖关系,使我们的代码模块化并易于更改。
Conclusion
这就是SwiftUI布局系统指南的第二部分。下周,我们将通过看一看如何让我们的视图在多个屏幕大小之间更好地缩放,以及如何定义布局优先级和更多自定义布局来结束这个系列。但现在,让我们总结一下这部分内容:
aspectratio()修饰符让我们可以在视图调整大小时调整视图内容的缩放方式。它对图像特别有用。
使用.frame(maxWidth: .infinity)(或它的高度等效值)可以告诉视图在给定的维度内占用尽可能多的空间,这反过来可以用来“强制”父视图将所有可用的空间平均分配给子视图。
GeometryReader是一个特殊的视图,它可以读取周围的几何图形,并让我们相应地构建定制的布局。
使用SwiftUI的Preferences system,我们可以通过一个视图层次结构向上通信,例如为了通知父视图的计算大小。