GeometryReader to calculate total height of views - swiftui

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

Related

How to enable both wipe able tabview and bottom navigator in swiftUi

I am new with iOS and swiftUi. I got stuck when try to make tabview display as default but also want my tabview is wipeable.
I already know that we can make tabview swipe able by adding tabViewStyle but the navigator in the bottom will be disappeared.
.tabViewStyle(.page(indexDisplayMode: .never))
The solution that I can think of is add a new View in the bottom and custom like a navigator, hope that someone know other better solution than that.
Thank you !
A possible approach is to use two synchronised TabView for this purpose by matching selections and aligned content height. See also comments inline.
Tested with Xcode 13.2 / iOS 15.2
struct TestTwoTabViews: View {
#State private var selection1: Int = 1
#State private var selection2: Int = 1
// to make 2d TabView as height as content view of 1st TabView
#State private var viewHeight = CGFloat.infinity
var body: some View {
ZStack(alignment: .top) {
// responsible for bottom Tabs
TabView(selection: $selection1) {
Color.clear
.background(GeometryReader {
// read 1st content height
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
.tabItem{Image(systemName: "1.square")}.tag(1)
Color.clear
.tabItem{Image(systemName: "2.square")}.tag(2)
Color.clear
.tabItem{Image(systemName: "3.square")}.tag(3)
}
// responsible for paging
TabView(selection: $selection2) {
Color.yellow.overlay(Text("First"))
.tag(1)
Color.green.overlay(Text("Second"))
.tag(2)
Color.blue.overlay(Text("Third"))
.tag(3)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(maxHeight: viewHeight) // content height
}
.onPreferenceChange(ViewHeightKey.self) {
self.viewHeight = $0 // apply content height
}
.onChange(of: selection1) {
selection2 = $0 // sync second
}
.onChange(of: selection2) {
selection1 = $0 // sync first
}
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = value + nextValue()
}
}

SwiftUI ScrollView doesn't compute custom StaggeredGrid height

I am trying to create a new grid structure in SwiftUI to show cards with variable heights. To that extent, I use several LazyVStacks in a HStack and have a condition to display my data items in the correct order. This view works alone, adapts the number of columns to the size of the screen, but when using it in a ScrollView, its size is not computed properly and the following views end up beneath the grid instead of below it. Here is the code I used for the grid :
struct StaggeredGrid<Element, Content>: View where Content: View {
var preferredItemWidth: CGFloat
var data: [Element] = []
var content: (Element) -> Content
init(preferredItemWidth: CGFloat, data: [Element], #ViewBuilder content: #escaping (Element) -> Content) {
self.preferredItemWidth = preferredItemWidth
self.data = data
self.content = content
}
var body: some View {
GeometryReader { geometry in
HStack(alignment: .top, spacing: 20) {
ForEach(1...Int(geometry.size.width / preferredItemWidth), id: \.self) { number in
LazyVStack(spacing: 20) {
ForEach(0..<data.count, id: \.self) { index in
if (index+1) % Int(geometry.size.width / preferredItemWidth) == number % Int(geometry.size.width / preferredItemWidth) {
content(data[index])
}
}
}
}
}
.padding([.horizontal])
}
}
}
And the preview to show the behavior :
struct StaggeredGrid_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
VStack {
StaggeredGrid(preferredItemWidth: 160, data: (1...10).map{"Item \($0)"}) { item in
Text(item)
.foregroundColor(.blue)
}
Text("I should be below the grid")
}
}
}
}
Here is a picture of the preview:
Wrong appearance in a ScrollView
And a picture when the ScrollView is commented out:
Expected behavior, ScrollView removed
Thank you in advance for any help or clue about this behavior I do not understand.
I quote #Asperi :
"ScrollView has no own size and GeometryReader has no own size, so you've got into chicken-egg problem, ie. no-one knows size to render, so collapsed. You must have definite frame size for items inside ScrollView."
Here you have to set the height of your StaggeredGrid, or the GeometryReader it contains.
In your case its height depends of course on the height of its content (i.e. the HStack). You can read this height (the height of its background / overlay for example) with a Reader. And use it to definite the frame size of your GeometryReader
For example :
struct StaggeredGrid<Element, Content>: View where Content: View {
var preferredItemWidth: CGFloat
var data: [Element] = []
var content: (Element) -> Content
init(preferredItemWidth: CGFloat, data: [Element], #ViewBuilder content: #escaping (Element) -> Content) {
self.preferredItemWidth = preferredItemWidth
self.data = data
self.content = content
}
#State private var gridHeight: CGFloat = 100
var body: some View {
GeometryReader { geometry in
HStack(alignment: .top, spacing: 20) {
ForEach(1...Int(geometry.size.width / preferredItemWidth), id: \.self) { number in
LazyVStack(spacing: 20) {
ForEach(0..<data.count, id: \.self) { index in
if (index+1) % Int(geometry.size.width / preferredItemWidth) == number % Int(geometry.size.width / preferredItemWidth) {
content(data[index])
}
}
}
}
}
.overlay(GeometryReader { proxy in
Color.clear.preference(
key: HeightPreferenceKey.self,
value: proxy.size.height
)
})
.onPreferenceChange(HeightPreferenceKey.self) {
gridHeight = $0
}
.padding([.horizontal])
}
.frame(height: gridHeight)
}
}
private struct HeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat) {
value = nextValue()
}
}

SwiftUI: How do I sync/change progress bar progress based on scrollview’s user current scroll position?

I have horizontal progress bar within ScrollView and I need to change that progress bar value, when user is scrolling.
Is there any way to bind some value to current scroll position?
You can do this with a few GeometryReaders.
My method:
Get total height of ScrollView container
Get inner height of content
Find the difference for the total scrollable height
Get the distance between the scroll view container top and the content top
Divide the distance between tops by the total scrollable height
Use PreferenceKeys to set the proportion #State value
Code:
struct ContentView: View {
#State private var scrollViewHeight: CGFloat = 0
#State private var proportion: CGFloat = 0
var body: some View {
VStack {
ScrollView {
VStack {
ForEach(0 ..< 100) { index in
Text("Item: \(index + 1)")
}
}
.frame(maxWidth: .infinity)
.background(
GeometryReader { geo in
let scrollLength = geo.size.height - scrollViewHeight
let rawProportion = -geo.frame(in: .named("scroll")).minY / scrollLength
let proportion = min(max(rawProportion, 0), 1)
Color.clear
.preference(
key: ScrollProportion.self,
value: proportion
)
.onPreferenceChange(ScrollProportion.self) { proportion in
self.proportion = proportion
}
}
)
}
.background(
GeometryReader { geo in
Color.clear.onAppear {
scrollViewHeight = geo.size.height
}
}
)
.coordinateSpace(name: "scroll")
ProgressView(value: proportion, total: 1)
.padding(.horizontal)
}
}
}
struct ScrollProportion: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Result:

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

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.