I am trying to create two horizontal scrollable list of elements. Currently, this is what I am able to achieve:
As can be seen, I want to remove the space between the two Scroll Views. Here, is the code I am using to achieve this:
var body: some View {
NavigationView {
VStack (spacing: 0){
Text("Zara") .font(.custom(
"AmericanTypewriter",
fixedSize: 36))
ScrollView (.horizontal){
HorizontalScrollModel(searchResult: searchResults1)
}.task {
await loadData()
}
ScrollView (.horizontal){
HorizontalScrollModel(searchResult: searchResults1)
}
}
}
}
HorizontalScrollModel() returns a LazyHGrid of the products. I have tried setting the spacing to 0 with VStack (spacing: 0) but this did not work. How do I remove the space between the ScrollViews?
HorizontalScrollModel:
struct HorizontalScrollModel: View {
var searchResults1: [homeResult]
let columns = [
GridItem(.adaptive(minimum: 200))]
#State private var isHearted = false
init( searchResult: [homeResult]) {
self.searchResults1 = searchResult
}
var body: some View {
LazyHGrid (rows: columns ){
ForEach(searchResults1, id: \.prodlink) { item in
NavigationLink{
ProductView(imageLink: item.image_src, productLink: item.prodlink ,
productCost: String(item.price))
} label: {
VStack (alignment: .leading, spacing: 0){
AsyncImage(url: URL(string: item.image_src)) { phase in
if let image = phase.image {
image
.resizable()
.scaledToFit()
.frame(width: 150, height: 150)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else if phase.error != nil {
Text("There was an error loading the image.")
} else {
ProgressView()
}
}
.padding()
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(.gray.opacity(0.2), lineWidth: 4)
)
.padding(.top, 2)
.padding(.bottom, 2)
.padding(.leading, 2)
.padding(.trailing, 2)
.overlay (
RoundedRectangle(cornerRadius: 12)
.stroke(.gray.opacity(0.3), lineWidth: 1))
}
}
}
// .padding([.horizontal, .bottom])
}
}
}
Each ScrollView takes all available space offered by VStack, which since it has 2 views will be half the screen for each view.
Inside each ScrollView you then place a LazyHGrid, and inside the cell you are placing a 150x150 image, but the ScrollView size will not change to fit the image.
So, it now depends on what you're aiming for. If you just want the two ScrollView to be joined together, you can have a .frame modified to the outer VStack with a height of 308 (150 for each image + the padding).
Try adding the .fixedSize() modifier to the ScrollView:
ScrollView(.horizontal) {
HorizontalScrollModel(searchResult: searchResults1)
}
.fixedSize(horizontal: false, vertical: true)
You might also be able to add it just to the HorizontalScrollModel.
I have a similar problem to this question (no answer yet): SwiftUI HStack with GeometryReader and paddings
In difference my goal is to align two views inside an HStack and where the left view gets 1/3 of the available width and the right view gets 2/3 of the available width.
Using GeometryReader inside the ChildView messes up the whole layout, because it fills up the height.
This is my example code:
struct ContentView: View {
var body: some View {
VStack {
VStack(spacing: 5) {
ChildView().background(Color.yellow.opacity(0.4))
ChildView().background(Color.yellow.opacity(0.4))
Spacer()
}
.padding()
Spacer()
Text("Some random Text")
}
}
}
struct ChildView: View {
var body: some View {
GeometryReader { geo in
HStack {
Text("Left")
.frame(width: geo.size.width * (1/3))
Text("Right")
.frame(width: geo.size.width * (2/3))
.background(Color.red.opacity(0.4))
}
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.green.opacity(0.4))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Which results in this:
Now If you would embed this view inside others views the layout is completely messed up:
e.g. inside a ScrollView
So how would one achieve the desired outcome of having a HStack-ChildView which fills up the space it gets and divides it (1/3, 2/3) between its two children?
EDIT
As described in the answer, I also forgot to add HStack(spacing: 0). Leaving this out is the reason for the right child container to overflow.
You can create a custom PreferenceKey for the view size. Here is an example:
struct ViewSizeKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
Then, create a view which will calculate its size and assign it to the ViewSizeKey:
struct ViewGeometry: View {
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: ViewSizeKey.self, value: geometry.size)
}
}
}
Now, you can use them in your ChildView (even if it's wrapped in a ScrollView):
struct ChildView: View {
#State var viewSize: CGSize = .zero
var body: some View {
HStack(spacing: 0) { // no spacing between HStack items
Text("Left")
.frame(width: viewSize.width * (1 / 3))
Text("Right")
.frame(width: viewSize.width * (2 / 3))
.background(Color.red.opacity(0.4))
}
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.green.opacity(0.4))
.background(ViewGeometry()) // calculate the view size
.onPreferenceChange(ViewSizeKey.self) {
viewSize = $0 // assign the size to `viewSize`
}
}
}
I have some issues with the scrollview and GeometryReader. I want to have a list of items under an image. And each item should have the following width and height: ((width of the entire screen - leading padding - trailing padding) / 2).
I have tried two approaches for my use case. This is the code structure of my first one:
Approach #1
ScrollView
- VStack
- Image
- GeometryReader
- ForEach
- Text
I am using the geometry reader to get the width of the VStack as it has a padding and I don't want to have the full width of the scrollview.
But with the GeometryReader, only the last item from the ForEach loop is shown on the UI. And the GeometryReader has only a small height. See screenshot.
Code:
import SwiftUI
struct Item: Identifiable {
var id = UUID().uuidString
var value: String
}
struct ContentView: View {
func items() -> [Item] {
var items = [Item]()
for i in 0..<100 {
items.append(Item(value: "Item #\(i)"))
}
return items
}
var body: some View {
ScrollView {
VStack {
Image(systemName: "circle.fill")
.resizable()
.scaledToFill()
.frame(width: 150, height: 150)
.padding()
.foregroundColor(.green)
GeometryReader { geometry in
ForEach(self.items()) { item in
Text(item.value)
.frame(width: geometry.size.width / CGFloat(2), height: geometry.size.width / CGFloat(2))
.background(Color.red)
}
}
.background(Color.blue)
}
.frame(maxWidth: .infinity)
.padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The red color are the items in the ForEach loop. Blue the GeometryReader and green just the image.
Approach #2
ScrollView
-GeometryReader
- VStack
- Image
- ForEach
-Text
Then the items in my ForEach loop are rendered correctly but it's not possible to scroll anymore.
Code
import SwiftUI
struct Item: Identifiable {
var id = UUID().uuidString
var value: String
}
struct ContentView: View {
func items() -> [Item] {
var items = [Item]()
for i in 0..<100 {
items.append(Item(value: "Item #\(i)"))
}
return items
}
var body: some View {
ScrollView {
GeometryReader { geometry in
VStack {
Image(systemName: "circle.fill")
.resizable()
.scaledToFill()
.frame(width: 150, height: 150)
.padding()
.foregroundColor(.green)
ForEach(self.items()) { item in
Text(item.value)
.frame(width: geometry.size.width / CGFloat(2), height: geometry.size.width / CGFloat(2))
.background(Color.red)
}
}
.frame(maxWidth: .infinity)
}
.padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
How can I archive to have the UI correctly shown. Am I'm missing something here?
I would really appreciate some help.
Thank you.
EDIT
I found a workaround to have the UI correctly rendered with a working scrollView but that looks quite hacky to me.
I am using a PreferenceKey for this workaround.
I am using the geometryReader inside the scrollview with a height of 0. Only to get the width of my VStack.
On preferenceKeyChange I am updating a state variable and using this for my item to set the width and height of it.
import SwiftUI
struct Item: Identifiable {
var id = UUID().uuidString
var value: String
}
struct ContentView: View {
#State var width: CGFloat = 0
func items() -> [Item] {
var items = [Item]()
for i in 0..<100 {
items.append(Item(value: "Item #\(i)"))
}
return items
}
struct WidthPreferenceKey: PreferenceKey {
typealias Value = [CGFloat]
static var defaultValue: [CGFloat] = [0]
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
var body: some View {
ScrollView {
VStack(spacing: 0) {
Image(systemName: "circle.fill")
.resizable()
.scaledToFill()
.frame(width: 150, height: 150)
.padding()
.foregroundColor(.green)
GeometryReader { geometry in
VStack {
EmptyView()
}.preference(key: WidthPreferenceKey.self, value: [geometry.size.width])
}
.frame(height: 0)
ForEach(self.items()) { item in
Text(item.value)
.frame(width: self.width / CGFloat(2), height: self.width / CGFloat(2))
.background(Color.red)
}
}
.padding()
}
.onPreferenceChange(WidthPreferenceKey.self) { value in
self.width = value[0]
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Is this the only way of doing that, or is there a more elegant and easier way to do that?
First of all, since you are using a VStack, means you need a vertical scrolling. So, you need to set maxHeight property, instead of maxWidth.
In your first approach, you are wrapping your for..loop in a GeometryReader before wrapping it in a VStack. So, SwiftUI doesn't recognize that you want those items in a vertical stack until before building GeometryReader and its children. So, a good workaround is to put GeometryReader as the very first block before your scrollView to make it work:
import SwiftUI
struct Item: Identifiable {
var id = UUID().uuidString
var value: String
}
struct ContentView: View {
func items() -> [Item] {
var items = [Item]()
for i in 0..<100 {
items.append(Item(value: "Item #\(i)"))
}
return items
}
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack {
Image(systemName: "circle.fill")
.resizable()
.scaledToFill()
.frame(width: 150, height: 150)
.padding()
.foregroundColor(.green)
ForEach(self.items()) { item in
Text(item.value)
.frame(width: geometry.size.width / CGFloat(2), height: geometry.size.width / CGFloat(2))
.background(Color.red)
}
}
.frame(maxHeight: .infinity)
.padding()
}
}
.background(Color.blue)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The issue with your second approach, is that you are wrapping your infinite-height VStack in a GeometryReader, which is not necessarily has an infinite height. Besides, GeometryReader is a SwiftUI block to read the dimensions from outer block, rather than getting dimensions from its children.
Generally speaking, if you want to use Geometry of current main SwiftUI component which you are making (or the device screen, if it's a fullscreen component), then put GeometryReader at a parent level of other components. Otherwise, you need to put the GeometryReader component as the unique child of a well-defined dimensions SwiftUI block.
Edited: to add padding, simply add required padding to your scrollView:
ScrollView{
//...
}
.padding([.leading,.trailing],geometry.size.width / 8)
or in just one direction:
ScrollView{
//...
}
.padding(leading,geometry.size.width / 8) // or any other value, e.g. : 30
If you need padding just for items in for loop, simply put the for loop in another VStack and add above padding there.
Using SwiftUI, I am trying to center a View on the screen and then give it a header and/or footer of variable heights.
Using constraints it would look something like this:
let view = ...
let header = ...
let footer = ...
view.centerInParent()
header.pinBottomToTop(of: view)
footer.pinTopToBottom(of: view)
This way, the view would always be centered on the screen, regardless of the size of the header and footer.
I cannot figure out how to accomplish this with SwiftUI. Using any type of HStack or VStack means the sizes of the header and footer push around the view. I would like to avoid hardcoding any heights since the center view may vary in size as well.
Any ideas? New to SwiftUI so advice is appreciated!
If I correctly understood your goal (because, as #nayem commented, at first time seems I missed), the following approach should be helpful.
Code snapshot:
extension VerticalAlignment {
private enum CenteredMiddleView: AlignmentID {
static func defaultValue(in dimensions: ViewDimensions) -> CGFloat {
return dimensions[VerticalAlignment.center]
}
}
static let centeredMiddleView = VerticalAlignment(CenteredMiddleView.self)
}
extension Alignment {
static let centeredView = Alignment(horizontal: HorizontalAlignment.center,
vertical: VerticalAlignment.centeredMiddleView)
}
struct TestHeaderFooter: View {
var body: some View {
ZStack(alignment: .centeredView) {
Rectangle().fill(Color.clear) // !! Extends ZStack to full screen
VStack {
Header()
Text("I'm on center")
.alignmentGuide(.centeredMiddleView) {
$0[VerticalAlignment.center]
}
Footer()
}
}
// .edgesIgnoringSafeArea(.top) // uncomment if needed
}
}
struct Header: View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 40)
}
}
struct Footer: View {
var body: some View {
Rectangle()
.fill(Color.green)
.frame(height: 200)
}
}
struct SwiftUIView_Previews: PreviewProvider {
static var previews: some View {
TestHeaderFooter()
}
}
Here's the code:
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading) {
Rectangle()
.fill(Color.gray)
.frame(width: geometry.size.width, height: geometry.size.height * 0.1, alignment: .center)
Text("Center")
.frame(width: geometry.size.width, height: geometry.size.height * 0.2, alignment: .center)
Rectangle()
.fill(Color.gray)
.frame(width: geometry.size.width, height: geometry.size.height * 0.1, alignment: .center)
}
}
}
}
using GeometryReader you can apply the dynamic size for your views.
also here is screenshot for above code
put Spacer() between header view and footer view.
headerview()
Spacer()
footerview()
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.
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/
From iOS16 you can do:
struct ContentView: View {
var rowData: [RowData] = RowData.sample
var body: some View {
Grid(alignment: .leading) {
Text("Some sort of title")
ForEach(RowData.sample) { row in
GridRow {
Text(row.id)
Text(row.name)
}
}
}
.padding()
}
}
struct RowData: Identifiable {
var id: String
var name: String
static var sample: [Self] = [.init(id: "1", name: "Joe"), .init(id: "1000", name: "Diana")]
}
Previous answer
Here are three options to do it statically.
struct ContentView: View {
#State private var width: CGFloat? = 100
var body: some View {
List {
HStack {
Text("1. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
// Option 1
Text("John Smith")
.multilineTextAlignment(.leading)
//.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
HStack {
Text("20. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
// Option 2 (works mostly like option 1)
Text("Jane Done")
.background(Color.green)
Spacer()
}
HStack {
Text("2000. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
// Option 3 - takes all the rest space to the right
Text("Jax Dax")
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
}
}
}
Here is how it looks:
We may calculate the width based on the longenst entry as suggested in this answer.
There is couple of options to dynamically calculate width.
Option 1
import SwiftUI
import Combine
struct WidthGetter: View {
let widthChanged: PassthroughSubject<CGFloat, Never>
var body: some View {
GeometryReader { (g) -> Path in
print("width: \(g.size.width), height: \(g.size.height)")
self.widthChanged.send(g.frame(in: .global).width)
return Path() // could be some other dummy view
}
}
}
struct ContentView: View {
let event = PassthroughSubject<CGFloat, Never>()
#State private var width: CGFloat?
var body: some View {
List {
HStack {
Text("1. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.background(WidthGetter(widthChanged: event))
// Option 1
Text("John Smith")
.multilineTextAlignment(.leading)
//.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
HStack {
Text("20. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.background(WidthGetter(widthChanged: event))
// Option 2 (works mostly like option 1)
Text("Jane Done")
.background(Color.green)
Spacer()
}
HStack {
Text("2000. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.background(WidthGetter(widthChanged: event))
// Option 3 - takes all the rest space to the right
Text("Jax Dax")
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
}.onReceive(event) { (w) in
print("event ", w)
if w > (self.width ?? .zero) {
self.width = w
}
}
}
}
Option 2
import SwiftUI
struct ContentView: View {
#State private var width: CGFloat?
var body: some View {
List {
HStack {
Text("1. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.alignmentGuide(.leading, computeValue: { dimension in
self.width = max(self.width ?? 0, dimension.width)
return dimension[.leading]
})
// Option 1
Text("John Smith")
.multilineTextAlignment(.leading)
//.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
HStack {
Text("20. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.alignmentGuide(.leading, computeValue: { dimension in
self.width = max(self.width ?? 0, dimension.width)
return dimension[.leading]
})
// Option 2 (works mostly like option 1)
Text("Jane Done")
.background(Color.green)
Spacer()
}
HStack {
Text("2000. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.alignmentGuide(.leading, computeValue: { dimension in
self.width = max(self.width ?? 0, dimension.width)
return dimension[.leading]
})
// Option 3 - takes all the rest space to the right
Text("Jax Dax")
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
}
}
}
The result looks like this:
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 (with some 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:
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 🤷♂️
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
You can just have your two Texts and then a Spacer in an HStack. The Spacer will push your Texts to the left, and everything will self-adjust if either Texts change size due to the length of their content:
HStack {
Text("1.")
Text("John Doe")
Spacer()
}
.padding()
The Texts are technically center-aligned, but since the views automatically resize and only take up as much space as the text inside of it (since we did not explicitly set a frame size), and are pushed to the left by the Spacer, they appear left-aligned. The benefit of this over setting a fixed width is that you don't have to worry about text being truncated.
Also, I added padding to the HStack to make it look nicer, but if you want to adjust how close the Texts are to each other, you can manually set the padding on any of its sides. (You can even set negative padding to push items closer to each other than their natural spacing).
Edit
Didn't realize OP needed the second Text to be vertically aligned as well. I have a way to do it, but its "hacky" and wouldn't work for larger font sizes without more work:
These are the data objects:
class Person {
var name: String
var id: Int
init(name: String, id: Int) {
self.name = name
self.id = id
}
}
class People {
var people: [Person]
init(people: [Person]) {
self.people = people
}
func maxIDDigits() -> Int {
let maxPerson = people.max { (p1, p2) -> Bool in
p1.id < p2.id
}
print(maxPerson!.id)
let digits = log10(Float(maxPerson!.id)) + 1
return Int(digits)
}
func minTextWidth(fontSize: Int) -> Length {
print(maxIDDigits())
print(maxIDDigits() * 30)
return Length(maxIDDigits() * fontSize)
}
}
This is the View:
var people = People(people: [Person(name: "John Doe", id: 1), Person(name: "Alexander Jones", id: 2000), Person(name: "Tom Lee", id: 45)])
var body: some View {
List {
ForEach(people.people.identified(by: \.id)) { person in
HStack {
Text("\(person.id).")
.frame(minWidth: self.people.minTextWidth(fontSize: 12), alignment: .leading)
Text("\(person.name)")
}
}
}
}
To make it work for multiple font sizes, you would have to get the font size and pass it into the minTextWidth(fontSize:).
Again, I'd like to emphasize that this is "hacky" and probably goes against SwiftUI principles, but I could not find a built in way to do the layout you asked for (probably because the Texts in different rows do not interact with each other, so they have no way of knowing how to stay vertically aligned with each other).
Edit 2
The above code generates this:
You can set a fixed width to a number Text view. It makes this Text component with a fixed size.
HStack {
Text(item.number)
.multilineTextAlignment(.leading)
.frame(width: 30)
Text(item.name)
}
The drawback of this solution is that, if you will have a longer text there, it will be wrapped and ended with "...", but in that case I think you can roughly estimate which width will be enough.
If 1 line limit is ok with you:
Group {
HStack {
VStack(alignment: .trailing) {
Text("Vehicle:")
Text("Lot:")
Text("Zone:")
Text("Location:")
Text("Price:")
}
VStack(alignment: .leading) {
Text("vehicle")
Text("lot")
Text("zone")
Text("location")
Text("price")
}
}
.lineLimit(1)
.font(.footnote)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
HStack {
HStack {
Spacer()
Text("5.")
}
.frame(width: 40)
Text("Jon Smith")
}
But this will only work with fix width.
.frame(minWidth: 40) will fill the entire View because of Space()
.multilineTextAlignment(.leading) don't have any effect in my tests.
After trying to get this to work for a full day I came up with this solution:
EDIT: Link to Swift Package
import SwiftUI
fileprivate extension Color {
func exec(block: #escaping ()->Void) -> Self {
block()
return self
}
}
fileprivate class Deiniter {
let block: ()->Void
init(block: #escaping ()->Void) {
self.block = block
}
deinit {
block()
}
}
struct SameWidthContainer<Content: View>: View {
private var id: UUID
private let deiniter: Deiniter
#ObservedObject private var group: WidthGroup
private var content: () -> Content
init(group: WidthGroup, content: #escaping ()-> Content) {
self.group = group
self.content = content
let id = UUID()
self.id = id
WidthGroup.widths[group.id]?[id] = 100.0
self.deiniter = Deiniter() {
WidthGroup.widths[group.id]?.removeValue(forKey: id)
}
}
var body: some View {
ZStack(alignment: .leading) {
Rectangle()
.frame(width: self.group.width, height: 1)
.foregroundColor(.clear)
content()
.overlay(
GeometryReader { proxy in
Color.clear
.exec {
WidthGroup.widths[self.group.id]?[self.id] = proxy.size.width
let newWidth = WidthGroup.widths[group.id]?.values.max() ?? 0
if newWidth != self.group.width {
self.group.width = newWidth
}
}
}
)
}
}
}
class WidthGroup: ObservableObject {
static var widths: [UUID: [UUID: CGFloat]] = [:]
#Published var width: CGFloat = 0.0
let id: UUID
init() {
let id = UUID()
self.id = id
WidthGroup.widths[id] = [:]
}
deinit {
WidthGroup.widths.removeValue(forKey: id)
}
}
struct SameWidthText_Previews: PreviewProvider {
private static let GROUP = WidthGroup()
static var previews: some View {
Group {
SameWidthContainer(group: Self.GROUP) {
Text("One")
}
SameWidthContainer(group: Self.GROUP) {
Text("Two")
}
SameWidthContainer(group: Self.GROUP) {
Text("Three")
}
}
}
}
It is then used like this:
struct SomeView: View {
#State private var group1 = WidthGroup()
#State private var group2 = WidthGroup()
var body: some View {
VStack() {
ForEach(9..<12) { index in
HStack {
SameWidthContainer(group: group1) {
Text("All these will have same width in group 1 \(index)")
}
Text("Some other text")
SameWidthContainer(group: group2) {
Text("All these will have same width in group 2 \(index)")
}
}
}
}
}
}
If one of the views grows or shrinks all the views in the same group will grow/shrink with it. I just got it to work so I haven't tried it that much.
It's a bit of a hack but, hey, it doesn't seem to be another way than hacking.
Xcode 12.5
If you know the amount you want to offset the second view by, then you can place both views in a leading aligned ZStack and use the .padding(.horizontal, amount) modifier on the second view to offset it.
var body: some View {
NavigationView {
List {
ForEach(persons) { person in
ZStack(alignment: .leading) {
Text(person.number)
Text(person.name)
.padding(.horizontal, 30)
}
}
}
.navigationTitle("Challenge")
}
}
I think the correct way to do this would be using HorizontalAlignment. Something like:
extension HorizontalAlignment {
private enum LeadingName : AlignmentID {
static func defaultValue(in d: ViewDimensions) -> Length { d[.leading] }
}
static let leadingName = HorizontalAlignment(LeadingName.self)
}
List (people.identified(by: \.id)) {person in
HStack {
Text("\(person.id)")
Text("\(person.name)").alignmentGuide(.leadingName) {d in d[.leading]}
}
}
But I can't get it to work.
I can't find any examples of this with a List. It seems that List doesn't support alignment (yet?)
I can sort of get it to work with a VStack, and hard coded values like:
VStack (alignment: .leadingName ) {
HStack {
Text("1.")
Text("John Doe").alignmentGuide(.leadingName) {d in d[.leading]}
Spacer()
}
HStack {
Text("2000.")
Text("Alexander Jones").alignmentGuide(.leadingName) {d in d[.leading]}
Spacer()
}
HStack {
Text("45.")
Text("Tom Lee").alignmentGuide(.leadingName) {d in d[.leading]}
Spacer()
}
}
I'm hoping this will be fixed in a later beta...