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,您是否希望这些字符串居中?还是右对齐?等等…… 看起来你可以在这篇文章中使用HorizontalAlignmentalignmentGuide 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。

那么,我们如何实现WidthPreferenceDomainwidthPreference

我们需要测量 Name 列中所有项目的宽度,包括 Name 标题本身,以便我们可以将 Name 列的所有项目设置为具有相同的宽度。我们需要对 Balance 和 Currency 列重复该过程。

要测量项目,我们可以使用GeometryReader。因为GeometryReader 是一个贪婪视图,它会扩展以填充与其父级提供的尽可能多的空间,所以我们不想将任何表格项包装在GeometryReader 中。相反,我们想在每个表项上的background 中放置一个GeometryReaderbackground 视图与其父视图具有相同的框架。

要收集测量的宽度,我们可以通过定义符合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 属性,它匹配PreferenceKeydefaultValue 属性,所以我们可以重用我们已经定义的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 - 如何将文本视图与另一个文本视图垂直对齐,以制作类似于正确对齐的表格?的主要内容,如果未能解决你的问题,请参考以下文章

SwiftUI:如何计算文本视图的宽度?

将 HTML 代码实体传递到 SwiftUI 文本视图

将 SwiftUI 文本与另一个文本的基线和顶部对齐

如何从另一个视图swiftui中传递字段文本值

如何在 swiftUI 视图中使用按钮? [关闭]

如何根据 TextEditor 的文本更改为 SwiftUI 视图设置动画?