ForEach inside HStack causes size to be zero - swift

I was playing with a SwiftUI animation that required the view size. In my case, it is some list implemented using HStack. Since it consists of many items, I used the ForEach struct.
It shocked me when I saw its size (0.0, 0.0). After digging, I found out that changing ForEach to an explicit items declaration made the size correct.
And now, there's a question – why is that? How do you suggest overcoming this issue?
For me, it looks like a SwiftUI bug. Since ForEach conforms to the View protocol, it should behave like any other view IMO :D
Below you can find snippets of the code related to the issue. Both ForEach implementations and size modifier-related.
struct ContentView: View {
#State private var contentSize: CGSize = .zero
var body: some View {
HStack {
HStack {
// Version 1: - With ForEach inside HStack's size is (0.0, 0.0)
ForEach([0, 1], id: \.self) { item in
Text("Item \(item)")
}
// Version 2: - Without ForEach inside HStack's size is (101.66666666666666, 20.333333333333332)
Text("Item 0")
Text("Item 1")
}
.fixedSize()
.size($contentSize)
}
}
}
extension View {
public func size(_ size: Binding<CGSize>) -> some View {
modifier(SizeBindingViewModifier(size: size))
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = .zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
struct SizeBindingViewModifier: ViewModifier {
#Binding private var size: CGSize
init(size: Binding<CGSize>) {
_size = size
}
func body(content: Content) -> some View {
content
.background(
GeometryReader { proxy in
Color
.clear
.preference(
key: SizePreferenceKey.self,
value: proxy.size
)
}
)
.onPreferenceChange(SizePreferenceKey.self) { size in
print("size: \(size)")
self.size = size
}
}
}

Interestingly, if you check value and nextValue() in reduce, value is initially set correctly.
In fact, in the example above, reduce is only called (with nextValue() = .zero) when the ForEach is used. Presumably this is due size not being calculable initially due to the "dynamic" nature of the ForEach.
Assuming your views will never have zero size, a simple workaround could be:
static func reduce(value: inout Value, nextValue: () -> Value) {
guard nextValue() != .zero else { return }
value = nextValue()
}

Related

UIHostingController size too big

I'm trying to embed a SwiftUI View within a UIKit UIView, within a View again. It will look something like this:
View
↓
UIView
↓
View
Current code:
struct ContentView: View {
var body: some View {
Representable {
Text("Hello world!")
}
}
}
struct Representable<Content: View>: UIViewRepresentable {
private let content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
func makeUIView(context: Context) -> UIView {
let host = UIHostingController(rootView: content())
let hostView = host.view!
return hostView
}
func updateUIView(_ uiView: UIView, context: Context) {
uiView.backgroundColor = .systemRed
}
}
I want the Representable to only set the backgroundColor of the Text. It shouldn't be any bigger. Also, this is just an example, so this isn't just a Text and setting the background color.
Now
Aim
There is also a problem if the text is really long - it doesn't get constrained by the size of the screen / parent (using hugging priority in this case):
How can I make sure that Representable is only as big as the content itself, Text in this case? It should also work if the text wraps over a line for example when constrained to a certain width.
The simplest way is to use SwiftUI-Introspect and just grab the UIView from it. This is all the code needed:
Text("This is some really long text that will have to wrap to multiple lines")
.introspect(selector: TargetViewSelector.siblingOfType) { target in
target.backgroundColor = .systemRed
}
If the view is a bit more complex and there isn't a UIView specifically for it, you can embed it in a ScrollView so the content will now be a UIView:
ScrollView {
Text("Complex content here")
}
.introspectScrollView { scrollView in
scrollView.isScrollEnabled = false
scrollView.clipsToBounds = false
scrollView.subviews.first!.backgroundColor = .systemRed
}
If you don't want to use Introspect (which I would highly recommend), there is a second solution below. The second solution works in most situations, but not all.
See solution above first.
I've created a working answer. It looks quite complicated, but it works.
It basically works by using the inside GeometryReader to measure the size of the content to be wrapped and the outside GeometryReader to measure the size of the whole container. This means that Text will now wrap lines because it's constrained by the outside container's size.
Code:
struct ContentView: View {
var body: some View {
Wrapper {
Text("This is some really long text that will have to wrap to multiple lines")
}
}
}
struct Wrapper<Content: View>: View {
#State private var size: CGSize?
#State private var outsideSize: CGSize?
private let content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
var body: some View {
GeometryReader { outside in
Color.clear.preference(
key: SizePreferenceKey.self,
value: outside.size
)
}
.onPreferenceChange(SizePreferenceKey.self) { newSize in
outsideSize = newSize
}
.frame(width: size?.width, height: size?.height)
.overlay(
outsideSize != nil ?
Representable {
content()
.background(
GeometryReader { inside in
Color.clear.preference(
key: SizePreferenceKey.self,
value: inside.size
)
}
.onPreferenceChange(SizePreferenceKey.self) { newSize in
size = newSize
}
)
.frame(width: outsideSize!.width, height: outsideSize!.height)
.fixedSize()
.frame(width: size?.width ?? 0, height: size?.height ?? 0)
}
.frame(width: size?.width ?? 0, height: size?.height ?? 0)
: nil
)
}
}
struct SizePreferenceKey: PreferenceKey {
static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct Representable<Content: View>: UIViewRepresentable {
private let content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
func makeUIView(context: Context) -> UIView {
let host = UIHostingController(rootView: content())
let hostView = host.view!
return hostView
}
func updateUIView(_ uiView: UIView, context: Context) {
uiView.backgroundColor = .systemRed
}
}
Result:
Another example to show that it does make the wrapper the exact size as the SwiftUI view:
struct ContentView: View {
var body: some View {
VStack {
Wrapper {
Text("This is some really long text that will have to wrap to multiple lines")
}
.border(Color.green, width: 3)
Wrapper {
Text("This is some really long text that will have to wrap to multiple lines. However, this bottom text is a bit longer and may wrap more lines - but this isn't a problem here")
}
.border(Color.blue, width: 3)
}
}
}

How can I make a Custom ScrollView without using SwiftUI ScrollView?

I'm currently working on Custom ScrollView without ScrollView.
I don't want to add too many features, but I want to be able to scroll by dragging.
I've searched the Internet, but all I can find are examples of wrapped ScrollView.
The code I'm working on is as follows(WIP code):
import SwiftUI
struct ContentView: View {
#State private var yOffset: CGFloat = 0
#State private var contentSize: CGSize = .zero
var body: some View {
CustomScrollView {
ForEach(0..<100) { i in
Text(
"\(i)"
)
.frame(
maxWidth: .infinity
)
.background(
Color.green
)
}
.offset(y: 0)
.size(size: $contentSize)
}
.offset(y: 0)
}
}
struct CustomScrollView<Content: View>: View {
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
self.content
}
}
//==================================================
// For extension
//==================================================
struct ChildSizeReader<Content: View>: View {
#Binding var size: CGSize
let content: () -> Content
var body: some View {
// Remove ZStack from the existing answer.
content().background(
GeometryReader { proxy in
Color.clear.preference(
key: SizePreferenceKey.self,
value: proxy.size
)
}
)
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.size = preferences
}
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = .zero
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
extension View {
func size(size: Binding<CGSize>) -> some View {
ChildSizeReader(size: size) {
self
}
}
}
As you can see in the attached image, the top of the content is missing.
I would like to adjust it so that the top is visible, but I don't know how to do that.
Note:
The dragging and other processes will be implemented after the top is visible first.
Reference:
https://www.youtube.com/watch?v=vuFUX1Qwrmo
SwiftUI - Get size of child?

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

A view extension that runs conditional code based on its GeometryReader results

I’ve created a View extension to read its offset (inspired by https://fivestars.blog/swiftui/swiftui-share-layout-information.html):
func readOffset(in coordinateSpace: String? = nil, onChange: #escaping (CGFloat) -> Void) -> some View {
background(
GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: coordinateSpace == nil ? .global : .named(coordinateSpace)).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self, perform: onChange)
}
I’m also using Federico’s readSize function:
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geo in
Color.clear
.preference(key: SizePreferenceKey.self, value: geo.size)
})
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
The two work together to help me determine whether a child view within a scrollview is on/off-screen:
struct TestInfinityList: View {
#State var visibleItems: Set<Int> = []
#State var items: [Int] = Array(0...20)
#State var size: CGSize = .zero
var body: some View {
ScrollView(.vertical) {
ForEach(items, id: \.self) { item in
GeometryReader { geo in
VStack {
Text("Item \(item)")
}.id(item)
.readOffset(in: "scroll") { newOffset in
if !isOffscreen(when: newOffset, in: size.height) {
visibleItems.insert(item)
}
else {
visibleItems.remove(item)
}
}
}.frame(height: 300)
}
}.coordinateSpace(name: "scroll")
}
.readSize { newSize in
self.size = newSize
}
}
This is the isOffscreen function that checks for visibility:
func isOffscreen(when offset: CGFloat, in height: CGFloat) -> Bool {
if offset <= 0 && offset + height >= 0 {
return false
}
return true
}
Everything works fine. However, I’d like to optimise the code further into a single extension that checks for visibility based on the offset and size.height inputted, and also receives parameters for what to do if visible and when not i.e. move readOffset’s closure to be logic that co-exists with the extension code.
I’ve no idea whether this is feasible but thought it’s worth an ask.
You just need to create a View or ViewModifier that demands some Bindings. Note, the code below is just an example of some of the patterns you can use (e.g., an optional binding, escaping content closure), but in the form of a Stack style wrap rather than a ViewModifier (which based on the blog you know how to setup).
struct ScrollableVStack<Content: View>: View {
let content: Content
#Binding var useScrollView: Bool
#Binding var scroller: ScrollViewProxy?
#State private var staticGeo = ViewGeometry()
#State private var scrollContainerGeo = ViewGeometry()
let topFade: CGFloat
let bottomFade: CGFloat
init(_ useScrollView: Binding<Bool>,
topFade: CGFloat = 0.09,
bottomFade: CGFloat = 0.09,
_ scroller: Binding<ScrollViewProxy?> = .constant(nil),
#ViewBuilder _ content: #escaping () -> Content ) {
_useScrollView = useScrollView
_scroller = scroller
self.content = content()
self.topFade = topFade
self.bottomFade = bottomFade
}
var body: some View {
if useScrollView { scrollView }
else { VStack { staticContent } }
}
var scrollView: some View {
ScrollViewReader { scroller in
ScrollView(.vertical, showsIndicators: false) {
staticContent
.onAppear { self.scroller = scroller }
}
.geometry($scrollContainerGeo)
.fadeInOut(topFade: staticGeo.size.height * topFade,
bottomFade: staticGeo.size.height * bottomFade)
}
.onChange(of: staticGeo.size.height) { newStaticHeight in
useScrollView = newStaticHeight > scrollContainerGeo.size.height * 0.85
}
}
var staticContent: some View {
content
.geometry($staticGeo)
.padding(.top, staticGeo.size.height * topFade * 1.25)
.padding(.bottom, staticGeo.size.height * bottomFade)
}
}