In my app, LazyVGrid
re-builds its contents multiple times. The number of items in the grid may vary or remain the same. Each time a particular item must be scrolled into view programmatically.
When the LazyVGrid
first appears, an item can be scrolled into view using the onAppear()
modifier.
Is there any way of detecting the moment when the LazyVGrid
finishes re-building its items next time so that the grid can be safely scrolled?
Here is my code:
Grid
struct Grid: View {
@ObservedObject var viewModel: ViewModel
var columns: [GridItem] {
Array(repeating: .init(.flexible(), alignment: .topLeading), count: viewModel.data.count / viewModel.rows)
}
var body: some View {
GeometryReader { geometry in
ScrollView {
ScrollViewReader { scrollViewProxy in
LazyVGrid(columns: columns) {
let rowsCount = viewModel.rows
let columsCount = columns.count
ForEach((0..<rowsCount*columsCount), id: \.self) { index in
let data = viewModel.getData(for: index)
Text(data)
.id(index)
}
}
.onAppear {
// Scroll a particular item into view
let targetIndex = 32 // an arbitrary number for simplicity sake
scrollViewProxy.scrollTo(targetIndex, anchor: .top)
}
.onChange(of: geometry.size.width) { newWidth in
// Available screen width changed, for example on device rotation
// We need to re-build the grid to show more or less columns respectively.
// To achive this, we re-load data
// Problem: how to detect the moment when the LazyVGrid
// finishes re-building its items
// so that the grid can be safely scrolled?
let availableWidth = geometry.size.width
let columnsNumber = ScreenWidth.getNumberOfColumns(width: Int(availableWidth))
Task {
await viewModel.loadData(columnsNumber)
}
}
}
}
}
}
}
Helper enum to determine the number of columns to show in the grid
enum ScreenWidth: Int, CaseIterable {
case extraSmall = 320
case small = 428
case middle = 568
case large = 667
case extraLarge = 1080
static func getNumberOfColumns(width: Int) -> Int {
var screenWidth: ScreenWidth = .extraSmall
for w in ScreenWidth.allCases {
if width >= w.rawValue {
screenWidth = w
}
}
var numberOfColums: Int
switch screenWidth {
case .extraSmall:
numberOfColums = 2
case .small:
numberOfColums = 3
case .middle:
numberOfColums = 4
case .large:
numberOfColums = 5
case .extraLarge:
numberOfColums = 8
}
return numberOfColums
}
}
Simplified view model
final class ViewModel: ObservableObject {
@Published private(set) var data: [String] = []
var rows: Int = 26
init() {
data = loadDataHelper(3)
}
func loadData(_ cols: Int) async {
// emulating data loading latency
await Task.sleep(UInt64(1 * Double(NSEC_PER_SEC)))
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.data = _self.loadDataHelper(cols)
}
}
}
private func loadDataHelper(_ cols: Int) -> [String] {
var dataGrid : [String] = []
for index in 0..<rows*cols {
dataGrid.append("\(index) Lorem ipsum dolor sit amet")
}
return dataGrid
}
func getData(for index: Int) -> String {
if (index > data.count-1){
return "No data"
}
return data[index]
}
}
I found two solutions.
The first one is to put
LazyVGrid
insideForEach
with its range’s upper bound equal to anInt
published variable incremented each time data is updated. In this way a new instance ofLazyVGrid
is created on each update so we can make use ofLazyVGrid
’sonAppear
method to do some initialization work, in this case scroll a particular item into view.Here is how it can be implemented:
ViewModel
--------------------------------------------------------------
The second approach is based on the solution proposed by @NewDev.
The idea is to track grid items' "rendered" status and fire a callback once they have appeared after the grid re-built its contents in response to viewmodel's data change.
RenderModifier
keeps track of grid item's "rendered" status usingPreferenceKey
to collect data. The.onAppear()
modifier is used to set "rendered" status while the.onDisappear()
modifier is used to reset the status.Convenience methods on View:
Before loading new data the view model clears its current data to make the grid remove its contents. This is necessary for the
.onDisappear()
modifiers to get called on grid items.An example of usage of the
trackRendering()
andonRendered()
functions: