SwiftUI - 如何将文本视图与另一个文本视图垂直对齐,以制作类似于正确对齐的表格?
Posted
技术标签:
【中文标题】SwiftUI - 如何将文本视图与另一个文本视图垂直对齐,以制作类似于正确对齐的表格?【英文标题】:SwiftUI - How to vertically align a Text view with another Text view to make something similar to a Table with proper alignment? 【发布时间】:2021-11-05 13:01:44 【问题描述】:所以我基本上需要在 SwiftUI 中做一个类似这样的表格:
正如您在屏幕截图中看到的那样,我很难将字段与其对应的列对齐,例如“John”应该完全以“Name”为中心,“EUR”应该以“Currency”为中心等等. 正如您在代码中看到的那样,我目前正在使用堆栈间距参数对其进行注视,但这实在是太糟糕了,甚至无法在所有设备上工作,而且数据是动态的,并且来自 API 调用,因此无法正常工作。如何在 SwiftUI 上对齐?我需要支持 ios 13。
当前代码:
struct ContentView: View
var body: some View
VStack(spacing: 0)
ZStack
TitleBackground()
Text("Clients")
.foregroundColor(.white)
HStack(spacing: 30)
Text("Name")
Text("Balance")
Text("Currency")
Spacer()
.foregroundColor(Color(#colorLiteral(red: 0.4783782363, green: 0.4784628153, blue: 0.4783664346, alpha: 1)))
.frame(maxWidth:.infinity)
.padding(12)
.background(Color(#colorLiteral(red: 0.9332349896, green: 0.9333916306, blue: 0.9332130551, alpha: 1)))
RowView()
RowView()
RowView()
RowView()
struct RowView : View
var body: some View
HStack(spacing: 30)
Text("John")
Text("$5300")
Text("EUR")
Spacer()
.padding(12)
struct TitleBackground: View
var body: some View
ZStack(alignment: .bottom)
Rectangle()
.frame(maxWidth: .infinity, maxHeight: 60)
.foregroundColor(.red)
.cornerRadius(15)
//We only need the top corners rounded, so we embed another rectangle to the bottom.
Rectangle()
.frame(maxWidth: .infinity, maxHeight: 15)
.foregroundColor(.red)
【问题讨论】:
尝试阐明您希望布局的外观。它是否只有 3 个“列”?如果是这样,您是否希望它们填充视图的宽度?您希望名称在名称标题下左对齐还是居中?余额宝呢?如果第 1 行的余额为50
,第 2 行的余额为 235750
,您是否希望这些字符串居中?还是右对齐?等等……
看起来你可以在这篇文章中使用HorizontalAlignment
和alignmentGuide
swiftui-lab.com/alignment-guides
【参考方案1】:
有两种方法可以解决这个问题。首先,您可以使用两个 你可以用LazyVGrids
并设置相同的列,但您必须修复一些大小。第二种方法是PreferenceKeys
解决这个问题。
LazyVGrid
:
struct LazyVGridView: View
let columns = [
GridItem(.fixed(100)),
GridItem(.fixed(100)),
GridItem(.adaptive(minimum: 180))
]
var body: some View
VStack
LazyVGrid(columns: columns)
Text("Name")
.padding()
Text("Balance")
.padding()
Text("Currency")
.padding()
.foregroundColor(Color(#colorLiteral(red: 0.4783782363, green: 0.4784628153, blue: 0.4783664346, alpha: 1)))
.background(Color(#colorLiteral(red: 0.9332349896, green: 0.9333916306, blue: 0.9332130551, alpha: 1)))
LazyVGrid(columns: columns)
ForEach(clients) client in
Text(client.name)
.padding()
Text(client.balance.description)
.padding()
Text(client.currency)
.padding()
`PreferenceKeys`:
struct PrefKeyAlignmentView: View
let clients: [ClientInfo] = [
ClientInfo(name: "John", balance: 300, currency: "EUR"),
ClientInfo(name: "Jennifer", balance: 53000, currency: "EUR"),
ClientInfo(name: "Jo", balance: 30, currency: "EUR"),
ClientInfo(name: "Jill", balance: 530000, currency: "EUR")
]
@State private var col1Width: CGFloat = 100
@State private var col2Width: CGFloat = 100
@State private var col3Width: CGFloat = 110
var body: some View
VStack
HStack()
Text("Name")
.padding()
// The background view takes on the size of the Text + the padding, and the GeometryReader reads that size
.background(GeometryReader geometry in
Color.clear.preference(
//This sets the preference key value with the width of the background view
key: Col1WidthPrefKey.self,
value: geometry.size.width)
)
// This set the frame to the column width variable defined above
.frame(width: col1Width)
Text("Balance")
.padding()
.background(GeometryReader geometry in
Color.clear.preference(
key: Col2WidthPrefKey.self,
value: geometry.size.width)
)
.frame(width: col2Width)
Text("Currency")
.padding()
.background(GeometryReader geometry in
Color.clear.preference(
key: Col3WidthPrefKey.self,
value: geometry.size.width)
)
.frame(width: col3Width)
Spacer()
ForEach(clients) client in
HStack
Text(client.name)
.padding()
.background(GeometryReader geometry in
Color.clear.preference(
key: Col1WidthPrefKey.self,
value: geometry.size.width)
)
.frame(width: col1Width)
Text(client.balance.description)
.padding()
.background(GeometryReader geometry in
Color.clear.preference(
key: Col2WidthPrefKey.self,
value: geometry.size.width)
)
.frame(width: col2Width)
Text(client.currency)
.padding()
.background(GeometryReader geometry in
Color.clear.preference(
key: Col3WidthPrefKey.self,
value: geometry.size.width)
)
.frame(width: col3Width)
Spacer()
//This sets the various column widths whenever a new entry as found.
//It keeps the larger of the new item's width, or the last largest width.
.onPreferenceChange(Col1WidthPrefKey.self)
col1Width = max($0,col1Width)
.onPreferenceChange(Col2WidthPrefKey.self)
col2Width = max($0,col2Width)
.onPreferenceChange(Col3WidthPrefKey.self)
col3Width = max($0,col3Width)
struct ClientInfo: Identifiable
let id = UUID()
let name: String
let balance: Int
let currency: String
private extension PrefKeyAlignmentView
struct Col1WidthPrefKey: PreferenceKey
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat)
value = max(value, nextValue())
struct Col2WidthPrefKey: PreferenceKey
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat)
value = max(value, nextValue())
struct Col3WidthPrefKey: PreferenceKey
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat)
value = max(value, nextValue())
我不认为使用 alignmentGuides 会起作用,因为您必须处理多个视图,并且对齐指南仅对齐一组视图,而不是内部视图。
这两种解决方案都可以。PreferenceKeys
会自动调整所有内容,但理论上可能会因信息过多而超出屏幕范围。 LazyVGrid
更严格,但如果数据太长可能会导致截断/换行。如果您有两个不同的网格,我认为您不能对所有列使用 LazyVGrid
的自适应布局。可以将两者混合并设置LazyVGrid
宽度和PreferenceKeys
,但我没有亲自尝试过。
用于测试的数据源:
struct ClientInfo: Identifiable
let id = UUID()
let name: String
let balance: Int
let currency: String
let clients: [ClientInfo] = [
ClientInfo(name: "John", balance: 300, currency: "EUR"),
ClientInfo(name: "Jennifer", balance: 53000, currency: "EUR"),
ClientInfo(name: "Jo", balance: 30, currency: "EUR"),
ClientInfo(name: "Jill", balance: 530000, currency: "EUR")
]
编辑:在处理答案时,我忘记了您仅限于 iOS 13。LazyVGrids
不可用。我留下了LazyVGrid
的答案,因为如果您想将if #available(os:)
用于以后的操作系统或寻找更新解决方案的其他人,您可以使用它。
第二次编辑:我在考虑如何使这个视图更可重用,我想出了第三种方式,它也兼容 iOS 13,并且只使用一个 PreferenceKey
来帮助设置标题的背景颜色.这也允许无限列。我认为这是最简单的方法:
struct TableView: View
let headers: [String]
let tableData: [String:[String]]
let dataAlignment: [String:HorizontalAlignment]
let headerColor: Color
@State var headerHeight: CGFloat = 30
init(headers: [String], headerColor: Color = Color.clear, columnData: [[String]], alignment: [HorizontalAlignment])
self.headers = headers
self.headerColor = headerColor
if headers.count == columnData.count,
headers.count == alignment.count
var data: [String:[String]] = [:]
var align: [String:HorizontalAlignment] = [:]
for i in 0..<headers.count
let key = headers[i]
let value = columnData[i]
data[key] = value
align[key] = alignment[i]
dataAlignment = align
tableData = data
else
tableData = [:]
dataAlignment = [:]
var body: some View
ScrollView
HStack(spacing: 30)
ForEach(headers, id: \.self) header in
VStack
Text(header)
.padding()
.background( GeometryReader geometry in
Color.clear.preference(
key: HeaderHeightPrefKey.self,
value: geometry.size.height)
)
VStack(alignment: dataAlignment[header] ?? .center)
ForEach(tableData[header] ?? [], id: \.self) entry in
Text(entry)
.background(
VStack
Rectangle()
.fill(headerColor)
.frame(height: headerHeight, alignment: .top)
Spacer()
)
.onPreferenceChange(HeaderHeightPrefKey.self)
headerHeight = max($0,headerHeight)
private extension TableView
struct HeaderHeightPrefKey: PreferenceKey
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat)
value = max(value, nextValue())
它是这样使用的:
TableView(headers: ["Name", "Balance", "Currency"], headerColor: Color(uiColor: .systemGray5), columnData: [
["John", "Jennifer", "Jo", "Jill"],
["300", "53000", "30", "530000"],
["EUR", "EUR", "EUR", "EUR"]
], alignment: [.center, .trailing, .center])
并产生这个:
【讨论】:
非常感谢您提供两种解决方案!我从来没有读过任何关于 pref 键或它们如何工作的东西,所以我需要做一些研究才能真正理解 100% 这里发生了什么。无论出于何种原因,我认为有一种更简单的方法可以将视图相互对齐,但我想就是这样。至于网格,是的,我想到了这些,我看看我是否能说服我的团队在下一个版本中放弃 iOS 13,但我们现在确实支持它,所以这很糟糕。无论如何,请认真感谢您花时间来制定不是一个而是两个解决方案。非常感谢你!这在 UIKIT 上要容易得多,对吧? 是和不是。它在 UIKit 上更加严格。使用 SwiftUI,您必须允许视图的流动性。除了 iOS 13 的限制之外,SwiftUI 更容易。您不必管理约束。当您尝试将 SwiftUI 视为 UIKit 和约束时,您会遇到麻烦,因为它的行为完全不同。事实上,它并没有花太长时间。这是非常标准的东西;使用 pref 键的视图几乎无法了解。 完成!如果可以的话,我会选择你的答案和罗布斯,他们真的都很了不起。谢谢你:)。 Rob 从字面上写书,所以一定要看看他的。 我不能不管它。我发布了第三种方式。【参考方案2】:我看到 Yrb 在我之前发布了一个类似的答案,但我还是要发布这个,因为我的解决方案比他们的解决方案更模块化/可组合。
你想要这个布局:
我们将实现一个 API,以便我们可以使用以下代码为表格视图获取布局:
enum ColumnId: Hashable
case name
case balance
case currency
struct TableView: View
var body: some View
WidthPreferenceDomain
VStack(spacing: 0)
HStack(spacing: 30)
Text("Name")
.widthPreference(ColumnId.name)
Text("Balance")
.widthPreference(ColumnId.balance)
Text("Currency")
.widthPreference(ColumnId.currency)
Spacer()
.frame(maxWidth:.infinity)
.padding(12)
.background(Color(#colorLiteral(red: 0.9332349896, green: 0.9333916306, blue: 0.9332130551, alpha: 1)))
ForEach(0 ..< 4) _ in
RowView()
在上面的代码中:
我定义了一个ColumnId
类型来标识每个表格列。
我插入了一个WidthPreferenceDomain
容器视图,我们将在下面实现它。
我为每个表格行添加了widthPreference
修饰符,我们将在下面实现。
我们还需要像这样在RowView
中使用widthPreference
修饰符:
struct RowView : View
var body: some View
HStack(spacing: 30)
Text("John")
.widthPreference(ColumnId.name)
Text("$5300")
.widthPreference(ColumnId.balance)
Text("EUR")
.widthPreference(ColumnId.currency)
Spacer()
.padding(12)
我已将此答案的完整代码放在a gist for easy copying。
那么,我们如何实现WidthPreferenceDomain
和widthPreference
?
我们需要测量 Name 列中所有项目的宽度,包括 Name 标题本身,以便我们可以将 Name 列的所有项目设置为具有相同的宽度。我们需要对 Balance 和 Currency 列重复该过程。
要测量项目,我们可以使用GeometryReader
。因为GeometryReader
是一个贪婪视图,它会扩展以填充与其父级提供的尽可能多的空间,所以我们不想将任何表格项包装在GeometryReader
中。相反,我们想在每个表项上的background
中放置一个GeometryReader
。 background
视图与其父视图具有相同的框架。
要收集测量的宽度,我们可以通过定义符合PreferenceKey
协议的类型来使用“首选项”。由于我们想收集所有列的宽度,我们将把宽度存储在Dictionary
中,并以列 ID 为键。为了合并同一列的两个宽度,我们取最大宽度,以便每列扩展以适应其所有项目。
fileprivate struct WidthPreferenceKey: PreferenceKey
static var defaultValue: [AnyHashable: CGFloat] [:]
static func reduce(value: inout [AnyHashable : CGFloat], nextValue: () -> [AnyHashable : CGFloat])
value.merge(nextValue(), uniquingKeysWith: max($0, $1) )
要将收集到的宽度分配回表格项,我们可以通过定义符合EnvironmentKey
协议的类型来使用“环境”。 EnvironmentKey
只有一个要求,一个static var defaultValue
属性,它匹配PreferenceKey
的defaultValue
属性,所以我们可以重用我们已经定义的WidthPreferenceKey
类型:
extension WidthPreferenceKey: EnvironmentKey
要使用环境,我们还需要给EnvironmentValues
添加一个属性:
extension EnvironmentValues
fileprivate var widthPreference: WidthPreferenceKey.Value
get self[WidthPreferenceKey.self]
set self[WidthPreferenceKey.self] = newValue
现在我们可以定义widthPreference
修饰符:
extension View
public func widthPreference<Domain: Hashable>(_ key: Domain) -> some View
return self.modifier(WidthPreferenceModifier(key: key))
fileprivate struct WidthPreferenceModifier: ViewModifier
@Environment(\.widthPreference) var widthPreference
var key: AnyHashable
func body(content: Content) -> some View
content
.fixedSize(horizontal: true, vertical: false)
.background(GeometryReader proxy in
Color.clear
.preference(key: WidthPreferenceKey.self, value: [key: proxy.size.width])
)
.frame(width: widthPreference[key])
此修饰符使用背景GeometryReader
测量修改后的视图的宽度并将其存储在首选项中。它还设置视图的宽度,如果来自环境的widthPreference
具有它的宽度。请注意widthPreference[key]
在第一个布局过程中将是nil
,但这没关系,因为frame(width: nil)
是“inert” modifier;没有效果。
现在我们可以实现WidthPreferenceDomain
。它需要接收首选项并将其存储在环境中:
public struct WidthPreferenceDomain<Content: View>: View
@State private var widthPreference: WidthPreferenceKey.Value = [:]
private var content: Content
public init(@ViewBuilder content: () -> Content)
self.content = content()
public var body: some View
content
.environment(\.widthPreference, widthPreference)
.onPreferenceChange(WidthPreferenceKey.self) widthPreference = $0
【讨论】:
哇,老实说,我没想到人们会真正从头开始重新创建我的布局来帮助我,非常感谢你的这一点以及详尽的解释。老实说,我才刚刚开始,从来没有读过任何关于偏好键或它们如何工作的东西,所以说实话似乎有点令人生畏!但是我确实在一个新项目中运行了您的代码,最终结果正是我所追求的,所以我想我有大量的阅读/学习要做才能完全掌握这里发生的事情。这种布局在 UIKit 中要容易得多,对吧?无论如何,非常感谢!现在我会阅读偏好键! 我没有从头开始重新创建您的布局 - 我将您的代码复制并粘贴到一个新项目中,然后对其进行了修改。干得好,在您的问题中包含完整的工作示例! — Apple 提供的关于 SwiftUI 偏好的官方文档确实不多。 SwiftUI-Lab 是高级 SwiftUI 的金矿。 — 使用 UIKit 的布局约束,很容易使不同的视图具有匹配的宽度。 SwiftUI 尚未让我们真正控制其布局系统。以上是关于SwiftUI - 如何将文本视图与另一个文本视图垂直对齐,以制作类似于正确对齐的表格?的主要内容,如果未能解决你的问题,请参考以下文章