将 HStack 中的两个 SwiftUI 文本视图与正确的对齐方式对齐

Align two SwiftUI text views in HStack with correct alignment

我有一个包含两行的简单列表视图。

每行包含两个文本视图。看一看二。

我想对齐每行中的最后一个标签(视图二),以便名称标签前导对齐并保持对齐,无论字体大小如何。

第一个标签(查看一个)也需要前导对齐。

我试过在第一个标签(View One)上设置最小框架宽度,但它不起作用。设置最小宽度和让文本视图在视图一中领先对齐似乎也是不可能的。

有什么想法吗?这在 UIKit 中相当简单。

您可以为数字 Text 视图设置固定宽度。它使这个 Text 组件具有固定大小。

HStack {
        Text(item.number)
            .multilineTextAlignment(.leading)
            .frame(width: 30)
        Text(item.name)
}

这个解决方案的缺点是,如果你在那里有更长的文本,它将被换行并以“...”结尾,但在那种情况下我认为你可以粗略估计哪个宽度就足够了.

您可以只拥有两个 Text,然后在一个 HStack 中拥有一个 SpacerSpacer 会将您的 Text 推向左侧,如果 Text 中的任何一个由于其内容的长度而改变大小,所有内容都会 self-adjust:

HStack {
    Text("1.")
    Text("John Doe")
    Spacer()
}
.padding()

Text 在技术上是 center-aligned,但由于视图会自动调整大小并且只占用与其中文本一样多的 space(因为我们没有明确设置帧大小),并被 Spacer 推到左侧,它们出现 left-aligned。与设置固定宽度相比,这样做的好处是您不必担心文本被截断。

此外,我在 HStack 中添加了填充以使其看起来更好,但是如果您想调整 Text 彼此之间的距离,您可以手动设置填充它的任何一面。 (您甚至可以设置负填充以使项目彼此之间的距离比它们的自然间距更近)。

编辑

没有意识到 OP 也需要第二个 Text 垂直对齐。我有办法做到这一点,但它 "hacky" 并且如果不做更多工作就无法用于更大的字体大小:

这些是数据对象:

class Person {
    var name: String
    var id: Int
    init(name: String, id: Int) {
        self.name = name
        self.id = id
    }
}

class People {
    var people: [Person]
    init(people: [Person]) {
        self.people = people
    }
    func maxIDDigits() -> Int {
        let maxPerson = people.max { (p1, p2) -> Bool in
            p1.id < p2.id
        }
        print(maxPerson!.id)
        let digits = log10(Float(maxPerson!.id)) + 1
        return Int(digits)
    }
    func minTextWidth(fontSize: Int) -> Length {
        print(maxIDDigits())
        print(maxIDDigits() * 30)
        return Length(maxIDDigits() * fontSize)
    }
}

这是View:

var people = People(people: [Person(name: "John Doe", id: 1), Person(name: "Alexander Jones", id: 2000), Person(name: "Tom Lee", id: 45)])
var body: some View {   
    List {
        ForEach(people.people.identified(by: \.id)) { person in                
            HStack {
                Text("\(person.id).")
                    .frame(minWidth: self.people.minTextWidth(fontSize: 12), alignment: .leading)
                Text("\(person.name)")

            }
        }
    }
}

要使其适用于多种字体大小,您必须获取字体大小并将其传递到 minTextWidth(fontSize:)

再次强调,这是 "hacky" 并且可能违反 SwiftUI 原则,但我找不到一种内置的方式来完成您要求的布局(可能是因为 Text 不同行中的 s 不会相互影响,因此它们无法知道如何保持垂直对齐。

编辑 2 上面的代码生成这个:

HStack {
            HStack {
                Spacer()
                Text("5.")
            }
            .frame(width: 40)

            Text("Jon Smith")
        }

但这只适用于固定宽度。 .frame(minWidth: 40) 将填满整个视图,因为 Space()

.multilineTextAlignment(.leading) 在我的测试中没有任何影响。

我认为正确的方法是使用 HorizontalAlignment。类似于:

extension HorizontalAlignment {
    private enum LeadingName : AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> Length { d[.leading] }
    }
    static let leadingName = HorizontalAlignment(LeadingName.self)
}

List (people.identified(by: \.id)) {person in
    HStack {
        Text("\(person.id)")
        Text("\(person.name)").alignmentGuide(.leadingName) {d in d[.leading]}
    }
}

但我无法让它工作。

我找不到任何带有列表的示例。 List 似乎不支持对齐(还没有?)

我可以让它与 VStack 和硬编码值一起工作,例如:

        VStack (alignment: .leadingName ) {
        HStack {
            Text("1.")
            Text("John Doe").alignmentGuide(.leadingName) {d in d[.leading]}
            Spacer()
        }
        HStack {
            Text("2000.")
            Text("Alexander Jones").alignmentGuide(.leadingName) {d in d[.leading]}
            Spacer()
        }
        HStack {
            Text("45.")
            Text("Tom Lee").alignmentGuide(.leadingName) {d in d[.leading]}
            Spacer()
        }
    }

我希望这会在以后的测试版中得到修复...

我不得不处理这个。依赖固定宽度 frame 的解决方案不适用于动态类型,因此我无法使用它们。我解决这个问题的方法是将灵活项目(在本例中为左侧数字)放在 ZStack 中,并使用包含最宽允许内容的占位符,然后将占位符的不透明度设置为 0:

ZStack {
    Text("9999")
        .opacity(0)
        .accessibility(visibility: .hidden)
    Text(id)
}

虽然有点hacky,但至少支持动态类型‍♂️

完整示例如下!

import SwiftUI

struct Person: Identifiable {
    var name: String
    var id: Int
}

struct IDBadge : View {
    var id: Int
    var body: some View {
        ZStack(alignment: .trailing) {
            Text("9999.") // The maximum width dummy value
                .font(.headline)
                .opacity(0)
                .accessibility(visibility: .hidden)
            Text(String(id) + ".")
                .font(.headline)
        }
    }
}

struct ContentView : View {
    var people: [Person]
    var body: some View {
        List(people) { person in
            HStack(alignment: .top) {
                IDBadge(id: person.id)
                Text(person.name)
                    .lineLimit(nil)
            }
        }
    }
}

#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static let people = [Person(name: "John Doe", id: 1), Person(name: "Alexander Jones", id: 2000), Person(name: "Tom Lee", id: 45)]
    static var previews: some View {
        Group {
            ContentView(people: people)
                .previewLayout(.fixed(width: 320.0, height: 150.0))
            ContentView(people: people)
                .environment(\.sizeCategory, .accessibilityMedium)
                .previewLayout(.fixed(width: 320.0, height: 200.0))
        }
    }
}
#endif

我找到了一种解决此问题的方法,该方法支持动态类型并且不是 hacky。答案是使用 PreferenceKeys and GeometryReader!

这个解决方案的本质是每个数字 Text 都有一个宽度,根据其文本大小绘制。 GeometryReader 可以检测到这个宽度,然后我们可以使用 PreferenceKey 将其冒泡到 List 本身,其中可以跟踪最大宽度,然后将其分配给每个数字 Text框架宽度。

A PreferenceKey 是您使用关联类型创建的类型(可以是任何符合 Equatable 的结构,这是您存储有关首选项的数据的地方)附加到任何 View 并且当它附加时,它会在视图树中向上冒泡,并且可以使用 .onPreferenceChange(PreferenceKeyType.self).

在祖先视图中收听

首先,我们将创建 PreferenceKey 类型及其包含的数据:

struct WidthPreferenceKey: PreferenceKey {
    typealias Value = [WidthPreference]
    
    static var defaultValue: [WidthPreference] = []
    
    static func reduce(value: inout [WidthPreference], nextValue: () -> [WidthPreference]) {
        value.append(contentsOf: nextValue())
    }
}

struct WidthPreference: Equatable {
    let width: CGFloat
}

接下来,我们将创建一个名为 WidthPreferenceSettingView 的视图,它将附加到我们想要调整大小的背景(在本例中为数字标签)。这将负责设置首选项,该首选项将使用 PreferenceKeys 传递此数字标签的首选宽度。

struct WidthPreferenceSettingView: View {
    var body: some View {
        GeometryReader { geometry in
            Rectangle()
                .fill(Color.clear)
                .preference(
                    key: WidthPreferenceKey.self,
                    value: [WidthPreference(width: geometry.frame(in: CoordinateSpace.global).width)]
                )
        }
    }
}

最后,列表本身!我们有一个 @State 变量,它是数字“列”的宽度(在数字不直接影响代码中的其他数字的意义上并不是真正的列)。通过 .onPreferenceChange(WidthPreference.self) 我们监听我们创建的首选项的变化,并将最大宽度存储在我们的宽度状态中。在绘制完所有数字标签并由 GeometryReader 读取它们的宽度后,宽度传播回来,最大宽度由 .frame(width: width)

分配
struct ContentView: View {
    @State private var width: CGFloat? = nil
    
    var body: some View {
        List {
            HStack {
                Text("1. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(WidthPreferenceSettingView())
                Text("John Smith")
            }
            HStack {
                Text("20. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(WidthPreferenceSettingView())
                Text("Jane Done")
            }
            HStack {
                Text("2000. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(WidthPreferenceSettingView())
                Text("Jax Dax")
            }
        }.onPreferenceChange(WidthPreferenceKey.self) { preferences in
            for p in preferences {
                let oldWidth = self.width ?? CGFloat.zero
                if p.width > oldWidth {
                    self.width = p.width
                }
            }
        }
    }
}

如果您有多列数据,一种缩放方法是对您的列进行枚举或对它们进行索引,宽度的@State 将成为一个字典,其中每个键都是一列并且 .onPreferenceChange 与列的最大宽度的键值进行比较。

为了显示结果,this 是打开较大文本后的样子,效果很好:)。

这篇关于 PreferenceKey 和检查视图树的文章帮助很大:https://swiftui-lab.com/communicating-with-the-view-tree-part-1/

这里有三个静态选项。

struct ContentView: View {
    @State private var width: CGFloat? = 100

    var body: some View {
        List {
            HStack {
                Text("1. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(Color.blue)
                // Option 1
                Text("John Smith")
                    .multilineTextAlignment(.leading)
                    //.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .background(Color.green)
            }
            HStack {
                Text("20. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(Color.blue)
                // Option 2 (works mostly like option 1)
                Text("Jane Done")
                    .background(Color.green)
                Spacer()
            }
            HStack {
                Text("2000. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(Color.blue)
                // Option 3 - takes all the rest space to the right
                Text("Jax Dax")
                    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .background(Color.green)
            }
        }
    }
}

外观如下:

我们可能会根据 中建议的最长条目来计算宽度。

有几个选项可以动态计算宽度。

选项 1

import SwiftUI
import Combine

struct WidthGetter: View {
    let widthChanged: PassthroughSubject<CGFloat, Never>
    var body: some View {
        GeometryReader { (g) -> Path in
            print("width: \(g.size.width), height: \(g.size.height)")
            self.widthChanged.send(g.frame(in: .global).width)
            return Path() // could be some other dummy view
        }
    }
}

struct ContentView: View {
    let event = PassthroughSubject<CGFloat, Never>()

    @State private var width: CGFloat?

    var body: some View {
        List {
            HStack {
                Text("1. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(Color.blue)
                    .background(WidthGetter(widthChanged: event))
 
                // Option 1
                Text("John Smith")
                    .multilineTextAlignment(.leading)
                    //.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .background(Color.green)
            }
            HStack {
                Text("20. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(Color.blue)
                    .background(WidthGetter(widthChanged: event))
                // Option 2 (works mostly like option 1)
                Text("Jane Done")
                    .background(Color.green)
                Spacer()
            }
            HStack {
                Text("2000. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(Color.blue)
                    .background(WidthGetter(widthChanged: event))
                // Option 3 - takes all the rest space to the right
                Text("Jax Dax")
                    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .background(Color.green)
            }
        }.onReceive(event) { (w) in
            print("event ", w)
            if w > (self.width ?? .zero) {
                self.width = w
            }
        }
    }
}

选项 2

import SwiftUI

struct ContentView: View {
    
    @State private var width: CGFloat?
    
    var body: some View {
        List {
            HStack {
                Text("1. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(Color.blue)
                    .alignmentGuide(.leading, computeValue: { dimension in
                        self.width = max(self.width ?? 0, dimension.width)
                        return dimension[.leading]
                    })
                
                // Option 1
                Text("John Smith")
                    .multilineTextAlignment(.leading)
                    //.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .background(Color.green)
            }
            HStack {
                Text("20. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(Color.blue)
                    .alignmentGuide(.leading, computeValue: { dimension in
                        self.width = max(self.width ?? 0, dimension.width)
                        return dimension[.leading]
                    })
                // Option 2 (works mostly like option 1)
                Text("Jane Done")
                    .background(Color.green)
                Spacer()
            }
            HStack {
                Text("2000. ")
                    .frame(width: width, alignment: .leading)
                    .lineLimit(1)
                    .background(Color.blue)
                    .alignmentGuide(.leading, computeValue: { dimension in
                        self.width = max(self.width ?? 0, dimension.width)
                        return dimension[.leading]
                    })
                // Option 3 - takes all the rest space to the right
                Text("Jax Dax")
                    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .background(Color.green)
            }
        }
    }
}

结果如下所示:

有了Swift5.2和iOS13,可以用PreferenceKey protocol, preference(key:value:) method and onPreferenceChange(_:perform:)的方法解决这个问题。

您可以通过 3 个主要步骤实现 OP 提出的 View 代码,如下所示。


#1。初步实施

import SwiftUI

struct ContentView: View {

    var body: some View {
        NavigationView {
            List {
                HStack {
                    Text("5.")
                    Text("John Smith")
                }
                HStack {
                    Text("20.")
                    Text("Jane Doe")
                }
            }
            .listStyle(GroupedListStyle())
            .navigationBarTitle("Challenge")
        }
    }

}

#2。中间实现(设置等宽)

这里的想法是收集代表一个等级的Text的所有宽度,并将其中最宽的分配给ContentViewwidth 属性 .

import SwiftUI

struct WidthPreferenceKey: PreferenceKey {

    static var defaultValue: [CGFloat] = []
    static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
        value.append(contentsOf: nextValue())
    }

}

struct ContentView: View {

    @State private var width: CGFloat? = nil

    var body: some View {
        NavigationView {
            List {
                HStack {
                    Text("5.")
                        .overlay(
                            GeometryReader { proxy in
                                Color.clear
                                    .preference(
                                        key: WidthPreferenceKey.self,
                                        value: [proxy.size.width]
                                    )
                            }
                        )
                        .frame(width: width, alignment: .leading)
                    Text("John Smith")
                }
                HStack {
                    Text("20.")
                        .overlay(
                            GeometryReader { proxy in
                                Color.clear
                                    .preference(
                                        key: WidthPreferenceKey.self,
                                        value: [proxy.size.width]
                                    )
                            }
                        )
                        .frame(width: width, alignment: .leading)
                    Text("Jane Doe")
                }
            }
            .onPreferenceChange(WidthPreferenceKey.self) { widths in
                if let width = widths.max() {
                    self.width = width
                }
            }
            .listStyle(GroupedListStyle())
            .navigationBarTitle("Challenge")
        }
    }

}

#3。最终实施(重构)

为了使我们的代码可重用,我们可以将 preference 逻辑重构为 ViewModifier.

import SwiftUI

struct WidthPreferenceKey: PreferenceKey {

    static var defaultValue: [CGFloat] = []
    static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
        value.append(contentsOf: nextValue())
    }

}

struct EqualWidth: ViewModifier {

    func body(content: Content) -> some View {
        content
            .overlay(
                GeometryReader { proxy in
                    Color.clear
                        .preference(
                            key: WidthPreferenceKey.self,
                            value: [proxy.size.width]
                        )
                }
            )
    }

}

extension View {
    func equalWidth() -> some View {
        modifier(EqualWidth())
    }
}

struct ContentView: View {

    @State private var width: CGFloat? = nil

    var body: some View {
        NavigationView {
            List {
                HStack {
                    Text("5.")
                        .equalWidth()
                        .frame(width: width, alignment: .leading)
                    Text("John Smith")
                }
                HStack {
                    Text("20.")
                        .equalWidth()
                        .frame(width: width, alignment: .leading)
                    Text("Jane Doe")
                }
            }
            .onPreferenceChange(WidthPreferenceKey.self) { widths in
                if let width = widths.max() {
                    self.width = width
                }
            }
            .listStyle(GroupedListStyle())
            .navigationBarTitle("Challenge")
        }
    }

}

结果如下所示:

在尝试让它工作一整天后,我想到了这个解决方案:

编辑:Link to Swift Package

import SwiftUI

fileprivate extension Color {
    func exec(block: @escaping ()->Void) -> Self {
        block()
        return self
    }
}

fileprivate class Deiniter {
    let block: ()->Void
    init(block: @escaping ()->Void) {
        self.block = block
    }
    deinit {
       block()
    }
}
struct SameWidthContainer<Content: View>: View {
    private var id: UUID
    private let deiniter: Deiniter
    @ObservedObject private var group: WidthGroup
    private var content: () -> Content
    
    init(group: WidthGroup, content: @escaping ()-> Content) {
        self.group = group
        self.content = content
        
        let id = UUID()
        self.id = id
        

        WidthGroup.widths[group.id]?[id] = 100.0
        self.deiniter = Deiniter() {
            WidthGroup.widths[group.id]?.removeValue(forKey: id)
        }
    }
    
    
    var body: some View {
        ZStack(alignment: .leading) {
            Rectangle()
                .frame(width: self.group.width, height: 1)
                .foregroundColor(.clear)
            
            content()
                .overlay(
                    GeometryReader { proxy in
                        Color.clear
                            .exec {
                                WidthGroup.widths[self.group.id]?[self.id] = proxy.size.width
                                let newWidth = WidthGroup.widths[group.id]?.values.max() ?? 0
                                if newWidth != self.group.width {
                                    self.group.width = newWidth
                                }
                            }
                    }
                )
        }
    }
}

class WidthGroup: ObservableObject {

    static var widths: [UUID: [UUID: CGFloat]] = [:]
    @Published var width: CGFloat = 0.0
    
    let id: UUID
    
    init() {
        let id = UUID()
        self.id = id
        
        WidthGroup.widths[id] = [:]
    }
    
    deinit {
        WidthGroup.widths.removeValue(forKey: id)
    }
}

struct SameWidthText_Previews: PreviewProvider {
    
    private static let GROUP = WidthGroup()
    
    static var previews: some View {
        Group {
            SameWidthContainer(group: Self.GROUP) {
                Text("One")
            }
                
            SameWidthContainer(group: Self.GROUP) {
                Text("Two")
            }
            SameWidthContainer(group: Self.GROUP)  {
                Text("Three")
            }
        }
    }
}

然后这样使用:

struct SomeView: View {
    
    @State private var group1 = WidthGroup()
    @State private var group2 = WidthGroup()
    
    var body: some View {
        VStack() {
            ForEach(9..<12) { index in
                HStack {
                    SameWidthContainer(group: group1) {
                        Text("All these will have same width in group 1 \(index)")
                    }
                    
                    Text("Some other text")
                    
                    SameWidthContainer(group: group2) {
                        Text("All these will have same width in group 2 \(index)")
                    }
                }
            }
        }
    }
}

如果其中一个视图增大或缩小,则同一组中的所有视图都将 grow/shrink 随之增大或缩小。我刚刚让它工作,所以我没有尝试那么多。 这有点像黑客,但是,嘿,这似乎不是黑客的另一种方式。

如果您可以接受 1 行限制:

Group {
    HStack {
        VStack(alignment: .trailing) {
            Text("Vehicle:")
            Text("Lot:")
            Text("Zone:")
            Text("Location:")
            Text("Price:")
        }
        VStack(alignment: .leading) {
            Text("vehicle")
            Text("lot")
            Text("zone")
            Text("location")
            Text("price")
        }
    }
    .lineLimit(1)
    .font(.footnote)
    .foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)

Xcode 12.5

如果您知道要将第二个视图偏移多少,那么您可以将两个视图放在前导对齐 ZStack 中,然后在第二个视图上使用 .padding(.horizontal, amount) 修饰符来偏移它.

var body: some View {
    NavigationView {
        List {
            ForEach(persons) { person in
                ZStack(alignment: .leading) {
                    Text(person.number)
                    Text(person.name)
                        .padding(.horizontal, 30)
                }
            }
        }
        .navigationTitle("Challenge")
    }
}