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

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)
}
}

Related

GeometryReader inside ScrollView - scroll doesn't work anymore [duplicate]

With the new ScrollViewReader, it seems possible to set the scroll offset programmatically.
But I was wondering if it is also possible to get the current scroll position?
It seems like the ScrollViewProxy only comes with the scrollTo method, allowing us to set the offset.
Thanks!
It was possible to read it and before. Here is a solution based on view preferences.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
}.coordinateSpace(name: "scroll")
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I found a version without using PreferenceKey. The idea is simple - by returning Color from GeometryReader, we can set scrollOffset directly inside background modifier.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader { proxy -> Color in
DispatchQueue.main.async {
offset = -proxy.frame(in: .named("scroll")).origin.y
}
return Color.clear
})
}.coordinateSpace(name: "scroll")
}
}
I had a similar need but with List instead of ScrollView, and wanted to know wether items in the lists are visible or not (List preloads views not yet visible, so onAppear()/onDisappear() are not suitable).
After a bit of "beautification" I ended up with this usage:
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
List(0..<100) { i in
Text("Item \(i)")
.onItemFrameChanged(listGeometry: geometry) { (frame: CGRect?) in
print("rect of item \(i): \(String(describing: frame)))")
}
}
.trackListFrame()
}
}
}
which is backed by this Swift package: https://github.com/Ceylo/ListItemTracking
The most popular answer (#Asperi's) has a limitation:
The scroll offset can be used in a function
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
which is convenient for triggering an event based on that offset.
But what if the content of the ScrollView depends on this offset (for example if it has to display it). So we need this function to update a #State.
The problem then is that each time this offset changes, the #State is updated and the body is re-evaluated. This causes a slow display.
We could instead wrap the content of the ScrollView directly in the GeometryReader so that this content can depend on its position directly (without using a State or even a PreferenceKey).
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
}
where content is (CGPoint) -> some View
We could take advantage of this to observe when the offset stops being updated, and reproduce the didEndDragging behavior of UIScrollView
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin,
perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2,
scheduler: DispatchQueue.main),
perform: didEndScrolling)
}
where offsetObserver = PassthroughSubject<CGPoint, Never>()
In the end, this gives :
struct _ScrollViewWithOffset<Content: View>: View {
private let axis: Axis.Set
private let content: (CGPoint) -> Content
private let didEndScrolling: (CGPoint) -> Void
private let offsetObserver = PassthroughSubject<CGPoint, Never>()
private let spaceName = "scrollView"
init(axis: Axis.Set = .vertical,
content: #escaping (CGPoint) -> Content,
didEndScrolling: #escaping (CGPoint) -> Void = { _ in }) {
self.axis = axis
self.content = content
self.didEndScrolling = didEndScrolling
}
var body: some View {
ScrollView(axis) {
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin, perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2, scheduler: DispatchQueue.main), perform: didEndScrolling)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.coordinateSpace(name: spaceName)
}
}
Note: the only problem I see is that the GeometryReader takes all the available width and height. This is not always desirable (especially for a horizontal ScrollView). One must then determine the size of the content to reflect it on the ScrollView.
struct ScrollViewWithOffset<Content: View>: View {
#State private var height: CGFloat?
#State private var width: CGFloat?
let axis: Axis.Set
let content: (CGPoint) -> Content
let didEndScrolling: (CGPoint) -> Void
var body: some View {
_ScrollViewWithOffset(axis: axis) { offset in
content(offset)
.fixedSize()
.overlay(GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
width = geo.size.width
}
})
} didEndScrolling: {
didEndScrolling($0)
}
.frame(width: axis == .vertical ? width : nil,
height: axis == .horizontal ? height : nil)
}
}
This will work in most cases (unless the content size changes, which I don't think is desirable). And finally you can use it like that :
struct ScrollViewWithOffsetForPreviews: View {
#State private var cpt = 0
let axis: Axis.Set
var body: some View {
NavigationView {
ScrollViewWithOffset(axis: axis) { offset in
VStack {
Color.pink
.frame(width: 100, height: 100)
Text(offset.x.description)
Text(offset.y.description)
Text(cpt.description)
}
} didEndScrolling: { _ in
cpt += 1
}
.background(Color.mint)
.navigationTitle(axis == .vertical ? "Vertical" : "Horizontal")
}
}
}

GeometryReader to calculate total height of views

I have a main view with a fixed width and height because this view will be converted into a PDF. Next, I have an array of subviews that will be vertically stacked in the main view. There may be more subviews available than can fit in the main view so I need a way to make sure that I don't exceed the main view's total height. For example, if I have eight subviews in my array but only three will fit into the main view, then I need to stop adding subviews after three. The remaining subviews will start the process over on a new main view.
My problem is, if I use GeometryReader to get the height of a view, first I have to add it. But once the view is added, it’s too late to find out if it exceeded the total height available.
Below shows how I get the height of each view as it's being added. Which is not much to go on, I know, but I'm pretty stuck.
Update:
My current strategy is to create a temporary view where I can add subviews and return an array with only the ones that fit.
struct PDFView: View {
var body: some View {
VStack {
ForEach(tasks) { task in
TaskRowView(task: task)
.overlay(
GeometryReader { geo in
// geo.size.height - to get height of current view
})
}
}
.layoutPriority(1)
}
}
A possible solution is to use View Preferences.
You can calculate the total height of your items and in onPreferenceChange increase their count by 1 (until the totalHeight reaches maxHeight).
Here is the full implementation:
Create a custom PreferenceKey for retrieving view height:
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}
struct ViewGeometry: View {
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: ViewHeightKey.self, value: geometry.size.height)
}
}
}
Create a TaskRowView (of any height):
struct TaskRowView: View {
let index: Int
var body: some View {
Text("\(index)")
.padding()
.background(Color.red)
}
}
Use the custom PreferenceKey in your view:
struct ContentView: View {
#State private var count = 0
#State private var totalHeight: CGFloat = 0
#State private var maxHeightReached = false
let maxHeight: CGFloat = 300
var body: some View {
VStack {
Text("Total height: \(totalHeight)")
VStack {
ForEach(0 ..< count, id: \.self) {
TaskRowView(index: $0)
}
}
.background(ViewGeometry())
.onPreferenceChange(ViewHeightKey.self) {
totalHeight = $0
print(count, totalHeight)
guard !maxHeightReached else { return }
if $0 < maxHeight {
count += 1
} else {
count -= 1
maxHeightReached = true
}
}
}
}
}
You can get total height in one place as shown below:
VStack {
ForEach(tasks) { task in
TaskRowView(task: task)
}
}
.overlay(
GeometryReader { geo in
// geo.size.height // << move it here and get total height at once
})

SwiftUI ScrollView does not center content when content fits scrollview bounds

I'm trying to have the content inside a ScrollView be centered when that content is small enough to not require scrolling, but instead it aligns to the top. Is this a bug or I'm missing adding something? Using Xcode 11.4 (11E146)
#State private var count : Int = 100
var body : some View {
// VStack {
ScrollView {
VStack {
Button(action: {
if self.count > 99 {
self.count = 5
} else {
self.count = 100
}
}) {
Text("CLICK")
}
ForEach(0...count, id: \.self) { no in
Text("entry: \(no)")
}
}
.padding(8)
.border(Color.red)
.frame(alignment: .center)
}
.border(Color.blue)
.padding(8)
// }
}
Credit goes to #Thaniel for finding the solution. My intention here is to more fully explain what is happening behind the scenes to demystify SwiftUI and explain why the solution works.
Solution
Wrap the ScrollView inside a GeometryReader so that you can set the minimum height (or width if the scroll view is horizontal) of the scrollable content to match the height of the ScrollView. This will make it so that the dimensions of the scrollable area are never smaller than the dimensions of the ScrollView. You can also declare a static dimension and use it to set the height of both the ScrollView and its content.
Dynamic Height
#State private var count : Int = 5
var body: some View {
// use GeometryReader to dynamically get the ScrollView height
GeometryReader { geometry in
ScrollView {
VStack(alignment: .leading) {
ForEach(0...self.count, id: \.self) { num in
Text("entry: \(num)")
}
}
.padding(10)
// border is drawn before the height is changed
.border(Color.red)
// match the content height with the ScrollView height and let the VStack center the content
.frame(minHeight: geometry.size.height)
}
.border(Color.blue)
}
}
Static Height
#State private var count : Int = 5
// set a static height
private let scrollViewHeight: CGFloat = 800
var body: some View {
ScrollView {
VStack(alignment: .leading) {
ForEach(0...self.count, id: \.self) { num in
Text("entry: \(num)")
}
}
.padding(10)
// border is drawn before the height is changed
.border(Color.red)
// match the content height with the ScrollView height and let the VStack center the content
.frame(minHeight: scrollViewHeight)
}
.border(Color.blue)
}
The bounds of the content appear to be smaller than the ScrollView as shown by the red border. This happens because the frame is set after the border is drawn. It also illustrates the fact that the default size of the content is smaller than the ScrollView.
Why Does it Work?
ScrollView
First, let's understand how SwiftUI's ScrollView works.
ScrollView wraps it's content in a child element called ScrollViewContentContainer.
ScrollViewContentContainer is always aligned to the top or leading edge of the ScrollView depending on whether it is scrollable along the vertical or horizontal axis or both.
ScrollViewContentContainer sizes itself according to the ScrollView content.
When the content is smaller than the ScrollView, ScrollViewContentContainer pushes it to the top or leading edge.
Center Align
Here's why the content gets centered.
The solution relies on forcing the ScrollViewContentContainer to have the same width and height as its parent ScrollView.
GeometryReader can be used to dynamically get the height of the ScrollView or a static dimension can be declared so that both the ScrollView and its content can use the same parameter to set their horizontal or vertical dimension.
Using the .frame(minWidth:,minHeight:) method on the ScrollView content ensures that it is never smaller than the ScrollView.
Using a VStack or HStack allows the content to be centered.
Because only the minimum height is set, the content can still grow larger than the ScrollView if needed, and ScrollViewContentContainer retains its default behavior of aligning to the top or leading edge.
You observe just normal ScrollView behaviour. Here is a demo of possible approach to achieve your goal.
// view pref to detect internal content height
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
// extension for modifier to detect view height
extension ViewHeightKey: ViewModifier {
func body(content: Content) -> some View {
return content.background(GeometryReader { proxy in
Color.clear.preference(key: Self.self, value: proxy.size.height)
})
}
}
// Modified your view for demo
struct TestAdjustedScrollView: View {
#State private var count : Int = 100
#State private var myHeight: CGFloat? = nil
var body : some View {
GeometryReader { gp in
ScrollView {
VStack {
Button(action: {
if self.count > 99 {
self.count = 5
} else {
self.count = 100
}
}) {
Text("CLICK")
}
ForEach(0...self.count, id: \.self) { no in
Text("entry: \(no)")
}
}
.padding(8)
.border(Color.red)
.frame(alignment: .center)
.modifier(ViewHeightKey()) // read content view height !!
}
.onPreferenceChange(ViewHeightKey.self) {
// handle content view height
self.myHeight = $0 < gp.size.height ? $0 : gp.size.height
}
.frame(height: self.myHeight) // align own height with content
.border(Color.blue)
.padding(8)
}
}
}
The frame(alignment: .center) modifier you’ve added doesn’t work since what it does is wrapping your view in a new view of exactly the same size. Because of that the alignment doesn’t do anything as there is no additional room for the view do be repositioned.
One potential solution for your problem would be to wrap the whole ScrollView in a GeometryReader to read available height. Then use that height to specify that the children should not be smaller than it. This will then make your view centered inside of ScrollView.
struct ContentView: View {
#State private var count : Int = 100
var body : some View {
GeometryReader { geometry in
ScrollView {
VStack {
Button(action: {
if self.count > 99 {
self.count = 5
} else {
self.count = 100
}
}) {
Text("CLICK")
}
ForEach(0...self.count, id: \.self) { no in
Text("entry: \(no)")
}
}
.padding(8)
.border(Color.red)
.frame(minHeight: geometry.size.height) // Here we are setting minimum height for the content
}
.border(Color.blue)
}
}
}
For me, GeometryReader aligned things to the top no matter what. I solved it with adding two extra Spacers (my code is based on this answer):
GeometryReader { metrics in
ScrollView {
VStack(spacing: 0) {
Spacer()
// your content goes here
Spacer()
}
.frame(minHeight: metrics.size.height)
}
}

Make parent view's width the width of the smallest child?

How can I make a parent view's width the width of the smallest child?
I can learn the width of the child view using the answer to this question like so:
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().fill(Color.clear).preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
struct Content: View {
#State var width1: CGFloat = 0
#State var width2: CGFloat = 0
var body: some View {
VStack {
Text("Short")
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.width1 = $0 })
Text("Loooooooooooong")
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.width2 = $0 })
}
.frame(width: min(self.width1, self.width2)) // This is always 0
}
}
But the VStack has always width 0. It's like as if
the line .frame(width: min(self.width1, self.width2)) is being called
before the widths are set.
Any ideas?
This is a logic bug, in that the system is technically doing exactly what you've told it to, but that result is not what you, as the programmer, intended.
Basically, VStack wants to shrink as much as possible to fit the size of its content. Likewise, Text views are willing to shrink as much as possible (truncating their contents) to fit inside their parent view. The only hard requirement you've given is that the initial frame of the VStack have width of 0. So the following sequence happens:
width1 and width2 are initialized to 0
The VStack sets its width to min(0, 0)
The Text views inside shrink to width 0
WidthPreferenceKey.self is set to 0
.onPreferenceChange sets width1 and width2, which are already 0
All the constraints are satisfied, and SwiftUI happily stops layout.
Let's modify your code with the following:
Make WidthPreferenceKey.Value a typealias to [CGFloat] instead of CGFloat. This way, you can set as many preference key setters as you want, and they will just keep accumulating into an array.
Use one .onPreferenceChange call, which will find the minimum of all the read values, and set the single #State var width: CGFloat property
Add .fixedSize() to the Text views.
Like so:
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = [CGFloat]()
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
typealias Value = [CGFloat]
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle()
.fill(Color.clear)
.preference(key: WidthPreferenceKey.self,
value: [geometry.size.width])
}
}
}
struct Content: View {
#State var width: CGFloat = 0
var body: some View {
VStack {
Text("Short")
.fixedSize()
.background(TextGeometry())
Text("Loooooooooooong")
.fixedSize()
.background(TextGeometry())
}
.frame(width: self.width)
.clipped()
.background(Color.red.opacity(0.5))
.onPreferenceChange(WidthPreferenceKey.self) { preferences in
print(preferences)
self.width = preferences.min() ?? 0
}
}
}
Now the following happens:
width is initialized to 0
The VStack sets its width to 0
The Text views expand outside the VStack, since we've given permission with .fixedSize()
WidthPreferenceKey.self is set to [42.5, 144.5] (or something close)
.onPreferenceChange sets width to 42.5
The VStack sets its width to 42.5 to satisfy its .frame() modifier.
All constraints are satisfied, and SwiftUI stops layout. Note that .clipped() is keeping the edges of the long Text view from displaying, even though they are technically outside the bounds of the VStack.

How to use GeometryReader to achieve a table like layout?

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)
}
}