【发布时间】:2019-10-28 12:15:01
【问题描述】:
我有一个包含两行的简单列表视图。
每行包含两个文本视图。查看一和查看二。
我想对齐每行中的最后一个标签(查看两个),以便名称标签前导对齐,并且无论字体大小如何都保持对齐。
第一个标签(查看一个)也需要前导对齐。
我尝试在第一个标签(查看一个)上设置最小帧宽度,但它不起作用。设置最小宽度并让文本视图在视图一中对齐似乎也是不可能的。
有什么想法吗?这在 UIKit 中是相当直接的。
【问题讨论】:
我有一个包含两行的简单列表视图。
每行包含两个文本视图。查看一和查看二。
我想对齐每行中的最后一个标签(查看两个),以便名称标签前导对齐,并且无论字体大小如何都保持对齐。
第一个标签(查看一个)也需要前导对齐。
我尝试在第一个标签(查看一个)上设置最小帧宽度,但它不起作用。设置最小宽度并让文本视图在视图一中对齐似乎也是不可能的。
有什么想法吗?这在 UIKit 中是相当直接的。
【问题讨论】:
我找到了一种解决此问题的方法,该方法支持动态类型并且不是 hacky。答案是使用PreferenceKeys 和GeometryReader!
这个解决方案的本质是每个数字Text 都会有一个宽度,根据它的文本大小绘制它。 GeometryReader 可以检测到这个宽度,然后我们可以使用 PreferenceKey 将其冒泡到 List 本身,在那里可以跟踪最大宽度,然后分配给每个数字 Text 的帧宽度。
PreferenceKey 是您使用关联类型创建的类型(可以是符合 Equatable 的任何结构,这是您存储有关首选项的数据的地方),它附加到任何 View,并且当它是附加后,它会在视图树中冒泡,并且可以使用 .onPreferenceChange(PreferenceKeyType.self) 在祖先视图中收听。
首先,我们将创建 PreferenceKey 类型及其包含的数据:
struct CenteringColumnPreferenceKey: PreferenceKey {
typealias Value = [CenteringColumnPreference]
static var defaultValue: [CenteringColumnPreference] = []
static func reduce(value: inout [CenteringColumnPreference], nextValue: () -> [CenteringColumnPreference]) {
value.append(contentsOf: nextValue())
}
}
struct CenteringColumnPreference: Equatable {
let width: CGFloat
}
接下来,我们将创建一个名为CenteringView 的视图,它将附加到我们想要调整大小的任何内容(在本例中为数字标签)的背景。这将负责设置首选项,该首选项将使用 PreferenceKeys 传递此数字标签的首选宽度。
struct CenteringView: View {
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(
key: CenteringColumnPreferenceKey.self,
value: [CenteringColumnPreference(width: geometry.frame(in: CoordinateSpace.global).width)]
)
}
}
}
最后,列表本身!我们有一个@State 变量,它是数字“列”的宽度(不是真正的列,因为数字不会直接影响代码中的其他数字)。通过.onPreferenceChange(CenteringColumnPreference.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(CenteringView())
Text("John Smith")
}
HStack {
Text("20. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(CenteringView())
Text("Jane Done")
}
HStack {
Text("2000. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(CenteringView())
Text("Jax Dax")
}
}.onPreferenceChange(CenteringColumnPreferenceKey.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/
【讨论】:
我只需要处理这个。依赖固定宽度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
【讨论】:
Text 元素时,.trailing 出现错误,就像你的例子一样;但如果我只有一个 Text 元素,我没有。错误是“静态成员'尾随'不能用于'Alignment'类型的实例”。奇怪的是Text元素的数量导致了这个?你有什么想法吗?
.accessibility(hidden: true)。
在 Swift 5.2 和 iOS 13 中,您可以使用PreferenceKey 协议、preference(key:value:) 方法和onPreferenceChange(_:perform:) 方法来解决这个问题。
您可以通过3个主要步骤实现OP提出的View的代码,如下所示。
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")
}
}
}
这里的想法是收集代表等级的Texts 的所有宽度,并将其中最宽的宽度分配给ContentView 的width 属性。
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")
}
}
}
为了使我们的代码可重用,我们可以将 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")
}
}
}
结果如下:
【讨论】:
这里有三个静态选项。
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)
}
}
}
}
我们可以根据this answer 中建议的最长条目来计算宽度。
有几个选项可以动态计算宽度。
选项 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)
}
}
}
}
【讨论】:
.frame(minWidth: 100,idealWidth: 150,maxWidth:.infinity, alignment: .leading) 对齐......这个方法工作得很好......
您可以在 HStack 中添加两个 Texts,然后是 Spacer。 Spacer 会将您的 Texts 推向左侧,如果 Texts 由于内容长度而改变大小,所有内容都会自行调整:
HStack {
Text("1.")
Text("John Doe")
Spacer()
}
.padding()
Texts 在技术上是居中对齐的,但是由于视图会自动调整大小并且只占用与其中的文本一样多的空间(因为我们没有明确设置框架大小),并且被推送到在Spacer 左边,它们看起来是左对齐的。与设置固定宽度相比,这样做的好处是您不必担心文本会被截断。
另外,我在HStack 中添加了填充以使其看起来更漂亮,但如果您想调整Texts 彼此之间的接近程度,您可以手动设置其任一侧的填充。 (您甚至可以设置负填充以将项目彼此推得比它们的自然间距更近)。
编辑
没有意识到 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 原则,但我找不到内置方法来执行您要求的布局(可能是因为 Texts 在不同行不相互交互,因此它们无法知道如何保持垂直对齐)。
编辑 2 上面的代码生成这个:
【讨论】:
如果您可以接受 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")
}
}
【讨论】:
HStack {
HStack {
Spacer()
Text("5.")
}
.frame(width: 40)
Text("Jon Smith")
}
但这仅适用于固定宽度。
.frame(minWidth: 40) 将填满整个 View 因为 Space()
.multilineTextAlignment(.leading) 在我的测试中没有任何影响。
【讨论】:
在尝试让它工作一整天后,我想出了这个解决方案:
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)")
}
}
}
}
}
}
如果其中一个视图增长或缩小,同一组中的所有视图都将随之增长/缩小。我只是让它工作,所以我没有尝试过那么多。 这有点像黑客,但是,嘿,这似乎不是黑客以外的另一种方式。
【讨论】:
我认为正确的方法是使用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()
}
}
我希望这将在以后的测试版中得到修复...
【讨论】: