Make Equal-Width SwiftUI Views in List Rows - swiftui

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:
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.

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.

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

Related

How do I make two Text() views the same dynamic font size in SwiftUI, maximizing the font size?

Reacting to the user’s device rotation and taking into account the various screen sizes of iPhones and iPads i want two (for example) Text() Views to have the maximum possible font size without truncating and without line wrapping. I tried a lot, lastly this and nothing worked.
struct MinimumHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 1_000.0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = min(value, nextValue())
}
}
struct DetermineHeight: View{
typealias Key = MinimumHeightPreferenceKey
var body: some View {
GeometryReader { proxy in
Color.clear
.anchorPreference(key: Key.self, value: .bounds) {
anchor in proxy[anchor].size.height
}
}
}
}
struct ContentView: View {
#State var minTextHeight: CGFloat = 75
var body: some View {
VStack {
HStack(alignment: VerticalAlignment.firstTextBaseline){
Text("Short Text")
.frame(maxHeight: minTextHeight)
.overlay(DetermineHeight())
.border(Color.red)
.scaledToFit()
Text("This is a considerably longer text. ideally it should also reduce the shorter Text's size so they both look the same.")
//.frame(minHeight: 1.0, maxHeight: minTextHeight)
.overlay(DetermineHeight())
.border(Color.green)
}
.font(.title)
.lineLimit(1)
.minimumScaleFactor(0.25)
.onPreferenceChange(DetermineHeight.Key.self) {
minTextHeight = $0
}
Text("minHeight = \(minTextHeight)")
}
}
}
The boxes turn out the same hight, but the texts aren’t adjusted. (Plus: if I uncomment that line I the boxes don’t resize at all. Huh?)
Am I trying to do the impossible?

GeometryReader inside ScrollView - scroll doesn't work anymore [duplicate]

With the new ScrollViewReader, it seems possible to set the scroll offset programmatically.
But I was wondering if it is also possible to get the current scroll position?
It seems like the ScrollViewProxy only comes with the scrollTo method, allowing us to set the offset.
Thanks!
It was possible to read it and before. Here is a solution based on view preferences.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
}.coordinateSpace(name: "scroll")
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I found a version without using PreferenceKey. The idea is simple - by returning Color from GeometryReader, we can set scrollOffset directly inside background modifier.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader { proxy -> Color in
DispatchQueue.main.async {
offset = -proxy.frame(in: .named("scroll")).origin.y
}
return Color.clear
})
}.coordinateSpace(name: "scroll")
}
}
I had a similar need but with List instead of ScrollView, and wanted to know wether items in the lists are visible or not (List preloads views not yet visible, so onAppear()/onDisappear() are not suitable).
After a bit of "beautification" I ended up with this usage:
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
List(0..<100) { i in
Text("Item \(i)")
.onItemFrameChanged(listGeometry: geometry) { (frame: CGRect?) in
print("rect of item \(i): \(String(describing: frame)))")
}
}
.trackListFrame()
}
}
}
which is backed by this Swift package: https://github.com/Ceylo/ListItemTracking
The most popular answer (#Asperi's) has a limitation:
The scroll offset can be used in a function
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
which is convenient for triggering an event based on that offset.
But what if the content of the ScrollView depends on this offset (for example if it has to display it). So we need this function to update a #State.
The problem then is that each time this offset changes, the #State is updated and the body is re-evaluated. This causes a slow display.
We could instead wrap the content of the ScrollView directly in the GeometryReader so that this content can depend on its position directly (without using a State or even a PreferenceKey).
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
}
where content is (CGPoint) -> some View
We could take advantage of this to observe when the offset stops being updated, and reproduce the didEndDragging behavior of UIScrollView
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin,
perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2,
scheduler: DispatchQueue.main),
perform: didEndScrolling)
}
where offsetObserver = PassthroughSubject<CGPoint, Never>()
In the end, this gives :
struct _ScrollViewWithOffset<Content: View>: View {
private let axis: Axis.Set
private let content: (CGPoint) -> Content
private let didEndScrolling: (CGPoint) -> Void
private let offsetObserver = PassthroughSubject<CGPoint, Never>()
private let spaceName = "scrollView"
init(axis: Axis.Set = .vertical,
content: #escaping (CGPoint) -> Content,
didEndScrolling: #escaping (CGPoint) -> Void = { _ in }) {
self.axis = axis
self.content = content
self.didEndScrolling = didEndScrolling
}
var body: some View {
ScrollView(axis) {
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin, perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2, scheduler: DispatchQueue.main), perform: didEndScrolling)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.coordinateSpace(name: spaceName)
}
}
Note: the only problem I see is that the GeometryReader takes all the available width and height. This is not always desirable (especially for a horizontal ScrollView). One must then determine the size of the content to reflect it on the ScrollView.
struct ScrollViewWithOffset<Content: View>: View {
#State private var height: CGFloat?
#State private var width: CGFloat?
let axis: Axis.Set
let content: (CGPoint) -> Content
let didEndScrolling: (CGPoint) -> Void
var body: some View {
_ScrollViewWithOffset(axis: axis) { offset in
content(offset)
.fixedSize()
.overlay(GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
width = geo.size.width
}
})
} didEndScrolling: {
didEndScrolling($0)
}
.frame(width: axis == .vertical ? width : nil,
height: axis == .horizontal ? height : nil)
}
}
This will work in most cases (unless the content size changes, which I don't think is desirable). And finally you can use it like that :
struct ScrollViewWithOffsetForPreviews: View {
#State private var cpt = 0
let axis: Axis.Set
var body: some View {
NavigationView {
ScrollViewWithOffset(axis: axis) { offset in
VStack {
Color.pink
.frame(width: 100, height: 100)
Text(offset.x.description)
Text(offset.y.description)
Text(cpt.description)
}
} didEndScrolling: { _ in
cpt += 1
}
.background(Color.mint)
.navigationTitle(axis == .vertical ? "Vertical" : "Horizontal")
}
}
}

GeometryReader to calculate total height of views

I have a main view with a fixed width and height because this view will be converted into a PDF. Next, I have an array of subviews that will be vertically stacked in the main view. There may be more subviews available than can fit in the main view so I need a way to make sure that I don't exceed the main view's total height. For example, if I have eight subviews in my array but only three will fit into the main view, then I need to stop adding subviews after three. The remaining subviews will start the process over on a new main view.
My problem is, if I use GeometryReader to get the height of a view, first I have to add it. But once the view is added, it’s too late to find out if it exceeded the total height available.
Below shows how I get the height of each view as it's being added. Which is not much to go on, I know, but I'm pretty stuck.
Update:
My current strategy is to create a temporary view where I can add subviews and return an array with only the ones that fit.
struct PDFView: View {
var body: some View {
VStack {
ForEach(tasks) { task in
TaskRowView(task: task)
.overlay(
GeometryReader { geo in
// geo.size.height - to get height of current view
})
}
}
.layoutPriority(1)
}
}
A possible solution is to use View Preferences.
You can calculate the total height of your items and in onPreferenceChange increase their count by 1 (until the totalHeight reaches maxHeight).
Here is the full implementation:
Create a custom PreferenceKey for retrieving view height:
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}
struct ViewGeometry: View {
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: ViewHeightKey.self, value: geometry.size.height)
}
}
}
Create a TaskRowView (of any height):
struct TaskRowView: View {
let index: Int
var body: some View {
Text("\(index)")
.padding()
.background(Color.red)
}
}
Use the custom PreferenceKey in your view:
struct ContentView: View {
#State private var count = 0
#State private var totalHeight: CGFloat = 0
#State private var maxHeightReached = false
let maxHeight: CGFloat = 300
var body: some View {
VStack {
Text("Total height: \(totalHeight)")
VStack {
ForEach(0 ..< count, id: \.self) {
TaskRowView(index: $0)
}
}
.background(ViewGeometry())
.onPreferenceChange(ViewHeightKey.self) {
totalHeight = $0
print(count, totalHeight)
guard !maxHeightReached else { return }
if $0 < maxHeight {
count += 1
} else {
count -= 1
maxHeightReached = true
}
}
}
}
}
You can get total height in one place as shown below:
VStack {
ForEach(tasks) { task in
TaskRowView(task: task)
}
}
.overlay(
GeometryReader { geo in
// geo.size.height // << move it here and get total height at once
})

SwiftUI grid with column that fits content?

Is this layout possible with SwiftUI?
I want the first column to wrap the size of the labels, so in this case it will be just big enough to show "Bigger Label:". Then give the rest of the space to the second column.
This layout is pretty simple with auto layout.
SwiftUI 2020 has LazyVGrid but the only ways I see to set the column sizes use hardcoded numbers. Do they not understand what a problem that causes with multiple languages and user-adjustable font sizes?
It is not so complex if to compare number of code lines to make this programmatically in both worlds...
Anyway, sure it is possible. Here is a solution based on some help modifier using view preferences feature. No hard. No grid.
Demo prepared & tested with Xcode 12 / iOS 14.
struct DemoView: View {
#State private var width = CGFloat.zero
var body: some View {
VStack {
HStack {
Text("Label1")
.alignedView(width: $width)
TextField("", text: .constant("")).border(Color.black)
}
HStack {
Text("Bigger Label")
.alignedView(width: $width)
TextField("", text: .constant("")).border(Color.black)
}
}
}
}
and helpers
extension View {
func alignedView(width: Binding<CGFloat>) -> some View {
self.modifier(AlignedWidthView(width: width))
}
}
struct AlignedWidthView: ViewModifier {
#Binding var width: CGFloat
func body(content: Content) -> some View {
content
.background(GeometryReader {
Color.clear
.preference(key: ViewWidthKey.self, value: $0.frame(in: .local).size.width)
})
.onPreferenceChange(ViewWidthKey.self) {
if $0 > self.width {
self.width = $0
}
}
.frame(minWidth: width, alignment: .trailing)
}
}

Make parent view's width the width of the smallest child?

How can I make a parent view's width the width of the smallest child?
I can learn the width of the child view using the answer to this question like so:
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().fill(Color.clear).preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
struct Content: View {
#State var width1: CGFloat = 0
#State var width2: CGFloat = 0
var body: some View {
VStack {
Text("Short")
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.width1 = $0 })
Text("Loooooooooooong")
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.width2 = $0 })
}
.frame(width: min(self.width1, self.width2)) // This is always 0
}
}
But the VStack has always width 0. It's like as if
the line .frame(width: min(self.width1, self.width2)) is being called
before the widths are set.
Any ideas?
This is a logic bug, in that the system is technically doing exactly what you've told it to, but that result is not what you, as the programmer, intended.
Basically, VStack wants to shrink as much as possible to fit the size of its content. Likewise, Text views are willing to shrink as much as possible (truncating their contents) to fit inside their parent view. The only hard requirement you've given is that the initial frame of the VStack have width of 0. So the following sequence happens:
width1 and width2 are initialized to 0
The VStack sets its width to min(0, 0)
The Text views inside shrink to width 0
WidthPreferenceKey.self is set to 0
.onPreferenceChange sets width1 and width2, which are already 0
All the constraints are satisfied, and SwiftUI happily stops layout.
Let's modify your code with the following:
Make WidthPreferenceKey.Value a typealias to [CGFloat] instead of CGFloat. This way, you can set as many preference key setters as you want, and they will just keep accumulating into an array.
Use one .onPreferenceChange call, which will find the minimum of all the read values, and set the single #State var width: CGFloat property
Add .fixedSize() to the Text views.
Like so:
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = [CGFloat]()
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
typealias Value = [CGFloat]
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle()
.fill(Color.clear)
.preference(key: WidthPreferenceKey.self,
value: [geometry.size.width])
}
}
}
struct Content: View {
#State var width: CGFloat = 0
var body: some View {
VStack {
Text("Short")
.fixedSize()
.background(TextGeometry())
Text("Loooooooooooong")
.fixedSize()
.background(TextGeometry())
}
.frame(width: self.width)
.clipped()
.background(Color.red.opacity(0.5))
.onPreferenceChange(WidthPreferenceKey.self) { preferences in
print(preferences)
self.width = preferences.min() ?? 0
}
}
}
Now the following happens:
width is initialized to 0
The VStack sets its width to 0
The Text views expand outside the VStack, since we've given permission with .fixedSize()
WidthPreferenceKey.self is set to [42.5, 144.5] (or something close)
.onPreferenceChange sets width to 42.5
The VStack sets its width to 42.5 to satisfy its .frame() modifier.
All constraints are satisfied, and SwiftUI stops layout. Note that .clipped() is keeping the edges of the long Text view from displaying, even though they are technically outside the bounds of the VStack.