Correct way to layout SwiftUI (similar to Autolayout) - ios

Question:
I'm struggling to layout views effectively with SwiftUI.
I am very familiar with UIKit and Autolayout and always found it intuitive.
I know SwiftUI is young and only beginning so maybe I expect too much, but taking a simple example:
Say I have a HStack of Text() views.
|--------------------------------|
| Text("static") Text("Dynamic") |
|________________________________|
When I have dynamic content, the static Text strings jump all over the place as the size of the HStack changes, when Text("Dynamic") changes...
I've tried lot's of things, Spacers(), Dividers(), looked at approaches using PreferenceKeys (link), Alignment Guides (link)
Closest to an answer seems alignment guides, but they are convoluted.
What's the canonical approach to replicate Autolayout's ability to basically anchor views to near the edge of the screen, and layout correctly without jumping around?
I'd like to anchor the static text "Latitude" so it doesn't jump around.
There are other examples, so a more general answer on how best to layout would be appreciated...
With Autolayout it felt I chose were things went. With SwiftUI it's a lottery.
Example, showing the word "Latitude" jump around as co-ordinates change:
Example, code:
HStack {
Text("Latitude:")
Text(verbatim: "\(self.viewModelContext.lastRecordedLocation().coordinate.latitude)")
}
I'm really struggling when my views have changing/dynamic context. All works OK for static content as shown in all of the WWDC videos.
Potential Solution:
Using a HStack like this:
HStack(alignment: .center, spacing: 20) {
Text("Latitude:")
Text(verbatim: "\(self.viewModelContext.lastRecordedLocation().coordinate.latitude)")
Spacer()
}
.padding(90)
The result is nicely anchored, but I hate magic numbers.

As you've somewhat discovered, the first piece is that you need to decide what you want. In this case, you seem to want left-alignment (based on your padding solution). So that's good:
HStack {
Text("Latitude:")
Text(verbatim: "\(randomNumber)")
Spacer()
}
That's going to make the HStack as wide as its containing view and push the text to the left.
But from you later comments, you seem to not want it to be on the far left. You have to decide exactly what you want in that case. Adding .padding will let you move it in from the left (perhaps by adding .leading only), but maybe you want to match it to the screen size.
Here's one way to do that. The important thing is to remember the basic algorithm for HStack, which is to give everyone their minimum, and then split up the remaining space among flexible views.
HStack {
HStack {
Spacer()
Text("Latitude:")
}
HStack {
Text(verbatim: "\(randomNumber)")
Spacer()
}
}
The outer HStack has 2 children, all of whom are flexible down to some minimum, so it offers each an equal amount of space (1/2 of the total width) if it can fit that.
(I originally did this with 2 extra Spacers, but I forgot the Spacers seem to have special handling to get their space last.)
The question is what happens if randomNumber is too long? As written, it'll wrap. Alternatively, you could add .fixedSize() which would stop it from wrapping (and push Latitude to the left to make it fit). Or you could add .lineLimit(1) to force it to truncate. It's up to you.
But the important thing is the addition of flexible HStacks. If every child is flexible, then they all get the same space.
If you want to force things into thirds or quarters, I find you need to add something other than a Spacer. For example, this will give Latitude and the number 1/4 of the available space rather than 1/2 (note the addition of Text("")):
HStack {
HStack {
Text("")
Spacer()
}
HStack {
Spacer()
Text("Latitude:")
}
HStack {
Text(verbatim: "\(randomNumber)")//.lineLimit(1)
Spacer()
}
HStack {
Text("")
Spacer()
}
}
In my own code, I do this kind of thing so much I have things like
struct RowView: View {
// A centered column
func Column<V: View>(#ViewBuilder content: () -> V) -> some View {
HStack {
Spacer()
content()
Spacer()
}
}
var body: some View {
HStack {
Column { Text("Name") }
Column { Text("Street") }
Column { Text("City") }
}
}
}

Related

How to handle text wrapping and alignment / padding with consecutive Text Views in SwiftUI?

When putting Text() views that have enough text to cause it to wrap, one after the other, they don't have the same alignment / padding unless they have a similar word structure.
I would like to both have a solution to this problem that allows for every Text view to have the same alignment and padding, as well as understand the SwiftUI Magic that I am apparently trying to fight.
I believe it has to do with the default "auto centering" alignment behavior of swiftUI but not sure why sentences of similar length don't appear with the same alignment / padding. And when I have tried to apply a group to the text views and apply collective padding or multiLineAlignment etc it doesn't seem to change the output.
struct TutorialView: View {
var body: some View {
VStack {
Text("About This App.")
.padding([.top, .bottom] , 20)
.font(.system(size: 30))
Text("This example text is meant to be long enough to wrap because when it is, then the alignment aint what we thought it would be...")
Text("This is meant to wrap and it will look different than the text above. even though it wraps too")
Text("It isn't always clear which will appear to have padding and which will hug the wall")
Text("Literally all of these are differnt...")
Spacer()
}
}
}
Approach
You are right, default alignment is center
Add different background colors and you will understand how they are aligned.
Code
I have modified your code just to demonstrate the layout
struct ContentView: View {
var body: some View {
VStack {
Text("About This App.")
.padding([.top, .bottom] , 20)
.font(.system(size: 30))
.background(.orange)
VStack(alignment: .leading) {
Text("This example text is meant to be long enough to wrap because when it is, then the alignment aint what we thought it would be...")
.background(.green)
Text("This is meant to wrap and it will look different than the text above. even though it wraps too")
.background(.blue)
Text("It isn't always clear which will appear to have padding and which will hug the wall")
.background(.red)
Text("Literally all of these are differnt...")
.background(.yellow)
Spacer()
}
}
}
}
Note:
There are times .fixedSize(horizontal:, vertical:) would come in handy (not used in this example)

SwiftUI - Enable Text view to present trailing whitespace

I have a a Text("string") view in SwiftUI that displays two different strings, one much longer than the other. I would like my SwiftUI text view to present both strings as if they were equal in length, so the view remains the same size (I have other dynamic elements on the page that I do not want it affecting).
To handle this, I am padding my shorter string with spaces (" ") until it reaches the length of the larger string. This does not solve my issue - it seems like SwiftUI Text view is ignoring the trailing whitespace and not presenting it on the screen. Is there any way to resolve this?
If you are OK with having the texts occupying the whole width of the parent view, or if you can use .frame(width:) to have a fixed width, you could try the following approach:
VStack {
HStack {
Text("Short")
Spacer()
}
.background(.blue)
HStack {
Text("A very, very long text to show")
Spacer()
}
.background(.yellow)
}
.padding()
You'll see that the blue and yellow areas are the same size.

SwiftUI alignment of "rating" views in two separate rows in Form for macOS and iOS

Writing a Multiplatform app initially for macOS and iOS that uses a master detail view structure and within the detail view is a Form and many Sections, one of which contains two structs that represent a user's rating (of food) for enjoyment and consumption.
Here is the code for the enjoyment rating...
struct Rating: View {
#Environment(\.colorScheme) var colourScheme
#Binding var rating: Int
var body: some View {
HStack {
Text("Enjoyment")
.padding(.trailing, 12.0)
#if os(iOS)
Spacer()
#endif
ForEach(0..<5) { counter in
Image(systemName: rating > counter ? "star.fill" : "star")
.onTapGesture(count: 1) {
if rating == 1 {
rating = 0
}
else {
rating = counter + 1
}
}
// this line for image system name = "circle.fill" : "circle"
//.padding(.trailing, counter != 4 ? 2.5 : 0)
}
.shadow(color: colourScheme == .light ? .gray : .white, radius: colourScheme == .light ? 1.0 : 2.0, x: 0, y: 0)
}
.foregroundColor(Color.accentColor)
}
}
The consumption rating is very similar with minor changes.
So far these look good on iOS because the (iOS only) Spacer() pushes each Rating view to the right or trailing edge of the row and my .padding modifier hack for the circle image makes the spacing between each image "about right".
While I'm struggling to figure out how to align the "dots" for the macOS target, I'm also struggling to figure out how to align each image programmatically, so that if I changed the image the alignment would work.
See screenshots below (that illustrate how the five Images do not align).
iOS
macOS
I've read a few blogs on the .alignmentGuide modifier including Alignment guides in SwiftUI by Majid Jabrayilov.
It seems to be the way I should go but I'm stuck in my attempts on how to work this out.
I've added a comment about the use of Spacer() between your label and your rating control, but separately it's worth looking at the rating control itself.
Firstly, right now you're relying on the five elements sharing an HStack with the label, and using conditional padding logic within the loop to control the spacing between elements.
That part would be easier if you give your rating element its own HStack. That way, spacing between elements can be determined using the stack's spacing attribute without having to worry about whether or not you're on the last loop iteration. For example:
HStack(spacing: 2.5) {
ForEach(0..<5) {
Image(systemName: "circle")
// etc.
}
}
In terms of aligning the child elements of a rating view so that they align with a similar view below regardless of the symbol being used, you can constrain the frame width of each child element to be the same, regardless of what image they're displaying.
You can accomplish that by adding a .frame() modifier to each child element in the loop:
HStack {
ForEach(0..<5) {
Image(systemName: "xxx")
.frame(width: 40, alignment: .center)
}
}
You'd obviously need to pick a width that works for you - and you could mix this with a spacing attribute on the HStack as well.

How to have 1 column in a multiple column list be of the same width w/out using a frame modifier of width so to retain flexibility

I have a list of entries that consist of multiple columns of UI with all except the first free to be uniquely sized horizontally (i.e. they’re as short/long as their content demands). I know with the first consistently sized column I can set a frame modifier width to achieve this, but I was hoping there is a better and more flexible way to get the desired behaviour. The reason being I don’t believe the solution is optimised to consider the user’s display size nor the actual max content width of the columns. That is, the width set will either not be wide enough when the display size is set to the largest, or, if it is, then it will be unnecessarily wide on a smaller/regular display size.
This is my current best attempt:
GeometryReader { geometry in
VStack {
HStack {
HStack {
Text("9am")
Image(systemName: "cloud.drizzle").font(Font.title2)
.offset(y: 4)
}.padding(.all)
.background(Color.blue.opacity(0.2))
.cornerRadius(16)
VStack {
HStack {
Text("Summary")
.padding(.trailing, 4)
.background(Color.white)
.layoutPriority(1)
VStack {
Spacer()
Divider()
Spacer()
}
VStack {
Text("12°")
Text("25%")
.foregroundColor(Color.black)
.background(Color.white)
}.offset(y: -6)
Spacer()
}.frame(width: geometry.size.width/1.5)
}
Spacer()
}
HStack {
HStack {
Text("10am")
.customFont(.subheadline)
Image(systemName: "cloud.drizzle").font(Font.title2)
.offset(y: 4)
.opacity(0)
}
.padding(.horizontal)
.padding(.vertical,4)
.background(Color.blue.opacity(0.2))
.cornerRadius(16)
VStack {
HStack {
ZStack {
Text("Mostly cloudy")
.customFont(.body)
.padding(.trailing, 4)
.background(Color.white)
.opacity(0)
VStack {
Spacer()
Divider()
Spacer()
}
}
VStack {
Text("13°")
Text("25%")
.foregroundColor(Color.black)
.background(Color.white)
}.offset(y: -6)
Spacer()
}.frame(width: geometry.size.width/1.75)
}
Spacer()
}
}
}
For me, this looks like:
As you can tell, 10 am is slightly wider than 9 am. To keep them as closely sized as possible, I’m including a cloud icon in it too, albeit with zero opacity. Ideally, 10 am would be sized the same as 9 am without needing a transparent cloud icon. More generally speaking, what would make sense is the widest HStack in this column is identified and then whatever its width is will be applied to all other columns. Keep in mind, my code above is static for demo purposes. It will be a view that is rendered iterating through a collection of rows.
You can use dynamic frame modifiers, such as frame(.maxWidth: .infinity) modifier to extend views so that they fill up the entire frame, even if the frame is dynamic. Here is an example that should help you get going:
struct CustomContent: View {
var body: some View {
VStack {
VStack {
CustomRow(timeText: "9am", systemIcon: "cloud.drizzle", centerText: "Summary", temperature: "12°", percent: "25%")
CustomRow(timeText: "10am", systemIcon: nil, centerText: nil, temperature: "13°", percent: "25%")
}
VStack {
CustomRow(timeText: "9am", systemIcon: "cloud.drizzle", centerText: "Summary", temperature: "12°", percent: "25%")
CustomRow(timeText: "10am", systemIcon: nil, centerText: nil, temperature: "13°", percent: "25%")
}
.frame(width: 300)
}
}
}
struct CustomContent_Previews: PreviewProvider {
static var previews: some View {
CustomContent()
}
}
struct CustomRow: View {
let timeText: String
let systemIcon: String?
let centerText: String?
let temperature: String
let percent: String
var body: some View {
HStack {
//Left column
HStack(alignment: .center) {
Text(timeText)
if let icon = systemIcon {
Image(systemName: icon)
.font(.title2)
}
}
.padding(.all)
.frame(width: 105, height: 60)
.background(Color.blue.opacity(0.2))
.cornerRadius(16)
// Center column
ZStack(alignment: .leading) {
Capsule()
.fill(Color.black.opacity(0.3))
.frame(height: 0.5)
if let text = centerText {
Text(text)
.lineLimit(1)
.background(Color.white)
}
}
// Right column
VStack {
Text(temperature)
Text(percent)
.foregroundColor(Color.black)
}
}
}
}
Guided by https://www.wooji-juice.com/blog/stupid-swiftui-tricks-equal-sizes.html, I accomplished this.
This is the piece of UI I want to make sure is horizontally sized equally across all rows with the width set to whatever is the highest:
HStack {
VStack {
Spacer()
Text("9am")
Spacer()
}
}.frame(minWidth: self.maximumSubViewWidth)
.overlay(DetermineWidth())
The stack the above is contained in has an OnPreferenceChange modifier:
.onPreferenceChange(DetermineWidth.Key.self) {
if $0 > maximumSubViewWidth {
maximumSubViewWidth = $0
}
}
The magic happens here:
struct MaximumWidthPreferenceKey: PreferenceKey
{
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat)
{
value = max(value, nextValue())
}
}
struct DetermineWidth: View
{
typealias Key = MaximumWidthPreferenceKey
var body: some View
{
GeometryReader
{
proxy in
Color.clear
.anchorPreference(key: Key.self, value: .bounds)
{
anchor in proxy[anchor].size.width
}
}
}
}
The link at the top best describes each’s purpose.
MaximumWidthPreferenceKey
This defines a new key, sets the default to zero, and as new values get added, takes the widest
DetermineWidth
This view is just an empty (Color.clear) background, but with our new preference set to its width. We’ll get back to that clear background part in a moment, but first: there are several ways to set preferences, here, we’re using anchorPreference. Why?
Well, anchorPreference has “No Overview Available” so I don’t actually have a good answer for that, other than it seems to be more reliable in practice. Yeah, cargo-cult code. Whee! I have a hunch that, what with it taking a block and all, SwiftUI can re-run that block to get an updated value when there are changes that affect layout.
Another hope I have is that this stuff will get better documented, so that we can better understand how these different types are intended to be used and new SwiftUI developers can get on board without spending all their time on Stack Overflow or reading blog posts like this one.
Anyway, an anchor is a token that represents a dimension or location in a view, but it doesn’t give you the value directly, you have to cash it in with a GeometryProxy to get the actual value, so, that’s what we did — to get the value, you subscript a proxy with it, so proxy[anchor].size.width gets us what we want, when anchor is .bounds (which is the value we passed in to the anchorPreference call). It’s kind of twisted, but it gets the job done.
maximumSubViewWidth is a binding variable passed in from the parent view to ensure the maximumSubViewWidth each subview refers to is always the the up-to-date maximum.
ForEach(self.items) { item, in
ItemSubview(maximumSubViewWidth: $maximumSubViewWidth, item: item)
}
The one issue with this was there was an undesired subtle but still noticeable animation on the entire row with any UI that gets resized to the max width. What I did to work around this is add an animation modifier to the parent container that’s nil to start with that switches back to .default after an explicit trigger.
.animation(self.initialised ? .default : nil)
I set self.initialised to be true after the user explicitly interacts with the row (In my case, they tap on a row to expand to show additional info) – this ensured the initial animation doesn't incorrectly happen but animations go back to normal after that. My original attempt toggled initialised's state in the .onAppear modifier so that the change is automatic but that didn't work because I’m assuming resizing can occur after the initial appearance.
The other thing to note (which possibly suggests although this solution works that it isn't the best method) is I'm seeing this message in the console repeated for either every item, or just the ones that needed to be resized (unclear but the total number of warnings = number of items):
Bound preference MaximumWidthPreferenceKey tried to update multiple
times per frame.
If anyone can think of a way to achieve the above whilst avoiding this warning then great!
UPDATE: I figured the above out.
It’s actually an important change because without addressing this I was seeing the column keep getting wider on subsequent visits to the screen.
The view has a new widthDetermined #State variable that’s set to false, and becomes true inside .onAppeared.
I then only determine the width for the view IF widthDetermined is false i.e. not set. I do this by using the conditional modifier proposed at https://fivestars.blog/swiftui/conditional-modifiers.html:
func `if`<Content: View>(_ conditional: Bool, content: (Self) -> Content) -> TupleView<(Self?, Content?)> {
if conditional { return TupleView((nil, content(self))) }
else { return TupleView((self, nil)) }
}
and in the view:
.if(!self.widthDetermined) {
$0.overlay(DetermineWidth())
}
I had similar issue. My text in one of the label in a row was varying from 2 characters to 20 characters. It messes up the horizontal alignment as you have seen. I was looking to make this column in row as fixed width. I came up with something very simple. And it worked for me.
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)
}
}
}

How to Programmatically Navigate List by Row Content?

I have a List within a NavigationView where each view under List should have navigatable elements attached to it (cover image, user avatar + name, etc.) For example, clicking the cover image navigates to view A, while clicking the user's name/avatar navigates to view B. Sadly, in all cases, the entire list element was clickable and did not grant the intended behavior.
At first, I tried wrapping my content within a NavigationLink.
NavigationLink(destination: Text("Media"), tag: .media, selection: $selection) {
WebImage(url: URL(string: activity.media?.coverImage?.extraLarge ?? ""))
.resizable()
.placeholder { color }
.cornerRadius(8)
.frame(width: 90, height: 135)
}
This causes an arrow to appear to indicate the view is navigatable for the user but is unwanted in this situation. It was also taking up a lot of space from the view unnecessarily.
My next attempt was to wrap the view and NavigationLink in a ZStack.
ZStack {
NavigationLink(destination: Text("Media"), tag: .media, selection: $selection) {
EmptyView()
}.hidden()
WebImage(url: URL(string: activity.media?.coverImage?.extraLarge ?? ""))
.resizable()
.placeholder { color }
.cornerRadius(8)
}.frame(width: 90, height: 135)
The .hidden() modifier was applied to the NavigationLink to prevent the arrow from appearing when the image was transparent. While this solution both hides the arrow and cleans up the extra space, there are two issues:
The entire list element is still clickable.
A ZStack covered by the .frame modifier requires I know how large I want to make it. The user's name & avatar view can't easily overcome this dilemma.
Thirdly, I tried wrapping the view in a Button where the label was the cover image and the action was to change selection to navigate programmatically, but this brought the spacing issue from #1 and the overall issue of the list element being clickable.
I later discovered a solution that would cut down the previous issues I had, but brought one problem. To understand it, this is what my main activity view looks like:
NavigationView {
List(viewModel.activities) { activity in
ActivitySelectionView(activity: activity, selection: $selection)
}.navigationTitle("Activity Feed")
}.onAppear {
viewModel.fetchActivities()
}
By encapsulating List(...) {...} in a ScrollView and changing List to a ForEach, I was able to produce the output I wanted: clickable view within an element, the cover image became lighter when clicking on it, opposed to the list element becoming darker as a whole until let go, etc.
However, this is not a list. It does not look good, nor will it look better on other platforms (this is an iOS project). For example, this code does not respect the edges as a list does. It also does not include a divider, but the Divider struct can help. I feel this is not the right solution to this problem.
To sum it all up, how do I create a List inside a NavigationView where the list respects what views inside an element are navigatable?
I found an elegant solution to my problem, so I'd like to share it for people who may stumble upon this question in the future.
You need to use a ScrollView within the List {...} somewhere. In the ScrollView block, it's perfectly suitable to make certain elements in the list cell navigatable.
NavigationView {
List(1..<11) { num in
ScrollView {
Text("\(num)!")
NavigationLink(destination: Text("Number: \(num)")) {
Text("Click me")
}
}
}
}

Resources