GeometryReader inside ScrollView - scroll doesn't work anymore [duplicate] - swiftui

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

Related

SwiftUI - How to make a selectable scrollable list?

I'm trying to make something like this:
before scrolling
after scrolling
Essentially:
The items are arranged in a scrollable list
The item located at the center is the one selected
The selected item's properties are accessible (by updating #State variables)
Ideally the scroll gesture is "sticky." For example, whichever item closest to the center after scrolling readjusts its position to the center, so that the overall arrangement is the same.
I've tried using a ScrollView but I have no idea of how to implement 2 and 4. I guess the idea is quite similar to a Picker?
I've been stuck on this for a while. Any suggestion would be greatly appreciated. Thanks in advance!
You could detect the location of items to select the centred one.
// Data model
struct Item: Identifiable {
let id = UUID()
var value: Int
// Other properties...
var loc: CGRect = .zero
}
struct ContentView: View {
#State private var ruler: CGFloat!
#State private var items = (0..<10).map { Item(value: $0) }
#State private var centredItem: Item!
var body: some View {
HStack {
if let item = centredItem {
Text("\(item.value)")
}
HStack(spacing: -6) {
Rectangle()
.frame(height: 1)
.measureLoc { loc in
ruler = (loc.minY + loc.maxY) / 2
}
Image(systemName: "powerplug.fill")
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
ForEach($items) { $item in
Text("\(item.value)")
.padding()
.frame(width: 80, height: 80)
.background(centredItem != nil &&
centredItem.id == item.id ? .yellow : .white)
.border(.secondary)
.measureLoc { loc in
item.loc = loc
if let ruler = ruler {
if item.loc.maxY >= ruler && item.loc.minY <= ruler {
withAnimation(.easeOut) {
centredItem = item
}
}
// Move outsides
if ruler <= items.first!.loc.minY ||
ruler >= items.last!.loc.maxY {
withAnimation(.easeOut) {
centredItem = nil
}
}
}
}
}
}
// Extra space above and below
.padding(.vertical, ruler)
}
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Detect location:
struct LocKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
}
extension View {
func measureLoc(_ perform: #escaping (CGRect) ->()) -> some View {
overlay(GeometryReader { geo in
Color.clear
.preference(key: LocKey.self, value: geo.frame(in: .global))
}.onPreferenceChange(LocKey.self, perform: perform))
}
}

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

How to use geometry reader so that the view does not expand?

I have used geometry reader like this
GeometryReader { r in
ScrollView {
Text("SomeText").frame(width: r.size.width / 2)
}
}
The problem is that the reader expands vertically much like Spacer().
Is there anyway that I can make it not do this?
After googling around I found this answer here.
Create this new struct
struct SingleAxisGeometryReader<Content: View>: View {
private struct SizeKey: PreferenceKey {
static var defaultValue: CGFloat { 10 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
#State private var size: CGFloat = SizeKey.defaultValue
var axis: Axis = .horizontal
var alignment: Alignment = .center
let content: (CGFloat)->Content
var body: some View {
content(size)
.frame(maxWidth: axis == .horizontal ? .infinity : nil,
maxHeight: axis == .vertical ? .infinity : nil,
alignment: alignment)
.background(GeometryReader {
proxy in
Color.clear.preference(key: SizeKey.self, value: axis == .horizontal ? proxy.size.width : proxy.size.height)
}).onPreferenceChange(SizeKey.self) { size = $0 }
}
}
And then use it like this
SingleAxisGeometryReader { width in // For horizontal
// stuff here
}
or
SingleAxisGeometryReader(axis: .vertical) { height in // For vertical
// stuff here
}
With this answer, it’s now generic with no code change.
Since background is fit to actual view size always.you can use this trick, adding GeometryReader in background without changing the size of the view itself.
ScrollView {
}.background(
GeometryReader { r in
// stuff
}
)
}
It's somewhat unclear what you're actually trying to do with the views if it's not actually the code you gave at the top. With regards to that, though, you can swap the position of the GeometryReader and the ScrollView. What the GeometryReader does is find the frame of the available space, and it fills it. With a ScrollView the actual height is 0. So, this:
ScrollView {
GeometryReader {r in
Text("SomeText").frame(width: r.size.width / 2)
}
}

SwiftUI/PreferenceKey: How to avoid moving rects when scrolling?

I'm working on a tab bar that is scrollable and that has a moving background for the selected tab.
The solution is based on PreferenceKeys; however, I have a problem to get the moving background stable in relation to the tabs. Currently, it moves when scrolling, which is not desired; instead, it shall be fixed in relation to the tab item and scroll with them.
Why is this the case, and how to avoid that? When removing the ScrollView, the background moves correctly to the selected tab item. The TabItemButton is just a Button with some special label.
struct TabBar: View {
#EnvironmentObject var service: IRScrollableTabView.Service
// We support up to 15 items.
#State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 15)
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
ZStack {
IRScrollableTabView.Indicator()
.frame(width: self.rects[self.service.selectedIndex].size.width,
height: self.rects[self.service.selectedIndex].size.height)
.offset(x: self.offset(width: geo.size.width))
.animation(.easeInOut(duration: 0.3))
HStack(alignment: .top, spacing: 10) {
ForEach(0..<self.service.tabItems.count, id: \.self) { index in
TabItemButton(index: index,
isSelected: true,
item: self.service.tabItems[index])
// We want a fixed tab item with.
.frame(width: 70)
// This detects the effective positions of the tabs.
.background(IRTabItemViewSetter(index: index))
}
}
// We want to have the positions within this space.
.coordinateSpace(name: "IRReference")
// Update the current tab positions.
.onPreferenceChange(IRTabItemPreferenceKey.self) { preferences in
debugPrint(">>> Preferences:")
for p in preferences {
debugPrint(p.rect)
self.rects[p.viewIndex] = p.rect
}
}
}
}
}
}
private func offset(width: CGFloat) -> CGFloat {
debugPrint(width)
let selectedRect = self.rects[self.service.selectedIndex]
debugPrint(selectedRect)
let selectedOffset = selectedRect.minX + selectedRect.size.width / 2 - width / 2
debugPrint(selectedOffset)
return selectedOffset
}
}
struct Setter: View {
let index: Int
var body: some View {
GeometryReader { geo in
Rectangle()
.fill(Color.clear)
.preference(key: IRPreferenceKey.self,
value: [IRData(viewIndex: self.index,
rect: geo.frame(in: .named("IRReference")))])
}
}
}
struct IRPreferenceKey: PreferenceKey {
typealias Value = [IRData]
static var defaultValue: [IRScrollableTabView.IRData] = []
static func reduce(value: inout [IRScrollableTabView.IRData], nextValue: () -> [IRScrollableTabView.IRData]) {
value.append(contentsOf: nextValue())
}
}
struct IRData: Equatable {
let viewIndex: Int
let rect: CGRect
}
The service is defined this way (i.e., nothing special...):
final class Service: ObservableObject {
#Published var currentDestinationView: AnyView
#Published var tabItems: [IRScrollableTabView.Item]
#Published var selectedIndex: Int { didSet { debugPrint("selectedIndex: \(selectedIndex)") } }
init(initialDestinationView: AnyView,
tabItems: [IRScrollableTabView.Item],
initialSelectedIndex: Int) {
self.currentDestinationView = initialDestinationView
self.tabItems = tabItems
self.selectedIndex = initialSelectedIndex
}
}
struct Item: Identifiable {
var id: UUID = UUID()
var title: String
var image: Image = Image(systemName: "circle")
}
I solved the problem! The trick seemed to be to put another GeometryReader around the Indicator view and to take its width for calculating the offset. The .onPreferenceChange must be attached to the HStack, and the .coordinateSpace to the ZStack. Now it's working...
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
ZStack {
GeometryReader { innerGeo in
IRScrollableTabView.Indicator()
.frame(width: self.rects[self.service.selectedIndex].size.width,
height: self.rects[self.service.selectedIndex].size.height)
.offset(x: self.offset(width: innerGeo.size.width))
.animation(.easeInOut(duration: 0.3))
}
HStack(alignment: .top, spacing: 10) {
ForEach(0..<self.service.tabItems.count, id: \.self) { index in
TabItemButton(index: index,
isSelected: true,
item: self.service.tabItems[index])
// We want a fixed tab item with.
.frame(width: 70)
// This detects the effective positions of the tabs.
.background(IRTabItemViewSetter(index: index))
}
}
// Update the current tab positions.
.onPreferenceChange(IRTabItemPreferenceKey.self) { preferences in
debugPrint(">>> Preferences:")
for p in preferences {
debugPrint(p.rect)
self.rects[p.viewIndex] = p.rect
}
}
}
// We want to have the positions within this space.
.coordinateSpace(name: "IRReference")
}
}
}
private func offset(width: CGFloat) -> CGFloat {
debugPrint(width)
let selectedRect = self.rects[self.service.selectedIndex]
debugPrint(selectedRect)
let selectedOffset = -width / 2 + CGFloat(80 * self.service.selectedIndex) + selectedRect.size.width / 2
debugPrint(selectedOffset)
return selectedOffset
}

Measure the rendered size of a SwiftUI view?

Is there a way to measure the computed size of a view after SwiftUI runs its view rendering phase? For example, given the following view:
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
}
}
With the view selected, the computed size is displayed In the preview canvas in the bottom left corner. Does anyone know of a way to get access to that size in code?
Printing out values is good, but being able to use them inside the parent view (or elsewhere) is better. So I took one more step to elaborate it.
struct GeometryGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { (g) -> Path in
print("width: \(g.size.width), height: \(g.size.height)")
DispatchQueue.main.async { // avoids warning: 'Modifying state during view update.' Doesn't look very reliable, but works.
self.rect = g.frame(in: .global)
}
return Path() // could be some other dummy view
}
}
}
struct ContentView: View {
#State private var rect1: CGRect = CGRect()
var body: some View {
HStack {
// make to texts equal width, for example
// this is not a good way to achieve this, just for demo
Text("Long text").background(Color.blue).background(GeometryGetter(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)
}
}
}
You could add an "overlay" using a GeometryReader to see the values. But in practice it would probably be better to use a "background" modifier and handle the sizing value discretely
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
.overlay(
GeometryReader { proxy in
Text("\(proxy.size.width) x \(proxy.size.height)")
}
)
}
}
Here is the ugly way I came up with to achieve this:
struct GeometryPrintingView: View {
var body: some View {
GeometryReader { geometry in
return self.makeViewAndPrint(geometry: geometry)
}
}
func makeViewAndPrint(geometry: GeometryProxy) -> Text {
print(geometry.size)
return Text("")
}
}
And updated Foo version:
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
.overlay(GeometryPrintingView())
}
}
To anyone who wants to obtain a size out of Jack's solution and store it in some property for further use:
.overlay(
GeometryReader { proxy in
Text(String())
.onAppear() {
// Property, eg
// #State private var viewSizeProperty = CGSize.zero
viewSizeProperty = proxy.size
}
.opacity(.zero)
}
)
This is a bit dirty obviously, but why not if it works.
Try to use PreferenceKey like this.
struct HeightPreferenceKey : PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct WidthPreferenceKey : PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct SizePreferenceKey : PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
extension View {
func readWidth() -> some View {
background(GeometryReader {
Color.clear.preference(key: WidthPreferenceKey.self, value: $0.size.width)
})
}
func readHeight() -> some View {
background(GeometryReader {
Color.clear.preference(key: HeightPreferenceKey.self, value: $0.size.height)
})
}
func onWidthChange(perform action: #escaping (CGFloat) -> Void) -> some View {
onPreferenceChange(WidthPreferenceKey.self) { width in
action(width)
}
}
func onHeightChange(perform action: #escaping (CGFloat) -> Void) -> some View {
onPreferenceChange(HeightPreferenceKey.self) { height in
action(height)
}
}
func readSize() -> some View {
background(GeometryReader {
Color.clear.preference(key: SizePreferenceKey.self, value: $0.size)
})
}
func onSizeChange(perform action: #escaping (CGSize) -> Void) -> some View {
onPreferenceChange(SizePreferenceKey.self) { size in
action(size)
}
}
}
struct MyView: View {
#State private var height: CGFloat = 0
var body: some View {
...
.readHeight()
.onHeightChange {
height = $0
}
}
}
As others pointed out, GeometryReader and a custom PreferenceKey is the best way forward for now. I've implemented a helper drop-in library which does pretty much that: https://github.com/srgtuszy/measure-size-swiftui