19

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.

3 Answers 3

41

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

Sign up to request clarification or add additional context in comments.

12 Comments

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.
Yeah, unfortunately the documentation for SwiftUI is pretty much non-existent. I learned about PreferenceKey from this article. 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 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?
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?
I had the list embedded inside a scroll view. I removed it and the error went away. thanks again!
|
0

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

Comments

0

Grids and Tables do that: align an justify their cells.
You could get the result like this:

struct ContentView : View {
    
    let dates: [Date] = [.now, .distantFuture, .distantPast]
    
    var body: some View {
        NavigationView {
            ScrollView {
                Grid(horizontalSpacing: 10, verticalSpacing: 10) {
                    ForEach(dates, id: \.self) { date in
                        DayGridRow(date: date)
                        Divider()
                    }
                    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                }
            }
            .padding()
            .navigationBarTitle(Text("Dates list"))
        }
    }
}

where

import SwiftUI

struct DayGridRow: View {
    let date: Date

    var body: some View {
        GridRow(alignment: .top) {
            VStack(alignment: .center) {
                Text(date, formatter: .weekdayFormatter)
                    .font(.caption)
                    .foregroundColor(.secondary)
                Text(date, formatter: .dayNumberFormatter)
                    .font(.body)
                    .foregroundColor(.red)
            }
            HStack {
                Divider()
                    .frame(height: 30)
            }
            .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
        }
    }

}

extension Formatter {
    
    static let weekdayFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "EEE"
        return formatter
    }()

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

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.