Get width of a view using in SwiftUI - swift

I need to get width of a rendered view in SwiftUI, which is apparently not that easy.
The way I see it is that I need a function that returns a view's dimensions, simple as that.
var body: some View {
VStack(alignment: .leading) {
Text(timer.name)
.font(.largeTitle)
.fontWeight(.heavy)
Text(timer.time)
.font(.largeTitle)
.fontWeight(.heavy)
.opacity(0.5)
}
}

The only way to get the dimensions of a View is by using a GeometryReader. The reader returns the dimensions of the container.
What is a geometry reader? the documentation says:
A container view that defines its content as a function of its own size and coordinate space. Apple Doc
So you could get the dimensions by doing this:
struct ContentView: View {
#State var frame: CGSize = .zero
var body: some View {
HStack {
GeometryReader { (geometry) in
self.makeView(geometry)
}
}
}
func makeView(_ geometry: GeometryProxy) -> some View {
print(geometry.size.width, geometry.size.height)
DispatchQueue.main.async { self.frame = geometry.size }
return Text("Test")
.frame(width: geometry.size.width)
}
}
The printed size is the dimension of the HStack that is the container of inner view.
You could potentially using another GeometryReader to get the inner dimension.
But remember, SwiftUI is a declarative framework. So you should avoid calculating dimensions for the view:
read this to more example:
Make a VStack fill the width of the screen in SwiftUI
How to make view the size of another view in SwiftUI

Getting the dimensions of a child view is the first part of the task. Bubbling the value of dimensions up is the second part. GeometryReader gets the dims of the parent view which is probably not what you want. To get the dims of the child view in question we might call a modifier on its child view which has actual size such as .background() or .overlay()
struct GeometryGetterMod: ViewModifier {
#Binding var rect: CGRect
func body(content: Content) -> some View {
print(content)
return GeometryReader { (g) -> Color in // (g) -> Content in - is what it could be, but it doesn't work
DispatchQueue.main.async { // to avoid warning
self.rect = g.frame(in: .global)
}
return Color.clear // return content - doesn't work
}
}
}
struct ContentView: View {
#State private var rect1 = CGRect()
var body: some View {
let t = HStack {
// make two texts equal width, for example
// this is not a good way to achieve this, just for demo
Text("Long text").overlay(Color.clear.modifier(GeometryGetterMod(rect: $rect1)))
// You can then use rect in other places of your view:
Text("text").frame(width: rect1.width, height: rect1.height).background(Color.green)
Text("text").background(Color.yellow)
}
print(rect1)
return t
}
}
Here is another convenient way to get and do something with the size of current view: readSize function.
extension View {
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
Usage:
struct ContentView: View {
#State private var commonSize = CGSize()
var body: some View {
VStack {
Text("Hello, world!")
.padding()
.border(.yellow, width: 1)
.readSize { textSize in
commonSize = textSize
}
Rectangle()
.foregroundColor(.yellow)
.frame(width: commonSize.width, height: commonSize.height)
}
}
}

There's a much simpler way to get the width of a view using GeometryReader. You need to create a state variable to store the width, then surround the desired view with a GeometryReader, and set the width value to the geometry inside that width. For instace:
struct ContentView: View {
#State var width: CGFloat = 0.00 // this variable stores the width we want to get
var body: some View {
VStack(alignment: .leading) {
GeometryReader { geometry in
Text(timer.name)
.font(.largeTitle)
.fontWeight(.heavy)
.onAppear {
self.width = geometry.size.width
print("text width: \(width)") // test
}
} // in this case, we are reading the width of text
Text(timer.time)
.font(.largeTitle)
.fontWeight(.heavy)
.opacity(0.5)
}
}
}
Note that the width will change if the target's view also changes. If you want to store it, I would suggest using a let constant somewhere else. Hope that helps!

Related

SwiftUI's anchorPreference collapses height of its view

I am using SwiftUI and I am trying to pass up the height from a subview up to its parent view. It’s my understanding to use something like PreferenceKey along with .anchorPreference and then act on the change using .onPreferenceChange.
However, due to the lack of documentation on Apple’s end, I am not sure if I am using this correctly or if this is a bug with the framework perhaps.
Essentially, I want a view that can grow or shrink based on its content, however, I want to cap its size, so it doesn’t grow past, say 300 pts vertically. After that, any clipped content will be accessible via its ScrollView.
The issue is that the value is always zero for height, but I get correct values for the width.
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
VStack {
GeometryReader { geometry in
VStack(alignment: .leading) {
content()
}
.padding(.top, 10)
.padding([.leading, .bottom, .trailing], 20)
.anchorPreference(key: SizePreferenceKey.self, value: .bounds, transform: { geometry[$0].size })
}
}
.onPreferenceChange(SizePreferenceKey.self) { self.contentHeight = $0.height }
When you want to get size of content then you need to read it from inside content instead of outside parent available space... in your case you could do this (as content itself is unknown) from content's background, like
VStack(alignment: .leading) {
content()
}
.padding(.top, 10)
.padding([.leading, .bottom, .trailing], 20)
.background(GeometryReader { geometry in
Color.clear
.anchorPreference(key: SizePreferenceKey.self, value: .bounds, transform: { geometry[$0].size })
})
.onPreferenceChange(SizePreferenceKey.self) { self.contentHeight = $0.height }
Note: content() should have determined size from itself, otherwise you'll get chicken-egg problem in ScrollView
Unfortunately, there seems to be no easy solution for this. I came up with this:
Anchors are partial complete values and require a GeometryProxy to return a value. That is, you create an anchor value - say a bounds property - for any child view (whose value is incomplete at this time). Then you can get the actual bounds value relative to a given geometry proxy only when you have that proxy.
With onPreferenceChange you don't get a geometry proxy, though. You need to use backgroundPreferenceValue or overlayPreferenceValue.
The idea would be now, to use backgroundPreferenceValue, create a geometry proxy and use this proxy to relate your "bounds" anchors that have been created for each view in your scroll view content and which have been collected with an appropriate preference key, storing anchor bounds values in an array. When you have your proxy and the anchors (view bounds) you can calculate the actual bounds for each view relative to your geometry proxy - and this proxy relates to your ScrollView.
Then with backgroundPreferenceValue we could set the frame of the background view of the ScrollView. However, there's a catch:
The problem with a ScrollView is, that you cannot set the background and expect the scroll view sets its frame accordingly. That won't work.
The solution to this is using a #State variable containing the height of the content, respectively the max height. It must be set somehow when the bounds are available. This is in backgroundPreferenceValue, however, we cannot set this state property directly, since we are in the view "update phase". We can workaround this problem by just using onAppear where we can set a state property.
The state property "height" can then be used to set the frame of the ScrollView directly using the frame modifier.
See code below:
Xcode Version 13.0 beta 4:
import SwiftUI
struct ContentView: View {
let labels = (0...1).map { "- \($0) -" }
//let labels = (0...9).map { "- \($0) -" }
#State var height: CGFloat = 0
var body: some View {
HStack {
ScrollView {
ForEach(labels, id: \.self) {
Text($0)
.anchorPreference(
key: ContentFramesStorePreferenceKey.self,
value: .bounds,
transform: { [$0] })
}
}
}
.frame(height: height)
.backgroundPreferenceValue(ContentFramesStorePreferenceKey.self) { anchors in
GeometryReader { proxy in
let boundss: [CGRect] = anchors.map { proxy[$0] }
let bounds = boundss.reduce(CGRect.zero) { partialResult, rect in
partialResult.union(rect)
}
let maxHeight = min(bounds.height, 100)
Color.red.frame(width: proxy.size.width, height: maxHeight)
.onAppear {
self.height = maxHeight
}
}
}
}
}
fileprivate struct ContentFramesStorePreferenceKey: PreferenceKey {
typealias Value = [Anchor<CGRect>]
static var defaultValue: Value = []
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(
NavigationView {
ContentView()
}
.navigationViewStyle(.stack)
)

GeometryReader with NavigationView in SwiftUI is initially giving .zero for size

I have a GeometryReader in a NavigationView and initially the size is 0 when the view first displayed. I'm not sure if it's a bug or the correct behavior but I'm looking for a way to solve this as my child views are not rendering correctly.
This struct demonstrates the problem.
This printout from below is: (0.0, 0.0) for size.
Is there anyway to force the NavigationView to provide correct geometry when initially displayed?
struct ContentView: View {
var body: some View {
NavigationView {
GeometryReader { geometry in
Text("Geometry Size Is Wrong")
.onAppear {
print(geometry.size) // prints out (0.0, 0.0)
}
}
}
}
}
Unfortunately, I don't think there's anything you can do to make NavigationView provide the correct geometry when initially displayed.
But if you do want access to the final geometry.size from within your view, you can use onChange(of:) as New Dev suggested:
struct ContentView: View {
#State var currentSize: CGSize?
var body: some View {
NavigationView {
GeometryReader { geometry in
Text("currentSize will soon be correct")
.onChange(of: geometry.size) { newSize in
currentSize = newSize
print(currentSize!) // prints (320.0, 457.0)
}
}
}
}
}
The above will work fine for many cases, but note that any local variables computed from geometry.size within the GeometryReader's subviews will not be accurate in the onChange block (it will capture the original, wrong value):
struct ContentView: View {
#State var currentSize: CGSize?
#State var halfWidth: CGFloat?
var body: some View {
NavigationView {
GeometryReader { geometry in
let halfWidthLocal = geometry.size.width / 2
Text("Half Width is really: \(halfWidthLocal)") // will read as "Half Width is really 160.000000"
.onChange(of: geometry.size) { newSize in
currentSize = newSize
halfWidth = halfWidthLocal
print(currentSize!) // prints (320.0, 457.0)
print(halfWidth!) // prints 0.0
}
}
}
}
}
In order to update state properties using the most up-to-date version of local variables, you can instead update the properties within a function that returns a view in your GeometryReader:
struct ContentView: View {
#State var currentSize: CGSize?
#State var halfWidth: CGFloat?
var body: some View {
NavigationView {
GeometryReader { geometry in
let halfWidthLocal = geometry.size.width / 2
makeText(halfWidthLocal: halfWidthLocal)
.onChange(of: geometry.size) { newSize in
currentSize = newSize
print(currentSize!) // prints (320.0, 457.0)
}
}
}
}
func makeText(halfWidthLocal: CGFloat) -> some View {
DispatchQueue.main.async { // Must update state properties on the main queue
halfWidth = halfWidthLocal
print(halfWidth!) // prints 0.0 the first time, then 160.0 the second time
}
return Text("Half Width is really: \(halfWidthLocal)") // will read as "Half Width is really 160.000000"
}
}
This type of situation came up for me, so just thought I'd pass on the knowledge to others.

How can I make my CustomView returns View plus some more extra data in SwiftUI?

I want build a CustomView that it works almost the same as like GeometryReader in functionality, I do not want re build the existed GeometryReader, I want use it to show case of my goal, for example I created this CustomView which reads the Size of content, I want my CustomView could be able send back that read Value of size in form of closure as we seen often in Swift or SwiftUI,
My Goal: I am trying to receive Size of View, which has been read in CustomView and saved in sizeOfText in my parent/ContentView View as form of closure.
Ps: I am not interested to Binding or using ObservableObject for this issue, the question try find the answer in way of sending back data as Closure form.
import SwiftUI
struct ContentView: View {
var body: some View {
CustomView { size in // <<: Here
Text("Hello, world!")
.background(Color.yellow)
.onAppear() {
print("read size is:", size.debugDescription)
}
.onChange(of: size) { newValue in
print("read size is:", newValue.debugDescription)
}
}
}
}
struct CustomView<Content: View>: View {
#State private var sizeOfText: CGSize = CGSize()
var content: () -> Content
var body: some View {
return content()
.background(
GeometryReader { geometry in
Color.clear.onAppear() { sizeOfText = geometry.size }
})
}
}
Specifiy the type of content as CGSize and then pass sizeOfText to content.
If you wish to learn more about closure, visit swift Doc.
https://docs.swift.org/swift-book/LanguageGuide/Closures.html
import SwiftUI
struct CustomView<Content: View>: View {
#State private var sizeOfText: CGSize = CGSize()
var content: (CGSize) -> Content
var body: some View {
return content(sizeOfText)
.background(
GeometryReader { geometry in
Color.clear.onAppear() { sizeOfText = geometry.size }
})
}
}
struct ContentView: View {
var body: some View {
CustomView { size in
Text("Hello, world!")
.background(Color.yellow)
.onAppear() {
print("read size is:", size.debugDescription)
}
}
}
}
You can specify the type in the content closure like this: var content: (_ size: CGFloat) -> Content
And then you can call the closure with your desired value. The value can also be #State in CustomView.
struct ContentView1: View {
var body: some View {
CustomView { size in // <-- Here
Text("Hello, world!")
.background(Color.yellow)
.onAppear() {
// print("read size is:", size.debugDescription)
}
}
}
}
struct CustomView<Content: View>: View {
#State private var sizeOfText: CGSize = CGSize()
var content: (_ size: CGFloat) -> Content // <-- Here
var body: some View {
return content(10)
.background(
GeometryReader { geometry in
Color.clear.onAppear() { sizeOfText = geometry.size }
})
}
}

NavigationView + GeometryReader views return incorrect size

For:
struct ContentView: View {
var body: some View {
NavigationView {
GeometryReader { geometry -> Text in
print("geometry.size \(geometry.size)")
return Text("text")
}
}
}
}
Output:
geometry.size (0.0, 0.0)
geometry.size (428.0, 749.0)
For:
struct ContentView: View {
var body: some View {
GeometryReader { geometry -> Text in
print("geometry.size \(geometry.size)")
return Text("text")
}
}
}
Output:
geometry.size (428.0, 749.0)
Is it a bug? Is it possible to avoid such behavior?
I don't believe this is a bug; any view has the right to reposition its subviews over its lifetime. Normally this behavior should make no difference to you, as the views will automatically get updated as the geometry changes.
However, if there is some logic you need done right when the geometry.size is updated to a non-zero value, you can monitor this in an onChange(of:) block:
struct ContentView: View {
var body: some View {
NavigationView {
GeometryReader { geometry in
Text("text")
.onChange(of: geometry.size) { newSize in
if newSize != .zero { /* do something with newSize */ }
}
}
}
}
}
I talk more about this behavior and ways to work around it in this other stackoverflow article

SwiftUI - Using GeometryReader Without Modifying The View Size

I have a header view which extends its background to be under the status bar using edgesIgnoringSafeArea. To align the content/subviews of the header view correctly, I need the safeAreaInsets from GeometryReader. However, when using GeometryReader, my view doesn't have a fitted size anymore.
Code without using GeometryReader
struct MyView : View {
var body: some View {
VStack(alignment: .leading) {
CustomView()
}
.padding(.horizontal)
.padding(.bottom, 64)
.background(Color.blue)
}
}
Preview
Code using GeometryReader
struct MyView : View {
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading) {
CustomView()
}
.padding(.horizontal)
.padding(.top, geometry.safeAreaInsets.top)
.padding(.bottom, 64)
.background(Color.blue)
.fixedSize()
}
}
}
Preview
Is there a way to use GeometryReader without modifying the underlying view size?
Answer to the question in the title:
It is possible to wrap the GeometryReader in an .overlay() or .background(). Doing so will mitigate the layout changing effect of GeometryReader. The view will be laid out as normal, the GeometryReader will expand to the full size of the view and emit the geometry into its content builder closure.
It's also possible to set the frame of the GeometryReader to stop its eagerness in expanding.
For example, this example renders a blue rectangle, and a "Hello world" text inside at 3/4th the height of the rectangle (instead of the rectangle filling up all available space) by wrapping the GeometryReader in an overlay:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.overlay(GeometryReader { geo in
Text("Hello world").padding(.top, geo.size.height * 3 / 4)
})
Spacer()
}
}
Another example to achieve the same effect by setting the frame on the GeometryReader:
struct MyView : View {
var body: some View {
GeometryReader { geo in
Rectangle().fill(Color.blue)
Text("Hello world").padding(.top, geo.size.height * 3 / 4)
}
.frame(height: 150)
Spacer()
}
}
However, there are caveats / not very obvious behaviors
1
View modifiers apply to anything up to the point that they are applied, and not to anything after. An overlay / background that is added after .edgesIgnoringSafeArea(.all) will respect the safe area (not participate in ignoring the safe area).
This code renders "Hello world" inside the safe area, while the blue rectangle ignores the safe area:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.edgesIgnoringSafeArea(.all)
.overlay(VStack {
Text("Hello world")
Spacer()
})
Spacer()
}
}
2
Applying .edgesIgnoringSafeArea(.all) to the background makes GeometryReader ignore the SafeArea:
struct MyView : View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(height: 150)
.overlay(GeometryReader { geo in
VStack {
Text("Hello world")
// No effect, safe area is set to be ignored.
.padding(.top, geo.safeAreaInsets.top)
Spacer()
}
})
.edgesIgnoringSafeArea(.all)
Spacer()
}
}
It is possible to compose many layouts by adding multiple overlays / backgrounds.
3
A measured geometry will be available to the content of the GeometryReader. Not to parent or sibling views; even if the values are extracted into a State or ObservableObject. SwiftUI will emit a runtime warning if that happens:
struct MyView : View {
#State private var safeAreaInsets = EdgeInsets()
var body: some View {
Text("Hello world")
.edgesIgnoringSafeArea(.all)
.background(GeometryReader(content: set(geometry:)))
.padding(.top, safeAreaInsets.top)
Spacer()
}
private func set(geometry: GeometryProxy) -> some View {
self.safeAreaInsets = geometry.safeAreaInsets
return Color.blue
}
}
I tried with the previewLayout and I see what you mean. However, I think the behavior is as expected. The definition of .sizeThatFits is:
Fit the container (A) to the size of the preview (B) when offered the
size of the device (C) on which the preview is running.
I intercalated some letters to define each part and make it more clear:
A = the final size of the preview.
B = The size of what you are modifying with .previewLayout(). In the first case, it's the VStack. But in the second case, it's the GeometryReader.
C = The size of the screen of the device.
Both views act differently, because VStack is not greedy, and only takes what it needs. GeometryReader, on the other side, tries to have it all, because it does not know what its child will want to use. If the child wants to use less, it can do it, but it has to start by being offered everything.
Perhaps if you edit your question to explain exactly what you would like to accomplish, I can refine my answer a little.
If you would like GeometryReader to report the size of the VStack. you may do so by putting it inside a .background modifier. But again, I am not sure what's the goal, so maybe that's a no go.
I have written an article about the different uses of GeometryReader. Here's the link, in case it helps: https://swiftui-lab.com/geometryreader-to-the-rescue/
UPDATE
Ok, with your additional explanation, here you have a working solution. Note that the Preview will not work, because safeInsets are reported as zero. On the simulator, however, it works fine:
As you will see, I use view preferences. They are not explained anywhere, but I am currently writing an article about them that I will post soon.
It may all look too verbose, but if you find yourself using it too often, you can encapsulate it inside a custom modifier.
import SwiftUI
struct InsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct InsetGetter: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().preference(key: InsetPreferenceKey.self, value: geometry.safeAreaInsets.top)
}
}
}
struct ContentView : View {
var body: some View {
MyView()
}
}
struct MyView : View {
#State private var topInset: CGFloat = 0
var body: some View {
VStack {
CustomView(inset: topInset)
.padding(.horizontal)
.padding(.bottom, 64)
.padding(.top, topInset)
.background(Color.blue)
.background(InsetGetter())
.edgesIgnoringSafeArea(.all)
.onPreferenceChange(InsetPreferenceKey.self) { self.topInset = $0 }
Spacer()
}
}
}
struct CustomView: View {
let inset: CGFloat
var body: some View {
VStack {
HStack {
Text("C \(inset)").color(.white).fontWeight(.bold).font(.title)
Spacer()
}
HStack {
Text("A").color(.white)
Text("B").color(.white)
Spacer()
}
}
}
}
I managed to solve this by wrapping the page main view inside a GeometryReader and pass down the safeAreaInsets to MyView. Since it is the main page view where we want the entire screen thus it is ok to be as greedy as possible.