iosswiftui

Align two SwiftUI text views in HStack with correct alignment


I have a simple list view that contains two rows.

Each row contains two text views. View one and View two.

I would like to align the last label (View two) in each row so that the name labels are leading aligned and keep being aligned regardless of font size.

The first label (View one) also needs to be leading aligned.

I've tried setting a min frame width on the first label (View One) but it doesn't work. It also seems impossible to set the min width and also to get a text view to be leading aligned in View One.

Any ideas? This is fairly straight forward in UIKit.

List View


Solution

  • 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/