How can I create an "unfilled frame" in SwiftUI? - swift

I'm sure this is something super-simple, but I cannot seem to figure it out. I am trying to create a "widget" that consists of three lines of text, stacked vertically. This information should be placed inside a "frame" or "border" so it resembles a card. There will be a row of these cards that will scroll horizontally.
Believe it or not, the only part of this I cannot figure out is how to draw the border around the widget. I've tried .border, but that snugs the border right up against the text. I know I can add padding, but what I really need is a fixed-size card so each element in the scrolling list is identical.
I've come closest using this:
.frame(width: geometry.size.width/1.3, height: 200)
.background(Color.white)
.border(Color.blue)
.cornerRadius(20)
...but the corners are all clipped. For reference, here's the complete code listing:
struct AccountTile: View {
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 8) {
Text("Account Balance").font(.largeTitle)
Text("Account Name").font(.headline)
HStack(spacing: 0) {
Text("There are ").font(.caption).foregroundColor(.gray)
Text("6 ").font(.caption).fontWeight(.bold).foregroundColor(.blue)
Text("unreconciled transactions.").font(.caption).foregroundColor(.gray)
}
}
.frame(width: geometry.size.width/1.3, height: 200)
.background(Color.white)
.border(Color.blue)
.cornerRadius(20)
}
}
}
...and here's what that code is producing:
This is almost what I'm looking for - I just need the border to be complete.

Use some padding and overlay to create your border. Here is the code (:
struct AccountTile: View {
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 8) {
Text("Account Balance").font(.largeTitle)
Text("Account Name").font(.headline)
HStack(spacing: 0) {
Text("There are ").font(.caption).foregroundColor(.gray)
Text("6 ").font(.caption).fontWeight(.bold).foregroundColor(.blue)
Text("unreconciled transactions.").font(.caption).foregroundColor(.gray)
}
}.frame(width: geometry.size.width/1.3, height: 200)
.background(Color.white)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.blue, lineWidth: 2))
}
}
}

Related

Different verticalAlignment in HStack in SwiftUI

I'm trying to create simple cell layout in SwiftUI but I somehow stumbled on problem how to define different vertical alignments of elements in same HStack:
This is basically what I'm trying to achieve:
Whole view should be a cell, where there are some arbitrary paddings(24 on top, 20 at bottom). What is important is following:
HStack contains icon (red), vstack (title and description) and another icon (green)
Red icon should be aligned to the top of the HStack as well as vstack with texts
Green icon should be centered in the whole view
I've tried to achieve this with following code:
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 24) {
Image(nsImage: viewModel.icon)
.frame(width: 20.0, height: 20.0)
VStack(alignment: .leading, spacing: 4) {
Text(viewModel.title)
Text(viewModel.text)
}
Spacer()
Image(nsImage: "viewModel.next")
}
.padding([.top], 24)
.padding([.bottom], 20)
Divider()
}
Without luck as obviously the green icon is also aligned to the top. I've tried to mess around with layout guides without success.
Another solution I've tried is
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 24) {
Image(nsImage: viewModel.icon)
.frame(width: 20.0, height: 20.0)
VStack(alignment: .leading, spacing: 4) {
Text(viewModel.title)
Text(viewModel.text)
}
Spacer()
VStack {
Spacer()
Image(nsImage: "viewModel.next")
Spacer()
}
}
.padding([.top], 24)
.padding([.bottom], 20)
Divider()
}
which doesn't work either as I have more of these 'cells' in super view and their height is stretched to fill the superview.
Any idea how to achieve this?
I would treat the left-hand image and text as a single, top-aligned HStack, then put that in another HStack aligned centrally with the right-hand image. In shorthand, omitting spacing etc.:
HStack(alignment: .center) {
HStack(alignment: .top) {
Image(nsImage: ...)
VStack(alignment: .leading) {
Text(...)
Text(...)
}
}
Spacer()
Image(nsImage: ...)
}
That way, you only have a spacer working in the horizontal axis, so your overall vertical frame will be determined by the content alone.

SwiftUI - LazyVGrid spacing

I have a LazyVGrid like so in SearchView.swift file
let layout = [
GridItem(.flexible()),
GridItem(.flexible())
]
NavigationView {
ZStack {
VStack {
....Some other stuff here
ScrollView (showsIndicators: false) {
LazyVGrid(columns: layout) {
ForEach(searchViewModel.allUsers, id: \.uid) { user in
NavigationLink(destination: ProfileDetailedView(userData: user)) {
profileCard(profileURL: user.profileURL, username: user.username, age: user.age, country: user.city)
}
}
}
}
}
}
}
My profileCard.swift looks like:
var body: some View {
ZStack {
Image.image(urlString: profileURL,
content: {
$0.image
.resizable()
.aspectRatio(contentMode: .fill)
}
)
.frame(width: 185, height: 250)
.overlay(
LinearGradient(gradient: Gradient(colors: [.clear, .black]), startPoint: .center, endPoint: .bottom)
)
.overlay(
VStack (alignment: .leading) {
Text("\(username.trimmingCharacters(in: .whitespacesAndNewlines)), ")
.font(.custom("Roboto-Bold", size:14))
.foregroundColor(Color.white)
+ Text("\(age)")
.font(.custom("Roboto-Light", size:14))
.foregroundColor(Color.white)
HStack {
Text("\(country)")
.font(.custom("Roboto-Light", size:14))
.foregroundColor(.white)
}
}
.padding(.leading, 15)
.padding(.bottom, 15)
,alignment: .bottomLeading
)
.cornerRadius(12)
}
}
This is producing 2 different spaces on different screen sizes:
iPhone 12:
iPhone 12 Pro Max
Im trying to get the same amount of spacing between the cards (horizontal and verticle) and the around the cards on all devices, any help on achieving this?
UPDATE
Following the example by #Adrien has gotten me closer to the problem, but when I use an image, the results change completely
let columns = Array(repeating: GridItem(.flexible(), spacing: 20, alignment: .center), count: 2)
ScrollView (showsIndicators: false) {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(searchViewModel.allUsers, id: \.uid) { user in
NavigationLink(destination: ProfileDetailedView(userData: user)) {
HStack {
Image("placeholder-avatar")
.resizable()
.aspectRatio(contentMode: .fill)
}
.frame(maxWidth: .infinity, minHeight: 240) // HERE 1
.background(Color.black)
.cornerRadius(25)
}
}
}.padding(20)
}
The array of GridItem only fixes the behavior of the container of each cell. Here it is flexible, but that does not mean that it will modify its content.
The View it contains can have three different behaviors :
it adapts to its container:
like Rectangle, Image().resizable(), or any View with a .frame(maxWidth: .infinity, maxHeight: .infinity)) modifier.
it adapts to its content (like Text, or HStack).
it has a fixed size (as is your case with .frame(width: 185, height: 250))
If you want the same spacing (vertical and horizontal) between cells, whatever the device, the content of your ProfileDetailedView must adapt to its container :
you have to modify your cell so that it adopts behavior 1.
you can use the spacing parameters of yours GridItem (horizontal spacing) and LazyVGrid (vertical).
Example:
struct SwiftUIView5: View {
let columns = Array(repeating: GridItem(.flexible(), spacing: 20, alignment: .center), count: 2) // HERE 2
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 20) { // HERE 2
ForEach((1...10), id: \.self) { number in
HStack {
Text(number.description)
.foregroundColor(.white)
.font(.largeTitle)
}
.frame(maxWidth: .infinity, minHeight: 200) // HERE 1
.background(Color.black)
.cornerRadius(25)
}
}
.padding(20) // HERE 2
}
}
}
Actually, the spacing is a function of the screen size, in your case. You have hard-coded the size of the card (185x250), so the spacing is as expected, a bit tighter on smaller screens, bit more airy on larger screens.
Note that the vertical spacing is consistent and as expected. This seems logical because there is no vertical size constraint.
BTW, the two screenshots are not the same size, otherwise this aspect would be more obvious.
If you want to keep the size of the card fixed, you can perhaps at least keep the horizontal space between them constant, but then you will have larger margins on the sides.
Or you can adjust the size of the card dynamically. Check out GeometryReader, which will help you access runtime screen sizes.

SwiftUI Center Content alignment without supporting frames

I'm having trouble trying to center a single element to emulate the navigation modal with a close button.
I would like to center content without using a supporting Rectangle on the sides or spacers.
What i'm trying to achieve is whenever the text grow, if it reaches the left sides where there is the close xmark button it should try to push itself on the right where there is available space until it reaches the right border and after wrap itself if there are no available space on the both sides.
here are some pictures:
expected result 1
expected result 2
current solution short text
current solution long text
i tried using long and short text to test the content behaviour
Currently this is the start of the code and basically i would like to avoid to add the blue rectangle (that would be usually clear)
struct TestAlignmentSwiftUIView: View {
var body: some View {
HStack(spacing: 0) {
Rectangle().fill(Color.blue).frame(width: 44, height: 44)
Text("aaa eee aaa")
.background(Color.red)
.padding(5)
Button(action: {}, label: {
Image(systemName: "xmark")
.padding(15)
.frame(width: 44, height: 44)
.background(Color.yellow)
})
}
.background(Color.green)
}
}
What i've tried so far but doesn't resolve the issue if the code inside the text component grow:
Using a zstack where i place the text and the close button one on
top of each other but the button is pushed to the side using a spacer. It will work for small text or content but is not scalable if the text grows
var body: some View {
ZStack {
HStack {
Spacer()
Button(action: {}, label: {
Image(systemName: "xmark")
.padding(15)
.frame(width: 44, height: 44)
.background(Color.yellow)
})
}
Text("aaa eee aaa random long very long text that should wrap without overlapping. long text")
.background(Color.red)
.frame(maxWidth: .infinity, alignment: .center)
.padding(5)
.opacity(0.7)
}
.background(Color.green)
}
Using alignment guides :
i would create my own center alignment guide, then use this custom alignment on a vstack where i place my content plus a fake filler rectangle that should center the elements on the content side.
the problem is that with swiftui , as far i know, you can only align one descendant element, and doesn't support multiple custom alignments on the stack of elements. so i would have only the text centered or the side button aligned not both aligned one to the center and the other to the trailing edge. and if i put a spacer between them it will just mess the alignment created. If I try with small text they will be both attached.
Heres the code. try to comment the button and you will see that it will center itself or add spacer between them.
extension HorizontalAlignment {
private enum MyAlignment: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
d[HorizontalAlignment.center]
}
}
static let myAlignment = HorizontalAlignment(MyAlignment.self)
}
var body: some View {
VStack(alignment: .myAlignment, spacing: 0) {
HStack {
Text("aaa eee aaa random ")
.background(Color.red)
.frame(maxWidth: .infinity, alignment: .center)
.padding(5)
.alignmentGuide(.myhAlignment, computeValue: { dimension in
dimension[HorizontalAlignment.center]
})
Button(action: {}, label: {
Image(systemName: "xmark")
.padding(15)
.frame(width: 44, height: 44)
.background(Color.yellow)
})
}
.background(Color.green)
Rectangle()
.fill(Color.purple)
.frame(width: 10, height: 10, alignment: .center)
.alignmentGuide(.myhAlignment, computeValue: { dimension in
dimension[HorizontalAlignment.center]
})
}
}
Tried with a combination of geometry reader and/or anchor preferences to read with sizes of the text content and side button width and apply the appropriate center offset manually, but it seems too hacky and it never worked as expected without good results
If you're familiar with uikit this problem would be resolved using a
centerX on the container with a minor layout priority and a right constraint from the center to the
close button, and call it a day. But on swiftui it seems soo hard to
handle this simple cases.
So far i haven't found a solution without using a supporting fixed frame on the side that would work with both long and short text. that space is clearly visibile if you try to use long text. and it will leave the user to wonder why is not used.
¯\ (ツ)/¯
EDIT: added possible solution in the answers
From the #Yrb suggestion in the comments, here's what i came up that shrink the blue size so it will center on the available space.
I added a fake text underneath and tracked the size. and if it's over the available space i will take the difference and shrink the blu rectangle.
One thing to keep in mind is that the hidden content if contains some text should have linelimit 1, otherwise it will get a smaller size from wrapping itself.
And i just assume that i know the size of the close button (or at least one side) for center alignment, and even if i don't know it at compile time, i could probably use a preference key to get the size at run time, and have it dynamic.
But for the moment i think it's fine the result that i got.
but honestly i hope to find something more easier in the future.
#State var text: String = "aaa eee aaa"
#State private var fillerWidth: CGFloat = 44
// i assume i know the max size of the close button or at least one side
private let kCloseButtonWidth: CGFloat = 44
private struct FakeSizeTitlteContentKey: PreferenceKey {
static var defaultValue: CGFloat { .zero }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
var body: some View {
ZStack(alignment: Alignment(horizontal: .center, vertical: .top)) {
GeometryReader { parentGeometry in
titleContent
.lineLimit(1) // hidden text must not wrap
.overlay(GeometryReader { proxyFake in
Color.clear.border(Color.black, width: 0.3)
.preference(key: FakeSizeTitlteContentKey.self, value: proxyFake.frame(in: .local).width
.onPreferenceChange(FakeSizeTitlteContentKey.self) { value in
let availableW = parentGeometry.frame(in: .local).width
let fillSpace = availableW - value - kCloseButtonWidth * 2
fillerWidth = min(kCloseButtonWidth, max(0, fillSpace))
}
})
}
.hidden()
VStack {
HStack(spacing: 0) {
Rectangle()
.fill(Color.blue)
.frame(width: fillerWidth, height: 44)
titleContent
.background(Color.green)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center)
Button(action: {}, label: {
Image(systemName: "xmark")
.padding(15)
.frame(width: kCloseButtonWidth, height: kCloseButtonWidth)
.background(Color.yellow)
})
}
.coordinateSpace(name: "fullCont")
.background(Color.green)
TextEditor(text: $text)
.frame(maxHeight: 150, alignment: .center)
.border(Color.black, width: 1)
.padding(15)
Spacer()
}
}
}
#ViewBuilder var titleContent: some View {
HStack(spacing: 0) {
Text(text)
.background(Color.red)
.padding(.horizontal, 5)
}
}

Swift UI stacked bordered views consistent border thickness

In my Swift UI project, I have a number of vertically stacked views each with a black border. See this example visual:
Note that the top and bottom borders merge together to form a sort of thicker border. How do I make the merged top / bottom borders look the same thickness as the horizontal borders? In CSS I would simply make the thickness of the top and bottom borders half of the horizontal borders, but Swift doesn't have the capability of changing border widths for each edge as far as I know.
struct test: View {
var body: some View {
ScrollView {
ScrollViewReader { value in
ForEach(0..<100) { i in
Text("Example \(i)")
.font(.title)
.frame(width: 200, height: 200)
.id(i)
.border(Color.black, width: 4)
}
.onAppear() {
value.scrollTo(99)
}
}
}
}
}
On each edge, you can just overlay a Rectangle. The rectangles on the top and bottom are half the height, so when together, it is the same size as the width ones.
Code:
ScrollView {
ScrollViewReader { value in
VStack(spacing: 0) {
ForEach(0..<100) { i in
Text("Example \(i)")
.font(.title)
.frame(width: 200, height: 200)
.id(i)
.overlay(
Rectangle().frame(height: 2),
alignment: .top
)
.overlay(
Rectangle().frame(height: 2),
alignment: .bottom
)
.overlay(
Rectangle().frame(width: 4),
alignment: .leading
)
.overlay(
Rectangle().frame(width: 4),
alignment: .trailing
)
}
}
.onAppear() {
value.scrollTo(99)
}
}
}
A little bit of a hacky solution but managed to have the result with a negative spacing in a VStack wrapping the view
struct test: View {
var body: some View {
ScrollView {
ScrollViewReader { value in
VStack(spacing: -4){
ForEach(0..<100) { i in
Text("Example \(i)")
.font(.title)
.frame(width: 200, height: 200)
.id(i)
.border(Color.black, width: 4)
}
}
.onAppear() {
value.scrollTo(99)
}
}
}
}
}
I think the easiest way would be to add a negative padding. Just make the padding value half of the border width. Some other notes:
Make sure to add a VStack or LazyVStack inside your ScrollView - otherwise, you won't be able to have fine control over spacing, alignment, or layout.
I recommend LazyVStack because you have 100 Texts, which is a lot. When you have lots of elements such as Text, use LazyVStack to improve performance.
struct ContentView: View {
var body: some View {
ScrollView {
ScrollViewReader { value in
LazyVStack(spacing: 0) { /// better performance + set spacing to 0
ForEach(0..<100) { i in
Text("Example \(i)")
.font(.title)
.frame(width: 200, height: 200)
.id(i)
.border(Color.black, width: 4)
.padding(.vertical, -2) /// make this half of the border width
}
}
.onAppear() {
value.scrollTo(99)
}
}
}
}
}
Result:

Prevent view from overflowing in both directions

I'm trying to build a user-expandable view that will show more of its content by increasing its height. To accomplish this I will add .clipped() so that the content shown out of its bounds will be hidden, just like you would add overflow: hidden; in CSS.
However, it seems like by default VStack is centering its children, so when the height is smaller than the sum of the children's height, they overflow in both the top and the bottom.
Here is an example of what I'm talking about:
struct ExandableView: View {
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Color.red)
.frame(height: 50)
.padding(.horizontal)
Rectangle()
.fill(Color.green)
.frame(height: 50)
.padding(.horizontal)
}
.frame(height: 90)
.background(Color.blue)
}
}
Is there any way to make it behave so that the red item (the first) is always inside the blue container and the children can only overflow from the bottom?
Change frame alignment according to ExandableView. like this
struct ExandableView: View {
var body: some View {
// Other VStack code
}
.frame(height: 90, alignment: isExpand ? .top : .center) //<--Here
.background(Color.blue)
}
}