NavigationView + GeometryReader views return incorrect size - swift

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

Related

List Views overlapping SwiftUI

Recently, i had been using a LazyVStack to load views Lazy from dynamic data however as you may know, when the Views get complex, it can cause major performance issues and so ive started using a List however on the iOS 15 simulator, it seems to mess up and causes the views to overlap whereas on my normal device everything is just fine, any help as to what i could be doing wrong?
Struct HomeView: View {
var body: some View {
GeometryReader { geometry in
List {
getTopView(geometry).anyView
.listRowBackground(Color.clear)
.listRowInsets(.init())
.padding(5)
ForEach(getDataViews) { view in
view.anyView
.listRowBackground(Color.clear)
.listRowInsets(.init())
}
Color.clear
.id("bottom-2")
.frame(height: bottomInsetHeight() + 100)
.listRowBackground(Color.clear)
}
.listStyle(.plain)
.overlay(NavigationBar(title: "Home"))
.overlay(ZStack {
...
}
)
}
}
}
And then in the getTopView method I just have a vstack within a custom struct named DataView, which just contains an AnyView and id Property(I did this to try resolve the issue with lists as i thought it was an issue with Ids but same effect)
func getTopView(_ geometry: GeometryProxy) -> DataView {
return DataView(anyView: AnyView(
VStack {
...
}
)
}
The actual data(posts), are loaded in a computed property returning an Array of DataView:
var getDataViews: [DataView] {
var anyViews: [DataView] = []
anyViews = viewModel.events.map { event in
return DataView(anyView: AnyView(
ZStack {
EventCell(event: event)
.padding()
.padding(.bottom)
Navigator.navigate(route: .eventDetail(event)) {
Rectangle()
}
.opacity(0)
}
)
)
}
return anyViews
}
The Data view struct:
struct DataView: Identifiable {
var id: String = UUID().uuidString
var anyView: AnyView
}
If anyone is wondering what the geometry reader is for, it is being used in presenting a 'stories', i doubt it could be causing the issue
Yes I tried putting sections as well, same effect

LazyVStack - row onAppear is called early

I have a LazyVStack, with lots of rows. Code:
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0 ..< 100) { i in
Text("Item: \(i + 1)")
.onAppear {
print("Appeared:", i + 1)
}
}
}
}
}
}
Only about 40 rows are visible on the screen initially, yet onAppear is triggered for 77 rows. Why is this, why is it called before it is actually visible on the screen? I don't see why SwiftUI would have to 'preload' them.
Is there a way to fix this, or if this is intended, how can I accurately know the last visible item (accepting varying row heights)?
Edit
The documentation for LazyVStack states:
The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.
So this must be a bug then, I presume?
By words from the documentation, onAppear shouldn't be like this:
The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.
However, if you are having problems getting this to work properly, see my solution below.
Although I am unsure why the rows onAppears are triggered early, I have created a workaround solution. This reads the geometry of the scroll view bounds and the individual view to track, compares them, and sets whether it is visible or not.
In this example, the isVisible property changes when the top edge of the last item is visible in the scroll view's bounds. This may not be when it is visible on screen, due to safe area, but you can change this to your needs.
Code:
struct ContentView: View {
#State private var isVisible = false
var body: some View {
GeometryReader { geo in
ScrollView {
LazyVStack {
ForEach(0 ..< 100) { i in
Text("Item: \(i + 1)")
.background(tracker(index: i))
}
}
}
.onPreferenceChange(TrackerKey.self) { edge in
let isVisible = edge < geo.frame(in: .global).maxY
if isVisible != self.isVisible {
self.isVisible = isVisible
print("Now visible:", isVisible ? "yes" : "no")
}
}
}
}
#ViewBuilder private func tracker(index: Int) -> some View {
if index == 99 {
GeometryReader { geo in
Color.clear.preference(
key: TrackerKey.self,
value: geo.frame(in: .global).minY
)
}
}
}
}
struct TrackerKey: PreferenceKey {
static let defaultValue: CGFloat = .greatestFiniteMagnitude
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
It works as per my comments above.
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0 ..< 100) { i in
Text("Item: \(i + 1)")
.id(i)
.frame(width: 100, height: 100)
.padding()
.onAppear { print("Appeared:", i + 1) }
}
}
}
}
}
It seems incredible but just adding a GeometryReader containing your ScrollView would resolve the issue
GeometryReader { _ in
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 14) {
Text("Items")
LazyVStack(spacing: 16) {
ForEach(viewModel.data, id: \.id) { data in
MediaRowView(data: data)
.onAppear {
print(data.title, "item appeared")
}
}
if viewModel.state == .loading {
ProgressView()
}
}
}
.padding(.horizontal, 16)
}
}

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

Get width of a view using in SwiftUI

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!