How to use GeometryReader to achieve a table like layout? - swiftui

I'm trying to achieve a layout like this:
For this simple example the base would be something like this:
HStack {
VStack {
Text("Foo")
Text("W")
Text("X")
}
VStack {
Text("Bar")
Text("Y")
Text("Z")
}
}
Now that relativeSize(...) is deprecated, the only remaining option I see is GeometryReader, but the issue with it is that once it's itself nested in another stack, it will attempt to fill all available space, in other terms it cannot determine the size it's containing stack would have had if it wasn't present in it and I end up with an overly sized stack.
I wonder if I'm missing something or if this is just how stacks work, or maybe a beta bug?
Thank you for your help
EDIT:
I did this:
VStack {
GeometryReader { /* #kontiki code */ }
Text("Other")
Spacer().layoutPriority(1)
}
But unfortunately this is the result I get, do you think this is a SwiftUI bug?

Second Attempt
I think this does exactly what you need. It uses Preferences. If you need to learn more about how to use SwiftUI preferences, check this post I wrote. They are fully explained there, but it is too long of a subject to post it here.
import SwiftUI
struct MyPref: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct SetWidthPreference: View {
var body: some View {
GeometryReader { proxy in
Rectangle().fill(Color.clear).preference(key: MyPref.self, value: proxy.size.width)
}
}
}
struct ContentView : View {
#State private var width: CGFloat = 0
var body: some View {
VStack {
ScrollView {
HStack(spacing: 0) {
VStack {
Text("Foo")
Text("Bar")
}.frame(width: width * 0.7, alignment: .leading).fixedSize().border(Color.red)
VStack {
Text("W")
Text("Y")
}.frame(width: width * 0.15).fixedSize().border(Color.red)
VStack {
Text("X")
Text("Z")
}.frame(width: width * 0.15).fixedSize().border(Color.red)
}
Text("Text below table")
}
.border(Color.green, width: 3)
HStack { Spacer() }.background(SetWidthPreference())
}
.onPreferenceChange(MyPref.self) { w in
print("\(w)")
DispatchQueue.main.async {
self.width = w
}
}
}
}
Previous Attempt (I keep it here, so comments make sense)
This example will draw 3 columns with 0.7, 0.15 and 0.15 of the parent's width. It's a starting point that you can fine tune. Note that the borders are there so that you can see what you are doing, of course you can remove them.
If GeometryReader is expanding too much, explain exactly what is that you want to accomplish, providing more context on the surroundings of the table (i.e., GeometryReader).
struct ContentView : View {
var body: some View {
GeometryReader { proxy in
HStack(spacing: 0) {
VStack {
Text("Foo")
Text("Bar")
}.frame(width: proxy.size.width * 0.7, alignment: .leading).fixedSize().border(Color.red)
VStack {
Text("W")
Text("Y")
}.frame(width: proxy.size.width * 0.15).fixedSize().border(Color.red)
VStack {
Text("X")
Text("Z")
}.frame(width: proxy.size.width * 0.15).fixedSize().border(Color.red)
}
}.padding(20)
}
}

Related

SwiftUI - How can I use ObservedObject or EnvironmentObject to store GeometryReader data?

I am trying to follow the design created for an app which has some objects placed in the middle of the screen.
The objects should have a size and padding proportional to the device's screen size, meaning they should appear bigger if the screen is bigger than the screen we take as a base in the design (the base is an iPhone 11 screen in this case). In addition, these objects have more objects inside, which should also be proportional to the screen size. For example: a Text view placed whithin the borders of a RoundedRectangle for which the font should grow if the screen is bigger than the screen used as a base; or an image inside another image. In these examples, the object and the objects inside of it should all be proportional to the screen size.
So far, we are using GeometryReader to accomplish this. The way we are doing it needs us to use GeometryReader in each file we have defined for a screen and its views. Once we have GeometryReader data, we use the Scale struct to get the correct proportions for the objects.
Here is the sample code:
GeometryReaderSampleView.swift
import SwiftUI
struct GeometryReaderSampleView: View {
var body: some View {
NavigationView {
GeometryReader { metrics in
ZStack {
VStack {
LoginMainDecorationView(Scale(geometry: metrics))
Spacer()
}
VStack {
HStack {
GreenSquareView(Scale(geometry: metrics))
Spacer()
}
.offset(x: 29, y: Scale(geometry: metrics).vertical(300.0))
Spacer()
}
}
}
}
}
}
struct GreenSquareView: View {
let scale:Scale
init (_ scale:Scale) {
self.scale = scale
}
var body: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: scale.horizontal(30))
.fill(Color.green)
.frame(width: scale.horizontal(157), height: scale.horizontal(146))
Text("Here goes\nsome text")
.font(.custom("TimesNewRomanPS-ItalicMT", size: scale.horizontal(20)))
.padding(.top, scale.horizontal(29))
.padding(.leading, scale.horizontal(19))
VStack {
Spacer()
HStack {
Spacer()
Image(systemName: "heart.circle")
.resizable()
.frame(width: scale.horizontal(20), height: scale.horizontal(20))
.offset(x: scale.horizontal(-20), y: scale.vertical(-17.0))
}
}.frame(width: scale.horizontal(157), height: scale.horizontal(146))
}
}
}
struct LoginMainDecorationView: View {
let scale:Scale
init (_ scale:Scale) {
self.scale = scale
}
var body: some View {
HStack {
Image(systemName: "cloud.rain")
.resizable()
.frame(width: scale.horizontal(84), height: scale.horizontal(68), alignment: .leading)
.offset(x: 0, y: scale.vertical(200.0))
Spacer()
Image(systemName: "cloud.snow")
.resizable()
.frame(width: scale.horizontal(119), height: scale.horizontal(91), alignment: .trailing)
.offset(x: scale.horizontal(-20.0), y: scale.vertical(330.0))
}
}
}
struct GeometryReaderSampleView_Previews: PreviewProvider {
static var previews: some View {
GeometryReaderSampleView()
}
}
Scale.swift
import SwiftUI
struct Scale {
// Size of iPhone 11 Pro
let originalWidth:CGFloat = 375.0
let originalHeight:CGFloat = 734.0
let horizontalProportion:CGFloat
let verticalProportion:CGFloat
init(screenWidth:CGFloat, screenHeight:CGFloat) {
horizontalProportion = screenWidth / originalWidth
verticalProportion = screenHeight / originalHeight
}
init(geometry: GeometryProxy) {
self.init(screenWidth: geometry.size.width, screenHeight: geometry.size.height)
}
func horizontal(_ value:CGFloat) -> CGFloat {
return value * horizontalProportion
}
func vertical(_ value:CGFloat) -> CGFloat {
return value * verticalProportion
}
}
The question / request
I would like to simplify this code and store the GeometryReader data (the Scale struct with its info) in an ObservedObject or an EnvironmentObject so that we can use it in different views and files all over the project. The problem with this is that we cannot get GeometryReader data until the view is loaded, and once the view is loaded I believe we cannot declare ObservedObject or EnvironmentObject anymore (is that correct?).
I know there could be a way to get the screen size without using GeometryReader as shown here: How to get the iPhone's screen width in SwiftUI?. But if I used GeometryReader to get the size of a view that is inside another view, I would like to have its information stored as well.
The goal would be not to use this code inside each view that needs to use scale:
let scale:Scale
init (_ scale:Scale) {
self.scale = scale
}
and instead use ObservedObject or EnvironmentObject to get the scale data from the views that need it. Therefore, how can I use ObservedObject or EnvironmentObject to store GeometryReader data?
I tend to think that you're fighting the general principals of SwiftUI a little by doing this (ie basing things on screen sizes rather than using the built-in SwiftUI layout principals that are screen size independent like padding). Assuming you want to go forward with the plan, though, I'd recommend using an #Envrionment value. I don't think it needs to be an #EnvironmentObject, since Scale is a struct and there's no compelling reason to have a reference-type to box the value.
Here's a simple example:
private struct ScaleKey: EnvironmentKey {
static let defaultValue = Scale(screenWidth: -1, screenHeight: -1)
}
extension EnvironmentValues {
var scale: Scale {
get { self[ScaleKey.self] }
set { self[ScaleKey.self] = newValue }
}
}
struct ContentView: View {
var body: some View {
GeometryReader { metrics in
SubView()
.environment(\.scale, Scale(geometry: metrics))
}
}
}
struct SubView : View {
#Environment(\.scale) private var scale : Scale
var body: some View {
Text("Scale: \(scale.horizontal(1)) x \(scale.vertical(1))")
}
}

How to layout properly in ZStack (I have visibility problem)?

Here is reproducable small code below;
As you'll see when you run the demo code, the Element view does stay under Color.blue when dragged eventhough its above according to ZStack. By the way I also played with zIndex modifier but still no luck. Any solution you offer? Thanks all.
struct ContentView: View {
var body: some View {
GeometryReader { gr in
ZStack {
Color.blue.opacity(0.3)
.aspectRatio(1, contentMode: .fit)
.frame(width: gr.size.width)
VStack {
Spacer()
ScrollView(.horizontal) {
HStack {
ForEach(1...15, id: \.self) { (idx) in
Element(index: idx)
}
}
.padding()
}
.background(Color.secondary.opacity(0.3))
}
}
}
}
}
struct Element: View {
#State private var dragAmount = CGSize.zero
var index: Int
var body: some View {
Rectangle()
.frame(width: 80, height: 80)
.overlay(Text("\(index)").bold().foregroundColor(.white))
.offset(dragAmount)
.gesture(
DragGesture(coordinateSpace: .global)
.onChanged {
self.dragAmount = CGSize(width: $0.translation.width, height: $0.translation.height)
}
.onEnded { _ in
self.dragAmount = .zero
}
)
}
}
iOS 15.5: still valid
How can achieve my goal then, like dragging Element on different view (in this scenario Color.blue)
Actually we need to disable clipping by ScrollView.
Below is possible approach based on helper extensions from my other answers (https://stackoverflow.com/a/63322713/12299030 and https://stackoverflow.com/a/60855853/12299030)
VStack {
Spacer()
ScrollView(.horizontal) {
HStack {
ForEach(1...15, id: \.self) { (idx) in
Element(index: idx)
}
}
.padding()
.background(ScrollViewConfigurator {
$0?.clipsToBounds = false // << here !!
})
}
.background(Color.secondary.opacity(0.3))
}

How to use geometry reader so that the view does not expand?

I have used geometry reader like this
GeometryReader { r in
ScrollView {
Text("SomeText").frame(width: r.size.width / 2)
}
}
The problem is that the reader expands vertically much like Spacer().
Is there anyway that I can make it not do this?
After googling around I found this answer here.
Create this new struct
struct SingleAxisGeometryReader<Content: View>: View {
private struct SizeKey: PreferenceKey {
static var defaultValue: CGFloat { 10 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
#State private var size: CGFloat = SizeKey.defaultValue
var axis: Axis = .horizontal
var alignment: Alignment = .center
let content: (CGFloat)->Content
var body: some View {
content(size)
.frame(maxWidth: axis == .horizontal ? .infinity : nil,
maxHeight: axis == .vertical ? .infinity : nil,
alignment: alignment)
.background(GeometryReader {
proxy in
Color.clear.preference(key: SizeKey.self, value: axis == .horizontal ? proxy.size.width : proxy.size.height)
}).onPreferenceChange(SizeKey.self) { size = $0 }
}
}
And then use it like this
SingleAxisGeometryReader { width in // For horizontal
// stuff here
}
or
SingleAxisGeometryReader(axis: .vertical) { height in // For vertical
// stuff here
}
With this answer, it’s now generic with no code change.
Since background is fit to actual view size always.you can use this trick, adding GeometryReader in background without changing the size of the view itself.
ScrollView {
}.background(
GeometryReader { r in
// stuff
}
)
}
It's somewhat unclear what you're actually trying to do with the views if it's not actually the code you gave at the top. With regards to that, though, you can swap the position of the GeometryReader and the ScrollView. What the GeometryReader does is find the frame of the available space, and it fills it. With a ScrollView the actual height is 0. So, this:
ScrollView {
GeometryReader {r in
Text("SomeText").frame(width: r.size.width / 2)
}
}

Content hugging priority behaviour in SwiftUI

I have a List made of cells, each containing an image, and a column of text, which I wish laid out in a specific way. Image on the left, taking up a quarter of the width. The rest of the space given to the text, which is left-aligned.
Here's the code I got:
struct TestCell: View {
let model: ModelStruct
var body: some View {
HStack {
Image("flag")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: UIScreen.main.bounds.size.width * 0.25)
VStack(alignment: .leading, spacing: 5) {
Text("Country: Moldova")
Text("Capital: Chișinău")
Text("Currency: Leu")
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
}
}
struct TestCell_Previews: PreviewProvider {
static var previews: some View {
TestCell()
.previewLayout(.sizeThatFits)
.previewDevice("iPhone 11")
}
}
And here are 2 examples:
As you can see, the height of the whole cell varies based on the aspect ratio of the image.
$1M question - How can we make the cell height hug the text (like in the second image) and not vary, but rather shrink the image in a scaleAspectFit manner inside the allocated rectangle
Note!
The text's height can vary, so no hardcoding.
Couldn't make it work with PreferenceKeys, as the cells will be part of a List, and there's some peculiar behaviour I'm trying to grasp around cell reusage, and onPreferenceChange not being called when 2 consecutive cells have the same height. To exhibit all this combined behaviour, make sure your model varies between cells when you test it.
Here is a possible solution, however it uses GeometryReader inside the background property of the VStack, to detect their height. That height is being applied to the Image then. I used SizePreferenceKey from this solution.
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = .zero
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
struct ContentView6: View {
#State var childSize: CGSize = .zero
var body: some View {
HStack {
Image("image1")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: UIScreen.main.bounds.size.width * 0.25, height: self.childSize.height)
VStack(alignment: .leading, spacing: 5) {
Text("Country: Moldova")
Text("Capital: Chișinău")
Text("Currency: Leu")
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(
GeometryReader { proxy in
Color.clear.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
}
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.childSize = preferences
}
.border(Color.yellow)
}
}
Will look like this.. you can apply different aspect ratios for the Image of course.
This is what worked for me to constrain a color view to the height of text content in a cell:
A height reader view:
struct HeightReader: View {
#Binding var height: CGFloat
var body: some View {
GeometryReader { proxy -> Color in
update(with: proxy.size.height)
return Color.clear
}
}
private func update(with value: CGFloat) {
guard value != height else { return }
DispatchQueue.main.async {
height = value
}
}
}
You can then use the reader in a compound view as a background on the view you wish to constrain to, using a state object to update the frame of the view you wish to constrain:
struct CompoundView: View {
#State var height: CGFloat = 0
var body: some View {
HStack(alignment: .top) {
Color.red
.frame(width: 2, height: height)
VStack(alignment: .leading) {
Text("Some text")
Text("Some more text")
}
.background(HeightReader(height: $height))
}
}
}
struct CompoundView_Previews: PreviewProvider {
static var previews: some View {
CompoundView()
}
}
I have found that using DispatchQueue to update the binding is important.

Square Text using aspectRatio in SwiftUI

I'm trying to achieve a following layout using Swift UI…
struct ContentView : View {
var body: some View {
List(1...5) { index in
HStack {
HStack {
Text("Item number \(index)")
Spacer()
}.padding([.leading, .top, .bottom])
.background(Color.blue)
Text("i")
.font(.title)
.italic()
.padding()
.aspectRatio(1, contentMode: .fill)
.background(Color.pink)
}.background(Color.yellow)
}
}
}
I'd like the Text("i") to be square, but setting the .aspectRatio(1, contentMode: .fill) doesn't seem to do anything…
I could set the frame width and height of the text so it's square, but it seems that setting the aspect ratio should achieve what I want in a more dynamic way.
What am I missing?
I think this is what you're looking for:
List(1..<6) { index in
HStack {
HStack {
Text("Item number \(index)")
Spacer()
}
.padding([.leading, .top, .bottom])
.background(Color.blue)
Text("i")
.font(.title)
.italic()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.aspectRatio(1, contentMode: .fill)
.background(Color.pink)
.fixedSize(horizontal: true, vertical: false)
.padding(.leading, 6)
}
.padding(6)
.background(Color.yellow)
}
The answer being said, i don't recommend giving SwiftUI too much freedom to decide the sizings. one of the biggest SwiftUI problems right now is the way it decides how to fit the views into each other. if something goes not-so-good on SwiftUI's side, it can result in too many calls to the UIKit's sizeToFit method which can slowdown the app, or even crash it.
but, if you tried this solution in a few different situations and it worked, you can assume that in your case, giving SwiftUI the choice of deciding the sizings is not problematic.
The issue is due to used different fonts for left/right sides, so paddings generate different resulting area.
Here is possible solution. The idea is to give right side rect based on default view size of left side text (this gives ability to track dynamic fonts sizes as well, automatically).
Tested with Xcode 12 / iOS 14
struct ContentView: View {
#State private var height = CGFloat.zero
var body: some View {
List(1...5, id: \.self) { index in
HStack(spacing: 8) {
HStack {
Text("Item number \(index)")
Spacer()
}
.padding([.leading, .top, .bottom])
.background(GeometryReader {
Color.blue.preference(key: ViewHeightKey.self, value: $0.frame(in: .local).size.height)
})
Text("i")
.italic()
.font(.title)
.frame(width: height, height: height)
.background(Color.pink)
}
.padding(8)
.background(Color.yellow)
.onPreferenceChange(ViewHeightKey.self) {
self.height = $0
}
}
}
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I managed to recreate the view in your first screenshot in SwiftUI. I wasn't sure on how much padding you wanted so I defined a private immutable variable for this value
The blue view is the one that will have the text content and could change in size so by using a GeometryReader you can get the size of the blue view and then use the height value from the size to set the width and height of the pink view. This means that whatever the height of the blue view is, the pink view will follow keeping an equal aspect ratio
The SizeGetter view below is used to get any views size using a GeometryReader and then binds that value back to a #State variable in the ContentView. Because the #State and #Binding property wrappers are being used, whenever the blueViewSize is updated SwiftUI will automatically refresh the view.
The SizeGetter view can be used for any view and is implemented using the .background() modifier as shown below
struct SizeGetter: View {
#Binding var size: CGSize;
var body: some View {
// Get the size of the view using a GeometryReader
GeometryReader { geometry in
Group { () -> AnyView in
// Get the size from the geometry
let size = geometry.frame(in: .global).size;
// If the size has changed, update the size on the main thread
// Checking if the size has changed stops an infinite layout loop
if (size != self.size) {
DispatchQueue.main.async {
self.size = size;
}
}
// Return an empty view
return AnyView(EmptyView());
}
}
}
}
struct ContentView: View {
private let padding: Length = 10;
#State private var blueViewSize: CGSize = .zero;
var body: some View {
List(1...5) { index in
// The yellow view
HStack(spacing: self.padding) {
// The blue view
HStack(spacing: 0) {
VStack(spacing: 0) {
Text("Item number \(index)")
.padding(self.padding);
}
Spacer();
}
.background(SizeGetter(size: self.$blueViewSize))
.background(Color.blue);
// The pink view
VStack(spacing: 0) {
Text("i")
.font(.title)
.italic();
}
.frame(
width: self.blueViewSize.height,
height: self.blueViewSize.height
)
.background(Color.pink);
}
.padding(self.padding)
.background(Color.yellow);
}
}
}
In my opinion it is better to set the background colour of a VStack or HStack instead of the Text view directly because you can then add more text and other views to the stack and not have to set the background colour for each one
I was searching very similar topic "Square Text in SwiftUI", came across your question and I think I've found quite simple approach to achieve your desired layout, using GeometryProxy to set width and heigh of the square view from offered geometry.size.
Checkout the code below, an example of TableCellView which can be used within List View context:
import SwiftUI
struct TableCellView: View {
var index: Int
var body: some View {
HStack {
HStack {
Text("Item number \(index)")
.padding([.top, .leading, .bottom])
Spacer()
}
.background(Color(.systemBlue))
.layoutPriority(1)
GeometryReader { geometry in
self.squareView(geometry: geometry)
}
.padding(.trailing)
}
.background(Color(.systemYellow))
.padding(.trailing)
}
func squareView(geometry: GeometryProxy) -> some View {
Text("i")
.frame(width: geometry.size.height, height: geometry.size.height)
.background(Color(.systemPink))
}
}