I'm struggling to make a View which changes depending on how large its contents' widths are. Perhaps I'm not approaching the problem correctly?
Take this example View, designed to be one of many items in a vertical List:
Hstack {
Text(leftText)
Spacer()
Text(rightText)
}
Given that leftText and rightText are subject to change, how does one change this view to look cleaner if the Text Views become too large to fit on the same row? e.g.
VStack (alignment: .leading) {
Text(leftText)
HStack {
Spacer()
Text(rightText)
}
}
The only solution I've come up with is measuring the rightText View by duplicating it and hiding it elsewhere (in a ZStack, so it doesn't mess up the UI spacing). This ends up being rather messy and wasteful, as the right View may not be as cheap as simple Text Views. Does anyone know a better way to approach this?
I agree with Lorem that the question is a little vague, but here are a few suggestions that might (perhaps) fit your needs.
Could you live with having both leftText and rightText multi-lined? The modifier is .lineLimit(n) where n is the maximum number of lines you want to allow.
Would a different truncation pattern work? The default is to have the trailing end of the text truncated. However, you can specify an alternative mode, such as .truncationMode(.middle) instead.
One caveat about combining these solutions, it seems to me that truncationMode only works when the lineLimit is exactly 1.
In case you want some thing similar like this
Here is the code
fileprivate let loremipsumText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit"
fileprivate func getRandomText() -> String {
String(loremipsumText.prefix(Int.random(in: 1 ..< loremipsumText.count)))
}
struct TextContainer: View {
var text: String
var body: some View {
ZStack {
Color.orange.cornerRadius(4)
Text(text)
.frame(maxWidth: UIScreen.main.bounds.width * 0.4)
.padding(3)
}
}
}
struct TestListViewDoubleText: View {
var body: some View {
List {
ForEach(0..<20) { _ in
HStack {
TextContainer(text: getRandomText())
Spacer()
TextContainer(text: getRandomText())
}
}
}
}
}
Related
I want to display a view as a popup/tooltip from a view. I beleive the best way to acheive this is by presenting it as an overlay. But, the view is not expanding outside of bounds of where its being presented.
import SwiftUI
struct ContentView: View {
let message = "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like)"
var body: some View {
VStack {
Button {
} label: {
Text("Tap Me")
.background(
Rectangle()
.fill(.red)
)
}
.overlay {
contentView
}
}
.padding()
}
var contentView: some View {
Text(message)
.foregroundColor(Color.white)
.padding()
.background(Color.black)
.foregroundColor(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 5))
.offset(y: 60)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
return ContentView()
}
}
Why is the contentView not expanding outside of the view from where it is being overlayed in my SwiftUI code?
I tried setting fixedSize, frame(maxWidth, but none of them have correct behaviour.
The .overlay modifier always takes the size of its parent view as its maximum size. To put any size view in front of another, you should use a ZStack, e.g.
ZStack {
Button {
} label: {
Text("Tap Me")
.background(
Rectangle()
.fill(.red)
)
}
contentView
}
This is what it looks like (with .opacity applied)
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)
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)
}
}
}
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") }
}
}
}
I have a horizontal stack of two pieces of text (the second highlighted in a blue). It fits fine on an iPhone XR, however when on a smaller device (like iPhone X), the text doesn't fit. I attempted to solve this by using minimumScaleFactor to scale the text. However, SwiftUI seems to make decisions on what to scale in the stack. In this example, on the smaller device, it removes the bolding and shrinks the first (non-blue) element only. The blue element remains unchanged. Any ideas as to why this would happen? How can I scale both bolded text elements down in size together? Thanks!
var normalText: String
var highlightedText: String
var body: some View {
HStack() {
Text(normalText)
.font(.largeTitle)
.bold()
.lineLimit(1)
Text(highlightedText)
.font(.largeTitle)
.bold()
.lineLimit(1)
.foregroundColor(.blue)
Spacer()
}
.minimumScaleFactor(0.5)
}
}
Here is how it displays on a smaller device:
And how it shows on a larger device:
struct ContentView: View {
var normalText: String = "Hello and Welcome to Stack "
var highlightedText: String = "Overflow"
var body: some View {
HStack() {
Group {
Text(normalText).bold() +
Text(highlightedText).bold()
.foregroundColor(.blue)
}
.lineLimit(1).font(.largeTitle)
Spacer()
}
.minimumScaleFactor(0.5)
}
}
+ is defined for 2 Text's but not 2 View's and as lineLimit() returns a View the joined text's needed to be Grouped. I made the combined string longer to force it to shrink.