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 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
}

Next, we'll create a View called WidthPreferenceSettingView 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 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)]
                )
        }
    }
}

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(WidthPreference.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(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
                }
            }
        }
    }
}

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 🤷‍♂️

ZStack with placeholder

Full example below! 📜

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

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 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. Intermediate implementation (set equal width)

The idea here is to collect all the widths for the Texts that represent a rank and assign the widest among them to the width property of ContentView.

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. Final implementation (refactoring)

To make our code reusable, we can refactor our preference logic into a 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")
        }
    }

}

The result looks like this:

Tags:

Ios

Swiftui