SWIFUI布局系统指南-第2部分

474 阅读9分钟

翻译地址

让我们从我们停下来的地方开始吧上周,并继续探索SwiftUI布局系统及其各种API和概念的工作方式。本周,我们将介绍一些更高级的技术--比如如何将视图与动态维度对齐,以及如何读取视图的周围几何图形,从而构建完全自定义布局。

处理动态内容

虽然应用程序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)
        }
    }
}

孤立地说,上面的实现看起来非常好。但是,如果我们现在尝试呈现一个由三行组成的水平行EventInfoBadge的底部的实例。ContentView,事情不会像我们想象的那样美好:

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()
    }
}

我们再次对视图的所有字符串和图像进行了硬编码,因为本文完全集中在SwiftUI布局系统上,不会涉及数据绑定。

我们有两个主要问题(您可以通过使用预览(以上按钮)-首先,我们的图标使用不正确的高宽比进行缩放,使它们看起来很拉长。第二,由于我们的每个信息徽章呈现不同的字符串,它们最终会得到不同的宽度-这使得我们的UI看起来非常不平衡。

首先,通过应用.aspectRatio()修饰符Image-告诉它在调整大小时要使其内容符合其范围,如下所示:

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(在这种情况下)在每个子节点之间平均分配可用的空间,而不是将最大的空间分配给具有最长文本的子节点。

为了实现这一点,让我们Text在我们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"
            )
        }
    }
}

而不是创建上面的HStack内嵌ContentView,我们现在可以简单地初始化EventInfoList我们可以走了

struct ContentView: View {
    var body: some View {
        VStack {
            EventHeader()
            Spacer()
            EventInfoList()
        }.padding()
    }
}

另外,如果我们想在应用程序的其他地方呈现相同类型的列表,那么我们现在就可以很容易地做到这一点了。

几何、首选项和布局依赖项

然而,事实证明,我们的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 功能生成器属性使我们的新视图能够与相同的DSL-就像SwiftUI的内置容器和堆栈所使用的语法一样。然后我们将分配一个childHeight对于DSL表达式的结果,如下所示:

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)
        }
    }
}

使用相同的@ViewBuilder属性作为SwiftUI的内置视图,我们现在可以返回到EventInfoList简单地替换它HStack用我们的新HeightSyncedRow-无须作任何额外改动:

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"
            )
        }
    }
}

接下来,让我们计算childHeight我们的价值HeightSyncedRow将分配给每个孩子。为此,我们将通过使用SwiftUI的视图层次结构,使每个子表的当前高度向上报告。优惠制-这使我们能够将给定的值与子视图中的偏好键关联起来,然后可以在其父视图中读取该值。

这样做首先需要我们实现PreferenceKey,这两个选项都包括首选项defaultValue,以及将两个值(前两个值和下一个值)简化为一个值的方法,如下所示:

private struct HeightPreferenceKey: PreferenceKey {
    static let defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat,
                       nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

接下来,我们将使用SwiftUI的GeometryReadertype-这是一个视图,除其他外,它使我们能够读取当前视图的容器的大小。通过嵌入GeometryReader作为一个给定的视图background,我们可以在不以任何方式影响视图布局的情况下执行这种读取,因为背景视图将始终占据与其连接的视图相同的帧。

最后,我们将将所有这些功能封装为View扩展,使我们能够将任何视图的高度同步到给定的Binding 属性包装器-这使我们得以执行:

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简单地让它应用到我们的新syncingHeightIfLarger对ITS的修饰符content视图-这反过来又会使每个孩子采用完全相同的高度:

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)
        }
    }
}

现在,为了解决这个问题,让我们把背景作业从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并更新它以传递背景视图。EventInfoBadge创建其HeightSyncedRow-像这样:

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我们的执行工作将完成:

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,然后我们就可以找到一种方法来管理我们的布局依赖关系,这种方式仍然可以使我们的代码模块化并易于更改。

推荐👇:

如果你想一起进阶,不妨添加一下交流群1012951431

面试题资料或者相关学习资料都在群文件中 进群即可下载!