在使用SwiftUI构建视图时,一个相当常见但却出人意料地难以解决的问题是如何使两个动态视图采取相同的宽度或高度。例如,这里我们正在研究一个LoginView ,它有两个按钮--一个用于登录,另一个用于触发密码重置--目前是这样实现的:
struct LoginView: View {
...
var body: some View {
VStack {
...
Group {
Button("Log in") {
...
}
Button("I forgot my password") {
...
}
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(20)
}
}
}
如果你使用上面的PREVIEW 按钮,你可以看到我们的按钮有不同的宽度,这是意料之中的,因为它们显示的是两个不同的文本,而这些文本并不一样长。但是,如果我们真的希望它们有相同的宽度(这可以说是看起来更好)--如何才能实现呢?
我们将忽略那些涉及硬编码我们的视图宽度的方法(这对大多数现代应用程序来说是行不通的,因为它们需要适应用户的可访问性设置和本地化字符串),或者手动测量每个基础字符串的大小。相反,我们将专注于那些更稳健、更容易维护的解决方案。
无限的框架
如果我们不介意让我们的按钮伸展到适合它们所显示的容器的宽度,那么一个潜在的解决方案就是给两个按钮一个无限的最大宽度。这可以用frame 修改器来完成,当它与一些额外的水平填充相结合时,可以给我们一个相当不错的结果(至少当我们的LoginView 将被显示在一个狭窄的容器中时,例如在一个纵向的iPhone上运行时):
struct LoginView: View {
...
var body: some View {
VStack {
...
Group {
Button("Log in") {
...
}
Button("I forgot my password") {
...
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(20)
.padding(.horizontal)
}
}
}
同样,请随时使用上面的PREVIEW 按钮,看看该实现的渲染结果是什么样子的。
GeometryReader
虽然我们总是可以使用其他更静态的maxWidth ,以防止我们的按钮被拉得太长,但也许我们想根据我们容器的宽度动态地计算这个值。要做到这一点,我们可以使用GeometryReader ,它可以让我们访问我们的按钮将被渲染的背景的几何信息。
然而,在这种情况下,使用GeometryReader 的一个相当大的缺点是,它占据了我们UI中所有可用的空间--这意味着我们的LoginView 最终可能会变得比我们所期望的大得多,而且我们还需要确保我们的根VStack 最终也会伸展所有的可用空间,以保持我们所期望的布局。
如果我们想让我们的按钮不被拉伸到超过总的可用宽度的60%,这样的实现就会是这样的:
struct LoginView: View {
...
var body: some View {
GeometryReader { geometry in
VStack {
...
Group {
Button("Log in") {
...
}
Button("I forgot my password") {
...
}
}
.frame(maxWidth: geometry.size.width * 0.6)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(20)
}
.frame(maxWidth: .infinity)
}
}
}
上述方法的另一个潜在缺点是,我们必须确保选择一个适用于所有屏幕和文本尺寸组合的宽度乘数,这有时会很麻烦。
使用网格
如果我们的应用程序使用iOS 14或macOS Big Sur作为其最低部署目标,那么另一个解决我们宽度同步问题的潜在方法是使用LazyHGrid 。非自适应的水平网格会自动同步其单元格的宽度,这让我们能够像这样实现我们想要的按钮布局:
struct LoginView: View {
...
var body: some View {
VStack {
...
LazyHGrid(rows: [buttonRow, buttonRow]) {
Group {
Button("Log in") {
...
}
Button("I forgot my password") {
...
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(20)
}
}
}
private var buttonRow: GridItem {
GridItem(.flexible(minimum: 0, maximum: 80))
}
}
然而,请注意,虽然我们的两个按钮的宽度现在是完全动态的(并且完全同步),但我们必须硬编码我们的网格的每一行的最大高度,因为否则我们的网格最终会占用所有的可用空间(就像一个GeometryReader )。在某些情况下,这可能不是一个问题,但如果我们最终使用上述解决方案,还是要注意这一点。
使用偏好键
对于同步两个动态视图的宽度或高度的问题,也许最动态和稳健的解决方案是使用SwiftUI的偏好系统来测量,然后将每个按钮的尺寸发送到它们的父视图。要做到这一点,让我们先创建一个PreferenceKey-conforming类型,通过挑选最大的一个来减少所有传送的按钮宽度值--像这样:
private extension LoginView {
struct ButtonWidthPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
}
有了上述内容,我们就可以在每个按钮的background 内嵌入一个GeometryReader (这使它假定与按钮本身的大小相同),然后使用我们新的偏好键来发送和观察我们的宽度值。然后我们将最大值存储在一个@State-marked属性中,当这个值被修改时,将导致我们的视图被更新。下面是这样一个实现的样子:
struct LoginView: View {
...
@State private var buttonMaxWidth: CGFloat?
var body: some View {
VStack {
...
Group {
Button("Log in") {
...
}
Button("I forgot my password") {
...
}
}
.background(GeometryReader { geometry in
Color.clear.preference(
key: ButtonWidthPreferenceKey.self,
value: geometry.size.width
)
})
.frame(width: buttonMaxWidth)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(20)
}
.onPreferenceChange(ButtonWidthPreferenceKey.self) {
buttonMaxWidth = $0
}
}
}
请注意,我们使用Color.clear (而不是EmptyView )作为我们GeometryReader 闭包的返回值。这是因为 SwiftUI 完全忽略了EmptyView 实例,这将使我们的偏好值无法在这种情况下被发送。
虽然上述解决方案肯定比我们之前探索的方案要复杂得多,但它确实给了我们最动态的解决方案(到目前为止),因为我们不再被迫对布局的任何方面进行硬编码。相反,我们的按钮现在会根据它们的标签进行调整,这给了我们一个在各种屏幕尺寸、语言和可访问性设置中都适用的解决方案。
总结
虽然SwiftUI的声明式设计有很多优点,但它确实使像我们在本文中探讨的任务变得相当困难。上述两种解决方案都不是真正完美的,像这样的事情可能会让我们希望有一个类似于Auto Layout的基于约束的布局系统,这将使我们更容易表达两个视图应该在宽度或高度上同步。
谢谢你的阅读!