SwiftUI的各种堆栈是该框架最基本的一些布局工具,它使我们能够定义水平、垂直或按深度堆叠的视图组。
谈到水平和垂直的变体(HStack 和VStack),我们有时可能会遇到要在两者之间动态切换的情况。例如,假设我们正在构建一个包含以下LoginActionsView ,让用户在登录时从一个动作列表中挑选的应用程序:
struct LoginActionsView: View {
...
var body: some View {
VStack {
Button("Login") { ... }
Button("Reset password") { ... }
Button("Create account") { ... }
}
.buttonStyle(ActionButtonStyle())
}
}
struct ActionButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.fixedSize()
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color.blue)
.cornerRadius(10)
}
}
上面,我们使用fixedSize 修改器来防止我们的按钮标签被截断,只有当我们确定某个视图的内容永远不会大于视图本身时,我们才应该这样做。要了解更多,请查看我的SwiftUI布局系统指南的第三部分。
目前,我们的按钮是垂直堆叠的,并填满了横轴上的所有可用空间(你可以使用上述代码样本的PREVIEW 按钮来看看那是什么样子)。虽然这在纵向的iPhone上看起来很好,但假设我们的用户界面在横向模式下被渲染时,我们反而想使用水平堆叠。
GeometryReader来拯救我们?
一种方法是使用GeometryReader 来测量当前的可用空间,并根据该空间的宽度是否大于高度,我们使用HStack 或VStack 来渲染我们的内容。
虽然我们完全可以把这个逻辑放在我们的LoginActionsView 本身,但我们很有可能在未来的某个时间点重复使用这些代码,所以让我们创建一个专门的视图,作为一个独立的组件执行我们的动态堆栈切换逻辑。
为了使我们的代码更适合未来,我们不会硬编码我们的两个堆栈变体将使用何种对齐方式或间距。相反,让我们像SwiftUI本身一样,对这些属性进行参数化,同时也分配框架所使用的相同的默认值--就像这样:
struct DynamicStack<Content: View>: View {
var horizontalAlignment = HorizontalAlignment.center
var verticalAlignment = VerticalAlignment.center
var spacing: CGFloat?
@ViewBuilder var content: () -> Content
var body: some View {
GeometryReader { proxy in
Group {
if proxy.size.width > proxy.size.height {
HStack(
alignment: verticalAlignment,
spacing: spacing,
content: content
)
} else {
VStack(
alignment: horizontalAlignment,
spacing: spacing,
content: content
)
}
}
}
}
}
由于我们使我们的新DynamicStack 使用了与HStack 和VStack 相同的API,我们现在可以简单地将我们以前的VStack 换成我们新的、自定义堆栈的实例,在我们的LoginActionsView :
struct LoginActionsView: View {
...
var body: some View {
DynamicStack {
Button("Login") { ... }
Button("Reset password") { ... }
Button("Create account") { ... }
}
.buttonStyle(ActionButtonStyle())
}
}
很好!然而,就像上面的代码样本PREVIEW 所显示的那样,使用GeometeryReader 来执行我们的动态堆栈切换确实有一个相当大的缺点,那就是几何读取器总是填满横轴和纵轴上的所有可用空间(以便实际能够测量该空间)。在我们的案例中,这意味着我们的LoginActionsView ,不再仅仅是水平方向的延伸,它现在也会移动到屏幕的顶部。
虽然我们有各种方法可以解决这些问题(例如,使用类似于我们在这篇问答文章中用来使多个视图具有相同的宽度或高度的技术),但问题是在确定我们的动态堆栈的方向时,测量可用空间是否真的是一个好方法。
一个关于尺寸类的案例
相反,让我们使用苹果的尺寸类系统来决定我们的DynamicStack ,在引擎盖下应该使用HStack 还是VStack 。这样做的好处不仅仅是我们能够保留在引入GeometryReader 之前的紧凑布局,而且我们的DynamicStack 将开始以一种非常类似于内置系统组件在所有设备和方向上的行为方式。
body为了开始观察当前的水平尺寸类,我们所要做的就是使用SwiftUI的环境系统--通过在我们的DynamicStack 中声明一个@Environment-marked 属性(有horizontalSizeClass 关键路径),这将让我们在我们视图的sizeClass 值上进行切换:
struct DynamicStack<Content: View>: View {
...
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
switch sizeClass {
case .regular:
hStack
case .compact, .none:
vStack
@unknown default:
vStack
}
}
}
private extension DynamicStack {
var hStack: some View {
HStack(
alignment: verticalAlignment,
spacing: spacing,
content: content
)
}
var vStack: some View {
VStack(
alignment: horizontalAlignment,
spacing: spacing,
content: content
)
}
}
有了上述内容,我们的LoginActionsView ,现在将在使用regular 尺寸类渲染时动态切换为水平布局(例如,在较大的iPhone上为横向,或在iPad上全屏运行时为任一方向),而在使用任何其他尺寸类配置时为垂直布局。所有这些仍然使用一个紧凑的垂直布局,不使用任何超过渲染其内容所需的空间。
使用布局协议
虽然我们已经结束了一个整洁的解决方案,它可以在所有支持SwiftUI的iOS版本中工作,但我们也要探索一些新的布局工具,这些工具在iOS 16中被引入(在撰写本文时,它仍然是Xcode 14的一部分,处于测试阶段)。
其中一个工具是新的Layout 协议,它既能让我们建立完全自定义的布局,可以直接集成到SwiftUI自己的布局系统中(在未来的文章中会有更多介绍),同时也为我们提供了一种新的方式,以一种非常流畅的、完全可动画的方式在各种布局之间动态切换。
这是因为事实证明,Layout 不仅仅是我们第三方开发者的API,苹果也让SwiftUI自己的布局容器也使用了这个新协议。因此,与其直接使用HStack 和VStack 作为容器视图,不如将它们作为符合Layout 的实例,使用AnyLayout 类型进行包装--像这样:
private extension DynamicStack {
var currentLayout: AnyLayout {
switch sizeClass {
case .regular, .none:
return horizontalLayout
case .compact:
return verticalLayout
@unknown default:
return verticalLayout
}
}
var horizontalLayout: AnyLayout {
AnyLayout(HStack(
alignment: verticalAlignment,
spacing: spacing
))
}
var verticalLayout: AnyLayout {
AnyLayout(VStack(
alignment: horizontalAlignment,
spacing: spacing
))
}
}
Content 上面的方法是可行的,因为当HStack 和VStack 的类型是EmptyView 时,它们都直接符合新的Layout 协议(当我们不向这样的堆栈传递任何content 闭包时就是这种情况),如果我们看一下 SwiftUI 的公共接口就会发现:
extension VStack: Layout where Content == EmptyView {
...
}
请注意,由于回归,上述条件一致性在Xcode 14 beta 3中被省略了。根据SwiftUI团队的Matt Ricketson的说法,一个临时的变通方法是直接使用底层的_HStackLayout 和_VStackLayout 类型。希望这个回归能在未来的测试版中得到修复。
现在我们能够通过我们新的currentLayout 属性来解决使用什么布局,我们现在可以更新我们的body 实现,以简单地调用从该属性返回的AnyLayout ,就像它是一个函数一样--像这样:
struct DynamicStack<Content: View>: View {
...
var body: some View {
currentLayout(content)
}
}
我们可以通过把它作为一个函数来调用来应用我们的布局(尽管它实际上是一个结构),这是因为Layout 协议使用了 Swift 的"作为函数调用 "功能。
那么,我们之前的解决方案和上述基于Layout 的解决方案有什么不同呢?关键的区别(除了后者需要iOS 16)是,切换布局可以保留正在渲染的底层视图的身份,而在HStack 和VStack 之间切换时,情况并非如此。这样做的结果是,动画将更加平滑,例如在切换设备方向时,我们也可能在执行这种变化时获得小的性能提升(因为SwiftUI总是在其视图层次结构尽可能静态时表现最佳)。
挑选合适的视图
但我们还没有完全结束,因为iOS 16还为我们提供了另一个有趣的新布局工具,有可能被用来实现我们的DynamicStack ,这就是一个新的视图类型,名为ViewThatFits 。就像它的名字所暗示的那样,这个新的容器将根据我们在初始化它时传递的候选列表,挑选最适合当前环境的视图。
在我们的例子中,这意味着我们可以同时传递给它一个HStack 和一个VStack ,它将代表我们在它们之间自动切换。
struct DynamicStack<Content: View>: View {
...
var body: some View {
ViewThatFits {
HStack(
alignment: verticalAlignment,
spacing: spacing,
content: content
)
VStack(
alignment: horizontalAlignment,
spacing: spacing,
content: content
)
}
}
}
请注意,在这种情况下,我们先放置HStack ,这一点很重要,因为VStack 可能总是合适的,即使在我们希望我们的布局是水平的情况下(比如iPad的全屏模式)。同样重要的是要指出,上述基于ViewThatFits 的技术将始终尝试使用我们的HStack ,即使在用紧凑尺寸类渲染时也是如此,只有在HStack 不合适时才会选择我们基于VStack 的布局。
总结
这就是实现专用DynamicStack 视图的四种不同方法,该视图根据当前的上下文在HStack 和VStack 之间动态切换。
谢谢你的阅读!