18

I have a List that displays days in the current month. Each row in the List contains the abbreviated day, a Divider, and the day number within a VStack. The VStack is then embedded in an HStack so that I can have more text to the right of the day and number.

struct DayListItem : View {

    // MARK: - Properties

    let date: Date

    private let weekdayFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "EEE"
        return formatter
    }()

    private let dayNumberFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "d"
        return formatter
    }()

    var body: some View {
        HStack {
            VStack(alignment: .center) {
                Text(weekdayFormatter.string(from: date))
                    .font(.caption)
                    .foregroundColor(.secondary)
                Text(dayNumberFormatter.string(from: date))
                    .font(.body)
                    .foregroundColor(.red)
            }
            Divider()
        }
    }

}

Instances of DayListItem are used in ContentView:

struct ContentView : View {

    // MARK: - Properties

    private let dataProvider = CalendricalDataProvider()

    private var navigationBarTitle: String {
        let formatter = DateFormatter()
        formatter.dateFormat = "MMMM YYY"
        return formatter.string(from: Date())
    } 

    private var currentMonth: Month {
        dataProvider.currentMonth
    }

    private var months: [Month] {
        return dataProvider.monthsInRelativeYear
    }

    var body: some View {
        NavigationView {
            List(currentMonth.days.identified(by: \.self)) { date in
                DayListItem(date: date)
            }
                .navigationBarTitle(Text(navigationBarTitle))
                .listStyle(.grouped)
        }
    }

}

The result of the code is below:

Screenshot of app running in Xcode Simulator

It may not be obvious, but the dividers are not lined up because the width of the text can vary from row to row. What I would like to achieve is to have the views that contains the day information be the same width so that they are visually aligned.

I have tried using a GeometryReader and the frame() modifiers to set the minimum width, ideal width, and maximum width, but I need to ensure that the text can shrink and grow with Dynamic Type settings; I chose not to use a width that is a percentage of the parent because I was uncertain how to be sure that localized text would always fit within the allowed width.

How can I modify my views so that each view in the row is the same width, regardless of the width of text?

Regarding Dynamic Type, I will create a different layout to be used when that setting is changed.

Nick Kohrn
  • 5,779
  • 3
  • 29
  • 49

2 Answers2

39

I got this to work using GeometryReader and Preferences.

First, in ContentView, add this property:

@State var maxLabelWidth: CGFloat = .zero

Then, in DayListItem, add this property:

@Binding var maxLabelWidth: CGFloat

Next, in ContentView, pass self.$maxLabelWidth to each instance of DayListItem:

List(currentMonth.days.identified(by: \.self)) { date in
    DayListItem(date: date, maxLabelWidth: self.$maxLabelWidth)
}

Now, create a struct called MaxWidthPreferenceKey:

struct MaxWidthPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = .zero
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        let nextValue = nextValue()
        
        guard nextValue > value else { return }
        
        value = nextValue
    }
}

This conforms to the PreferenceKey protocol, allowing you to use this struct as a key when communicating preferences between your views.

Next, create a View called DayListItemGeometry - this will be used to determine the width of the VStack in DayListItem:

struct DayListItemGeometry: View {
    var body: some View {
        GeometryReader { geometry in
            Color.clear
                .preference(key: MaxWidthPreferenceKey.self, value: geometry.size.width)
        }
        .scaledToFill()
    }
}

Then, in DayListItem, change your code to this:

HStack {
    VStack(alignment: .center) {
        Text(weekdayFormatter.string(from: date))
            .font(.caption)
            .foregroundColor(.secondary)
        Text(dayNumberFormatter.string(from: date))
            .font(.body)
            .foregroundColor(.red)
    }
    .background(DayListItemGeometry())
    .onPreferenceChange(MaxWidthPreferenceKey.self) {
        self.maxLabelWidth = $0
    }
    .frame(width: self.maxLabelWidth)

    Divider()
}

What I've done is I've created a GeometryReader and applied it to the background of the VStack. The geometry tells me the dimensions of the VStack which sizes itself according to the size of the text. MaxWidthPreferenceKey gets updated whenever the geometry changes, and after the reduce function inside MaxWidthPreferenceKey calculates the maximum width, I read the preference change and update self.maxLabelWidth. I then set the frame of the VStack to be .frame(width: self.maxLabelWidth), and since maxLabelWidth is binding, every DayListItem is updated when a new maxLabelWidth is calculated. Keep in mind that the order matters here. Placing the .frame modifier before .background and .onPreferenceChange will not work.

Canvas Screenshot

graycampbell
  • 7,430
  • 3
  • 24
  • 30
  • That is the exact behavior that I was looking for! Do you recall where you learned about the `PreferenceKey` protocol? I looked at the documentation, but it's quite lacking. – Nick Kohrn Jul 11 '19 at 23:19
  • 1
    Yeah, unfortunately the documentation for SwiftUI is pretty much non-existent. I learned about `PreferenceKey` from this [article](https://swiftui-lab.com/communicating-with-the-view-tree-part-1/). They only have a couple articles up so far, but the ones they do have up have already helped me a lot, so I would keep an eye out for when they post more. – graycampbell Jul 11 '19 at 23:26
  • 1
    @graycampbell `scaledToFill` causes my content to squash the rest of the row's content. If I remove it, then the modified content is crazy thin. How do I measure the original width of the content that's to be modified? – Ian Warburton Aug 25 '20 at 17:58
  • 1
    This has gotten 28 upvotes, so it must have worked at some point. I am encountering a bug for which a radar has been filed, but no solution offered. "Bound preference WidthPreferenceKey tried to update multiple times per frame." Is there a way to make this work? – Mozahler Sep 23 '20 at 23:01
  • @IanWarburton I’m not sure what the problem would be. I haven’t looked at this in a long time. I’ll try to take a look when I have time. – graycampbell Sep 23 '20 at 23:03
  • @Mozahler Yeah, that’s an error I’ve come across before. I don’t remember it being a problem with this when I wrote the answer. I’ll try to take a look at this when I have time. – graycampbell Sep 23 '20 at 23:07
  • @Mozahler I've updated my answer. It should fix issues with max width calculations. I wasn't getting the same error you were though, so I'm not sure if it will fix that. Let me know if the problem is still there after trying my updated answer. – graycampbell Sep 24 '20 at 01:02
  • @graycampbell - thanks for looking into this! I'm still getting "tried to update multiple times per frame" - I'm using a ForEach rather than List. I'll play with this a bit and see what I'm doing wrong. – Mozahler Sep 24 '20 at 13:35
  • 1
    I had the list embedded inside a scroll view. I removed it and the error went away. thanks again! – Mozahler Sep 24 '20 at 14:35
  • Is it suitable for calculate self sizing view by maxHeight ? – iTux Dec 15 '21 at 22:17
  • Great answer. This didn't work for me (the `PreferenceKey`'s `reduce()` method wasn't being called) until I removed the `.onPreferenceChange` block from `DayListItem` and attached it instead to the enclosing `List` (i.e. I moved `onPreferenceChange` up the view hierarchy to an ancestor view that contained all the views that modify the `MaxWidthPreferenceKey`). – par Feb 01 '23 at 21:12
2

I was trying to achieve something similar. My text in one of the label in a row was varying from 2 characters to 20 characters. It messes up the horizontal alignment. I was looking to make this column in row as fixed width. And here is a very simple solution I applied to achieve that and it worked for me. Hope it can benefit someone else too.

var body: some View { // view for each row in list
        VStack(){
            HStack {
                  Text(wire.labelValueDate)
                        .
                        .
                        .foregroundColor(wire.labelColor)
                        .fixedSize(horizontal: true, vertical: false)
                        .frame(width: 110.0, alignment: .trailing)
                    }
                  }
}