How can we make a Custom GeometryReader in SwiftUI? - swift

I was wondering how GeometryReader works under cover, and I am interested to build a custom GeometryReader for learning purpose!
Frankly I think every single view that we use in body is kind of GeometryReaderView with this difference that they do not use a closure for sending the proxy for us and it would annoying that every single view call back it's proxy! Therefore apple decided to give Geometry reading function to GeometryReader! So it is just my thoughts!
So I am looking a possible and also more likely SwiftUI-isch approach to reading proxy of view, or in other words see my codes in down:
struct ContentView: View {
var body: some View {
CustomGeometryReaderView { proxy in
Color.red
.onAppear() {
print(proxy)
}
}
}
}
struct CustomGeometryReaderView<Content: View>: View {
#ViewBuilder let content: (CGSize) -> Content
var body: some View {
// Here I most find a way to reading the available size for CustomGeometryReaderView and reporting it back!
return Color.clear.overlay(content(CGSize(width: 100.0, height: 100.0)), alignment: .topLeading)
}
}
Also I know that reading and reporting proxy of a view is not just the size of view, also it is about CoordinateSpace, frame ... But for now for making things easier to solve I am just working on size! So size matter!
As I said I am not interested to working with UIKit or UIViewRepresentable for reading the size! May apple using something like that under cover or may not!
My goal was trying solve the issue with pure SwiftUI or may some of you have some good link about source code of GeometryReader for reading and learning of it.

Ok, there are several instruments in SwiftUI providing access to view size (except GeometryReader of course).
The problem of course is to transfer that size value into view build phase, because only GeometryReader allows to do it in same build cycle.
Here is a demo of possible approach using Shape - a shape by design has no own size and consumes everything available, so covers all area, and has been provided that area rect as input.
Tested with Xcode 13 / iOS 15
struct CustomGeometryReaderView<Content: View>: View {
#ViewBuilder let content: (CGSize) -> Content
private struct AreaReader: Shape {
#Binding var size: CGSize
func path(in rect: CGRect) -> Path {
DispatchQueue.main.async {
size = rect.size
}
return Rectangle().path(in: rect)
}
}
#State private var size = CGSize.zero
var body: some View {
// by default shape is black so we need to clear it explicitly
AreaReader(size: $size).foregroundColor(.clear)
.overlay(Group {
if size != .zero {
content(size)
}
})
}
}
Alternate: same, but using callback-based pattern
struct CustomGeometryReaderView<Content: View>: View {
#ViewBuilder let content: (CGSize) -> Content
private struct AreaReader: Shape {
var callback: (CGSize) -> Void
func path(in rect: CGRect) -> Path {
callback(rect.size)
return Rectangle().path(in: rect)
}
}
#State private var size = CGSize.zero
var body: some View {
AreaReader { size in
if size != self.size {
DispatchQueue.main.async {
self.size = size
}
}
}
.foregroundColor(.clear)
.overlay(Group {
if size != .zero {
content(size)
}
})
}
}

Whenever I need to know the frame of a view, GeometryReader is just unnecessarily complicated and the 'Bound preference tried to update multiple times per frame' warnings are horrible. So I decided to create CustomGeometryReader

Related

how to better implement reverse infinite scroll for conversation like screens

In a chat-like app, the newest messages are shown at the bottom of the list. As you scroll up, the older messages are loaded and displayed, similar to the endless scrolling we’re accustomed to.
There are several options available to match this use case:
The first option is to use a List and invert it 180 degrees.
Another option is to use a ScrollView with a LazyVStack and invert them, similar to the List approach.
Another approach would be to cheat and fall back to the well-tested UIKit solutions.
Since SwiftUI is the future, I decided against the UIKit options (unless essential). and went for the ScrollView/LazyVStack option.
The problem is that when the items are prepended to the list, the ScrollView start-position (offset) is always the first item of the prepended list.
I cannot think of a non-hacky solution to force the ScrollView to stick with its initial offset (help is appreciated).
attaching an example code of both my reversed ScrollView and main ChatView screen.
struct ReversedScrollView<Content: View>: View {
var content: Content
init(#ViewBuilder builder: () -> Content) {
self.axis = axis
self.content = builder()
}
var body: some View {
GeometryReader { proxy in
ScrollView(axis) {
VStack {
Spacer()
content
}
.frame(
minWidth: minWidth(in: proxy, for: axis),
minHeight: minHeight(in: proxy, for: axis)
)
}
}
}
}
public struct ChatView: View {
public var body: some View {
GeometryReader { geometry in
messagesView(in: geometry)
}
}
#ViewBuilder private func messagesView(in geometry: GeometryProxy) -> some View {
ReversedScrollView {
ScrollViewReader { proxy in
LazyVStack {
ForEach(messages) { message in
ChatMessageCellContainer(
message: message,
size: geometry.size,
)
.id(message.id)
}
}
}
}
}
}

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

Size of View in SwiftUI

Here I am reading my View Size in a background process, everything works fine except than sending the un-wished value!
In this down code, I used (CGSize) -> Content, and It send the exist value of CGSize, which means CGSize() or (0.0, 0.0). Which it make sense because it is first finable value, but as you see in the codes I am calculating the needed value and I want send that value.
my Goal: how can I send my (CGSize) -> Content which CGSize is calculated.
PS: I am thinking using completionhandler on CGSize.
Something like this but I am not sure:
var content: (#escaping () -> CGSize) -> Content
or even this:
var content: (#escaping (Content) -> CGSize) -> Content
In this way that it waits until CGSize get calculated then sent it with Content together, as you can see in (CGSize) -> Content it capture first possible CGSize!
Console:
(94.66666666666666, 20.333333333333332)
read size onAppear is: (0.0, 0.0)
read size onChange is: (94.66666666666666, 20.333333333333332)
import SwiftUI
struct ContentView: View {
var body: some View {
CustomView { size in
Text("Hello, world!")
.background(Color.yellow)
.onAppear() {
print("read size onAppear is:", size.debugDescription)
}
.onChange(of: size) { newValue in
print("read size onChange is:", newValue.debugDescription)
}
}
}
}
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; print(sizeOfText) }
})
}
}
I could be able to find the magical answer:
extension View {
func sizeReader(size: #escaping (CGSize) -> Void) -> some View {
return self
.background(
GeometryReader { geometry in
Color.clear
.preference(key: ContentSizeReaderPreferenceKey.self, value: geometry.size)
.onPreferenceChange(ContentSizeReaderPreferenceKey.self) { newValue in size(newValue) }
}
.hidden()
)
}
}
struct ContentSizeReaderPreferenceKey: PreferenceKey {
static var defaultValue: CGSize { get { return CGSize() } }
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() }
}
Use case:
You can use .sizeReader on any View you like to read or save the Size of View. It is reverse GeometryReader, and I can say it make a significant difference in your app or project when you need to read or work with the size of a View! Look how much would be cleaner and easier using .sizeReader in your project.
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.sizeReader { size in print(size) }
}
}

With SwiftUI, is there a way to constrain a view's size to another non-sibling view?

I'm fiddling with a view layout - a graph - where I'm seeing how far I can get within an all-SwiftUI layout. Each of the component pieces are fine, but assembling the whole isn't working quite as I'd like. What I've found is that I can easily constrain sizing along a single stack axis - but not two axis at once (both vertical and horizontal).
I started to reach for AlignmentGuides, as I found you can align non-siblings with a custom guide. That will help my goal, but it doesn't solve the sizing part, which is the heart of this question:
Is there a way to constrain a view's size based on another, non-sibling, view?
A simplification of the structure is:
HStack {
CellOneView {
}
CellTwoView {
}
}
HStack {
CellThreeView {
}
CellFourView {
}
}
Which maps out to:
+-----+-----+
| 1 | 2 |
+-----+-----+
| 3 | 4 |
+-----+-----+
Is there a way to tell CellFour (which isn't in the same HStack as cell's 1 and 2) that I want it to constrain itself (and align) to the width of cell CellTwo?
This does not need to strictly be a grid view (example of grid view). There are really only three views that I care about in this case - the areas that roughly map to cell 1, cell 2, and cell 4. I want the heights of Cell 1 and Cell 2 to be the same (accomplished easily with the current HStack), and the widths of Cell 2 and Cell 4 to be the same - that's where I'm struggling.
1.Getting the size
struct CellTwoView: View {
var body: some View {
Rectangle()
.background(
GeometryReader(content: { (proxy: GeometryProxy) in
Color.clear
.preference(key: MyPreferenceKey.self, value: MyPreferenceData(rect: proxy.size))
})
)
}
}
Explanation - Here I have get the size of the view from using background View ( Color.clear ) , I used this trick unless getting the size from CellTwoView itself ; 'cause of SwiftUI-View size is determined by the view itself If they have size ( parent cannot change the size like in UIKit ). so if I use GeometryReader with CellTwoView itself , then the GeometryReader takes as much as size available in the parent of CellTwoView. - > reason -> GeometryReader depends on their parent size. (actually this is another topic and the main thing in SwiftUI)
Key ->
struct MyPreferenceKey: PreferenceKey {
static var defaultValue: MyPreferenceData = MyPreferenceData(size: CGSize.zero)
static func reduce(value: inout MyPreferenceData, nextValue: () -> MyPreferenceData) {
value = nextValue()
}
typealias Value = MyPreferenceData
}
Value (and how it is handle when preference change) ->
struct MyPreferenceData: Equatable {
let size: CGSize
//you can give any name to this variable as usual.
}
2. Applying the size to another view
struct ContentView: View {
#State var widtheOfCellTwoView: CGFloat = .zero
var body: some View {
VStack {
HStack {
CellOneView()
CellTwoView ()
.onPreferenceChange(MyPreferenceKey.self) { (prefereneValue) in
self.widtheOfCellTwoView = prefereneValue.size.width
}
}
HStack {
CellThreeView ()
CellFourView ()
.frame(width: widtheOfCellTwoView)
}
}
}
}
As you already started using alignment guides it is possible to with this instrument. Here is possible approach (for your scratchy example):
#State private var width: CGFloat = 10 // < initial value does not much matter
...
HStack {
CellOneView {
}
CellTwoView {
}
.alignmentGuide(VerticalAlignment.center, computeValue: { d in
// for simplicity of demo skipped checking for equality
DispatchQueue.main.async { // << must be async
self.width = d.width // << set limit
}
return d[VerticalAlignment.center]
})
}
HStack {
CellThreeView {
}
CellFourView {
}
.frame(width: self.width) // << apply limit, updated right in next loop
}

How to define variables inside a GeometryReader in SwiftUI

I'd like to calculate the line width of a shape inside a view based on the view's size. Looking through various posts here on StackOverflow, I think the solution is to use a GeometryReader like this:
struct MyView: View {
var body: some View {
GeometryReader { geometry in
// Here goes your view content,
// and you can use the geometry variable
// which contains geometry.size of the parent
// You also have function to get the bounds
// of the parent: geometry.frame(in: .global)
}
}
}
My question is, how can I define variables inside the GeometryReader construct to be used for the view? I've tried to put a var statement directly after the line "GeometryReader { geometry in", but this gives a compiler error.
This seems to be a function builder related bug (as of Beta 3), and I recommend filing feedback on it.
The workaround I've been using is to use GeometryProxy in a separate method with an explicit return.
var body: some View {
GeometryReader { proxy in
self.useProxy(proxy)
}
}
func useProxy(_ proxy: GeometryProxy) -> some View {
var width = proxy.size.width
return VStack {
// use width in here
}
}