How to know the current position in a LazyVGrid in SwiftUI - swiftui

I'm presenting a dynamic table that can expand from the top or bottom. When presenting data in a combination of ScrollView and LazyVGrid, if I add more data to the bottom the scroll view doesn't change (this is what I want). However when data is added to the top of the list, the scrollview moves to the relative same position as before (but now displaced by the amount of new elements) instead of staying at the same position.
I could easily fix this behaviour if I could know the current visible position so after adding more data I could use .scrollTo (oldPosition + additionalElements). However I can't find a way to get this value.
In the following View you can see the behaviour:
import SwiftUI
struct ContentView: View {
#State var table: [Int] = Array(100...200)
var body: some View {
VStack {
HStack {
Button {
let temp = Array(90...99)
self.table = temp + self.table
} label: {
Text("Add 10 on top. It will displace ScrollView")
}
Button {
let temp = Array(201...210)
self.table = self.table + temp
} label: {
Text("Add 10 to the bottom. It will not move ScrollView")
}
}
ScrollViewReader { reader in
ScrollView {
LazyVGrid(columns: [GridItem()], alignment: .center, spacing: 0.0) {
ForEach(table, id: \.self) { i in
Text("\(i)")
}
}
.onAppear {
reader.scrollTo(150, anchor: .center)
}
}
}
}
.padding()
}
}
Any idea how to get this value?
How to get current position of LazyVGrid?

Related

SwiftUI Scrollable Charts in IOS16

Using the new SwiftUI Charts framework, we can make a chart bigger than the visible screen and place it into a ScrollView to make it scrollable. Something like this:
var body : some View {
GeometryReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
Chart {
ForEach(data) { entry in
// ...
}
}
.frame(width: proxy.size.width * 2)
}
}
}
Does anybody know if it is possible to programmatically move the scroll to display a certain area of the chart?
I've tried using ScrollViewReader, setting the IDs at the x-axis labels, and trying to use the scrollTo function to navigate to any of those positions with no luck:
Chart {
/// ...
}
.chartXAxis {
AxisMarks(values: .stride(by: .day)) { value in
if let date : Date = value.as(Date.self) {
Text(date, style: .date)
.font(.footnote)
}
}
}
This cheesy workaround seems to do the trick. I put the chart in a ZStack with an HStack overlaying the chart. The HStack contains a bunch of invisible objects that conform to the Identifiable protocol. The quantity, ids, and positions of the invisible objects match the charted data.
Since the ZStack view now contains identifiable elements, ScrollViewReader works as expected.
import SwiftUI
import Charts
struct ChartData: Identifiable {
var day: Int
var value: Int
var id: String { "\(day)" }
}
struct ContentView: View {
#State var chartData = [ChartData]()
#State var scrollSpot = ""
let items = 200
let itemWidth: CGFloat = 30
var body: some View {
VStack {
ScrollViewReader { scrollPosition in
ScrollView(.horizontal) {
// Create a ZStack with an HStack overlaying the chart.
// The HStack consists of invisible items that conform to the
// identifible protocol to provide positions for programmatic
// scrolling to the named location.
ZStack {
// Create an invisible rectangle for each x axis data point
// in the chart.
HStack(spacing: 0) {
ForEach(chartData) { item in
Rectangle()
.fill(.clear)
// Setting maxWidth to .infinity here, combined
// with spacing:0 above, makes the rectangles
// expand to fill the frame specified for the
// chart below.
.frame(maxWidth: .infinity, maxHeight: 0)
// Here, set the rectangle's id to match the
// charted data.
.id(item.id)
}
}
Chart(chartData) {
BarMark(x: .value("Day", $0.day),
y: .value("Amount", $0.value),
width: 20)
}
.frame(width: CGFloat(items) * itemWidth, height: 300)
}
}
.padding()
.onChange(of: scrollSpot, perform: {x in
if (!x.isEmpty) {
scrollPosition.scrollTo(x)
scrollSpot = ""
}
})
}
.onAppear(perform: populateChart)
Button("Scroll") {
if let x = chartData.last?.id {
print("Scrolling to item \(x)")
scrollSpot = x
}
}
Spacer()
}
}
func populateChart() {
if !chartData.isEmpty { return }
for i in 0..<items {
chartData.append(ChartData(day: i, value: (i % 10) + 2))
}
}
}
IMHO this should work out of the SwiftUI box. Apple's comments for the initializer say it creates a chart composed of a series of identifiable marks. So... if the marks are identifiable, it is not a stretch to expect ScrollViewReader to work with the chart's marks.
But noooooo!
One would hope this is an oversight on Apple's part since the framework is new, and they will expose ids for chart marks in an upcoming release.

Picker scroll through one element horizontally

I have a SwiftUI Picker in which an item is selected. The text of one element can be large, so I used UIKit UIPickerView and set the manual height to 100, but at some point it became not enough. Is it possible to make scrolling horizontal for each element?
I want to get something like this:
Picker("Items", select: self._selectItem) {
ForEach(self.items, id: \.self) { item in
ScrollView(.horizontal, showsIndicators: false) {
Text(item.description)
}
.tag(item)
}
}
That should work fine. If you only want to scroll one item, you would have to insert a check of the item length.
let items = [
"A long item text.",
"And a even longer item text which is really going further.",
"Another item text which is really going further."
]
struct ContentView: View {
#State private var select = ""
var body: some View {
VStack {
Text("Make your selection!")
List(items, id: \.self) { item in
ScrollView(.horizontal) {
Text(item)
}
.listRowBackground(item == select ? Color.red : Color.white)
.onTapGesture {
select = item
}
}
}
}
}
I would strongly suggest to separate the picking from the text display and scrolling, e.g. like this:
struct ContentView: View {
#State private var select = items[0]
var body: some View {
VStack {
Text("Make your selection!")
Picker("Items", selection: $select) {
ForEach(items) { item in
Text(item.title)
.tag(item)
}
}
ScrollView {
Text(select.text)
}
.padding()
.frame(height: 200)
}
}
}

Can I create a ScrollView that is infinite scrolling but has fixed values?

Given a ScrollView like the following
If I have a ScrollView like the one below, can I display 1 to 10 again after the CircleView of 1 to 10?
I want to use the same 1-10 values and display 1, 2, 3....10 after 10. I want to use the same 1...10 values and display 1, 2, 3...10 after 10.
struct ContentView: View {
var body: some View {
VStack {
Divider()
ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(0..<10) { index in
CircleView(label: "\(index)")
}
}.padding()
}.frame(height: 100)
Divider()
Spacer()
}
}
}
struct CircleView: View {
#State var label: String
var body: some View {
ZStack {
Circle()
.fill(Color.yellow)
.frame(width: 70, height: 70)
Text(label)
}
}
}
reference:
https://www.simpleswiftguide.com/how-to-create-horizontal-scroll-view-in-swiftui/
This can be done like illustrated in the Advanced ScrollView Techniques video by Apple. Although that video is from before the SwiftUI era, you can easily implement it in SwiftUI. The advantage of this technique (compared to Toto Minai's answer) is that it does not need to allocate extra memory when scrolling up or down (And you will not run out of memory when you scroll too far 😉).
Here is an implementation in SwiftUI.
import SwiftUI
let overlap = CGFloat(100)
struct InfiniteScrollView<Content: View>: UIViewRepresentable {
let content: Content
func makeUIView(context: Context) -> InfiniteScrollViewRenderer {
let contentWidth = CGFloat(100)
let tiledContent = content
.float(above: content) // For an implementation of these modifiers:
.float(below: content) // see https://github.com/Dev1an/SwiftUI-InfiniteScroll
let contentController = UIHostingController(rootView: tiledContent)
let contentView = contentController.view!
contentView.frame.size.height = contentView.intrinsicContentSize.height
contentView.frame.size.width = contentWidth
contentView.frame.origin.y = overlap
let scrollview = InfiniteScrollViewRenderer()
scrollview.addSubview(contentView)
scrollview.contentSize.height = contentView.intrinsicContentSize.height * 2
scrollview.contentSize.width = contentWidth
scrollview.contentOffset.y = overlap
return scrollview
}
func updateUIView(_ uiView: InfiniteScrollViewRenderer, context: Context) {}
}
class InfiniteScrollViewRenderer: UIScrollView {
override func layoutSubviews() {
super.layoutSubviews()
let halfSize = contentSize.height / 2
if contentOffset.y < overlap {
contentOffset.y += halfSize
} else if contentOffset.y > halfSize + overlap {
contentOffset.y -= halfSize
}
}
}
The main idea is to
Tile the static content. This is done using the float() modifiers.
Change the offset of the scrollview to replace the current view with a previous or next tile when you reach a bound. This is done in layoutSubviews of the InfiniteScrollViewRenderer
Drawback
The main drawback of this technique is that up until now (July 2021) Lazy Stacks don't appear to be rendered lazily when they are not inside a SwiftUI List or ScrollView.
You could use LazyHStack to add a new item when the former item appeared:
// Use `class`, since you don't want to make a copy for each new item
class Item {
var value: Int
// Other properties
init(value: Int) {
self.value = value
}
}
struct ItemWrapped: Identifiable {
let id = UUID()
var wrapped: Item
}
struct ContentView: View {
static let itemRaw = (0..<10).map { Item(value: $0) }
#State private var items = [ItemWrapped(wrapped: itemRaw.first!)]
#State private var index = 0
var body: some View {
VStack {
Divider()
// Scroll indicator might be meaningless?
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 10) {
ForEach(items) { item in
CircleView(label: item.wrapped.value.formatted())
.onAppear {
// Index iteration
index = (index + 1) % ContentView.itemRaw.count
items.append(
ItemWrapped(wrapped: ContentView.itemRaw[index]))
}
}
}.padding()
}.frame(height: 100)
Divider()
Spacer()
}
}
}
You can do it in SwiftUI but it doesn’t use a ScrollView. Try this View available here: https://gist.github.com/kevinbhayes/550e4b080d2761aa20d351ff01bab13e

SwiftUI ScrollView does not center content when content fits scrollview bounds

I'm trying to have the content inside a ScrollView be centered when that content is small enough to not require scrolling, but instead it aligns to the top. Is this a bug or I'm missing adding something? Using Xcode 11.4 (11E146)
#State private var count : Int = 100
var body : some View {
// VStack {
ScrollView {
VStack {
Button(action: {
if self.count > 99 {
self.count = 5
} else {
self.count = 100
}
}) {
Text("CLICK")
}
ForEach(0...count, id: \.self) { no in
Text("entry: \(no)")
}
}
.padding(8)
.border(Color.red)
.frame(alignment: .center)
}
.border(Color.blue)
.padding(8)
// }
}
Credit goes to #Thaniel for finding the solution. My intention here is to more fully explain what is happening behind the scenes to demystify SwiftUI and explain why the solution works.
Solution
Wrap the ScrollView inside a GeometryReader so that you can set the minimum height (or width if the scroll view is horizontal) of the scrollable content to match the height of the ScrollView. This will make it so that the dimensions of the scrollable area are never smaller than the dimensions of the ScrollView. You can also declare a static dimension and use it to set the height of both the ScrollView and its content.
Dynamic Height
#State private var count : Int = 5
var body: some View {
// use GeometryReader to dynamically get the ScrollView height
GeometryReader { geometry in
ScrollView {
VStack(alignment: .leading) {
ForEach(0...self.count, id: \.self) { num in
Text("entry: \(num)")
}
}
.padding(10)
// border is drawn before the height is changed
.border(Color.red)
// match the content height with the ScrollView height and let the VStack center the content
.frame(minHeight: geometry.size.height)
}
.border(Color.blue)
}
}
Static Height
#State private var count : Int = 5
// set a static height
private let scrollViewHeight: CGFloat = 800
var body: some View {
ScrollView {
VStack(alignment: .leading) {
ForEach(0...self.count, id: \.self) { num in
Text("entry: \(num)")
}
}
.padding(10)
// border is drawn before the height is changed
.border(Color.red)
// match the content height with the ScrollView height and let the VStack center the content
.frame(minHeight: scrollViewHeight)
}
.border(Color.blue)
}
The bounds of the content appear to be smaller than the ScrollView as shown by the red border. This happens because the frame is set after the border is drawn. It also illustrates the fact that the default size of the content is smaller than the ScrollView.
Why Does it Work?
ScrollView
First, let's understand how SwiftUI's ScrollView works.
ScrollView wraps it's content in a child element called ScrollViewContentContainer.
ScrollViewContentContainer is always aligned to the top or leading edge of the ScrollView depending on whether it is scrollable along the vertical or horizontal axis or both.
ScrollViewContentContainer sizes itself according to the ScrollView content.
When the content is smaller than the ScrollView, ScrollViewContentContainer pushes it to the top or leading edge.
Center Align
Here's why the content gets centered.
The solution relies on forcing the ScrollViewContentContainer to have the same width and height as its parent ScrollView.
GeometryReader can be used to dynamically get the height of the ScrollView or a static dimension can be declared so that both the ScrollView and its content can use the same parameter to set their horizontal or vertical dimension.
Using the .frame(minWidth:,minHeight:) method on the ScrollView content ensures that it is never smaller than the ScrollView.
Using a VStack or HStack allows the content to be centered.
Because only the minimum height is set, the content can still grow larger than the ScrollView if needed, and ScrollViewContentContainer retains its default behavior of aligning to the top or leading edge.
You observe just normal ScrollView behaviour. Here is a demo of possible approach to achieve your goal.
// view pref to detect internal content height
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
// extension for modifier to detect view height
extension ViewHeightKey: ViewModifier {
func body(content: Content) -> some View {
return content.background(GeometryReader { proxy in
Color.clear.preference(key: Self.self, value: proxy.size.height)
})
}
}
// Modified your view for demo
struct TestAdjustedScrollView: View {
#State private var count : Int = 100
#State private var myHeight: CGFloat? = nil
var body : some View {
GeometryReader { gp in
ScrollView {
VStack {
Button(action: {
if self.count > 99 {
self.count = 5
} else {
self.count = 100
}
}) {
Text("CLICK")
}
ForEach(0...self.count, id: \.self) { no in
Text("entry: \(no)")
}
}
.padding(8)
.border(Color.red)
.frame(alignment: .center)
.modifier(ViewHeightKey()) // read content view height !!
}
.onPreferenceChange(ViewHeightKey.self) {
// handle content view height
self.myHeight = $0 < gp.size.height ? $0 : gp.size.height
}
.frame(height: self.myHeight) // align own height with content
.border(Color.blue)
.padding(8)
}
}
}
The frame(alignment: .center) modifier you’ve added doesn’t work since what it does is wrapping your view in a new view of exactly the same size. Because of that the alignment doesn’t do anything as there is no additional room for the view do be repositioned.
One potential solution for your problem would be to wrap the whole ScrollView in a GeometryReader to read available height. Then use that height to specify that the children should not be smaller than it. This will then make your view centered inside of ScrollView.
struct ContentView: View {
#State private var count : Int = 100
var body : some View {
GeometryReader { geometry in
ScrollView {
VStack {
Button(action: {
if self.count > 99 {
self.count = 5
} else {
self.count = 100
}
}) {
Text("CLICK")
}
ForEach(0...self.count, id: \.self) { no in
Text("entry: \(no)")
}
}
.padding(8)
.border(Color.red)
.frame(minHeight: geometry.size.height) // Here we are setting minimum height for the content
}
.border(Color.blue)
}
}
}
For me, GeometryReader aligned things to the top no matter what. I solved it with adding two extra Spacers (my code is based on this answer):
GeometryReader { metrics in
ScrollView {
VStack(spacing: 0) {
Spacer()
// your content goes here
Spacer()
}
.frame(minHeight: metrics.size.height)
}
}

SwiftUI Drag events how do restrict to detect only horizontal/vertical scrolls

I have List (vertically scrolled) with nested ScrollView (or View that has dragging attached to it). I would like to have nested view onDrag() to be detected only on horizontal scrolling and not on vertical one. Vertical scrolling should be propagated to wrapper List.
Well, the following demo of horizontal scroll view inside vertical list should be working, and it is, unless List for now has known "refresh" defect. So... I decided to post it just for demo... maybe with next SwiftUI/iOS update it will be fixed automatically.
Also I provided variant of horizontal scroll view inside vertical scroll view, as a workaround to List issue. Hope any of those would be helpful.
So, demo code for "vertical drag anywhere propagated to vertical scrolling container, horizontal drag works only in horizontal scroll view"
A - Working variant of body based on vertical ScrollView (to replace in below module)
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading) {
ForEach (0..<20, id: \.self) { i in
VStack {
self.rowItem(index: i)
Divider()
}
}
}
}
}
B - "Working" variant with List (having refresh issue)
struct TestScrollInList: View {
var body: some View {
List {
ForEach (0..<20, id: \.self) { i in
self.rowItem(index: i)
}
}
}
func rowItem(index i: Int) -> AnyView {
if i % 2 == 0 {
return AnyView(self.plainItem(index: i))
} else {
return AnyView(self.scrollableItem(index: i))
}
}
func plainItem(index i: Int) -> some View {
Text("Plain item \(i)")
}
func scrollableItem(index i: Int) -> some View {
ScrollView (.horizontal, showsIndicators: false) {
HStack {
ForEach(0..<10) { j in
RoundedRectangle(cornerRadius: 8)
.fill(j % 2 == 0 ? Color.green : Color.yellow)
.frame(width: 100, height: 100)
}
}
}
.frame(maxHeight: 110)
}
}
struct TestScrollInList_Previews: PreviewProvider {
static var previews: some View {
TestScrollInList()
}
}