Align two SwiftUI text views in HStack with correct alignment
I've found a way to fix this that supports dynamic type and isn't hacky. The answer is using PreferenceKeys and GeometryReader!
The essence of this solution is that each number Text
will have a width that it will be drawn with depending on its text size. GeometryReader
can detect this width and then we can use PreferenceKey
to bubble it up to the List itself, where the max width can be kept track of and then assigned to each number Text
's frame width.
A PreferenceKey
is a type you create with an associated type (can be any struct conforming to Equatable
, this is where you store the data about the preference) that is attached to any View
and when it is attached, it bubbles up through the view tree and can be listened to in an ancestor view by using .onPreferenceChange(PreferenceKeyType.self)
.
To start, we'll create our PreferenceKey type and the data it contains:
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}
Next, we'll create a View called CenteringView
that will be attached to the background of whatever we want to size (in this case, the number labels). This will take care of setting the preference which will pass up this number label's preferred width with 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)] ) } }}
Lastly, the list itself! We have an @State variable which is the width of the numbers "column" (not really a column in the sense that the numbers don't directly affect other numbers in code). Through .onPreferenceChange(CenteringColumnPreference.self)
we listen to changes in the preference we created and store the max width in our width state. After all of the number labels have been drawn and their width read by the GeometryReader, the widths propagate back up and the max width is assigned by .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 } } } }}
If you have multiple columns of data, one way to scale this is to make an enum of your columns or to index them, and the @State for width would become a dictionary where each key is a column and .onPreferenceChange
compares against the key-value for the max width of a column.
To show results, this is what it looks like with larger text turned on, works like a charm :).
This article on PreferenceKey and inspecting the view tree helped tremendously: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/
I just had to deal with this. The solutions that rely on a fixed width frame
won't work for dynamic type, so I couldn't use them. The way I got around it was by putting the flexible item (the left number in this case) in a ZStack
with a placeholder containing the widest allowable content, and then setting the placeholder's opacity to 0:
ZStack { Text("9999") .opacity(0) .accessibility(visibility: .hidden) Text(id)}
It's pretty hacky, but at least it supports dynamic type 🤷♂️
Full example below! 📜
import SwiftUIstruct 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 DEBUGstruct 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
With Swift 5.2 and iOS 13, you can use PreferenceKey
protocol, preference(key:value:)
method and onPreferenceChange(_:perform:)
method to solve this problem.
You can implement the code for the View
proposed by OP in 3 major steps, as shown below.
#1. Initial implementation
import SwiftUIstruct 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. Intermediate implementation (set equal width)
The idea here is to collect all the widths for the Text
s that represent a rank and assign the widest among them to the width
property of ContentView
.
import SwiftUIstruct 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. Final implementation (refactoring)
To make our code reusable, we can refactor our preference
logic into a ViewModifier
.
import SwiftUIstruct 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") } }}
The result looks like this:
![](https://i.stack.imgur.com/xQI0e.png)