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
inside ForEach
with its range’s upper bound equal to an Int
published variable incremented each time data is updated. In this way a new instance of LazyVGrid
is created on each update so we can make use of LazyVGrid
’s onAppear
method to do some initialization work, in this case scroll a particular item into view.
Here is how it can be implemented:
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
ForEach((viewModel.dataIndex-1..<viewModel.dataIndex), id: \.self) { dataIndex 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)
}
}
.id(1000 + dataIndex)
.onAppear {
print("LazyVGrid, onAppear, #\(dataIndex)")
let targetItem = 32 // arbitrary number
withAnimation(.linear(duration: 0.3)) {
scrollViewProxy.scrollTo(targetItem, anchor: .top)
}
}
}
}
}
.padding(EdgeInsets(top: 20, leading: 0, bottom: 0, trailing: 0))
.onAppear {
load(availableWidth: geometry.size.width)
}
.onChange(of: geometry.size.width) { newWidth in
// Available screen width changed.
// We need to re-build the grid to show more or less columns respectively.
// To achive this, we re-load data.
load(availableWidth: geometry.size.width)
}
}
}
private func load(availableWidth: CGFloat){
let columnsNumber = ScreenWidth.getNumberOfColumns(width: Int(availableWidth))
Task {
await viewModel.loadData(columnsNumber)
}
}
}
ViewModel
final class ViewModel: ObservableObject {
/*@Published*/ private(set) var data: [String] = []
@Published private(set) var dataIndex = 0
var rows: Int = 46 // arbitrary number
func loadData(_ cols: Int) async {
let newData = loadDataHelper(cols)
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.data = newData
_self.dataIndex += 1
}
}
}
private func loadDataHelper(_ cols: Int) -> [String] {
var dataGrid : [String] = []
for index in 0..<rows*cols {
dataGrid.append("\(index) Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")
}
return dataGrid
}
}
--------------------------------------------------------------
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 using PreferenceKey
to collect data.
The .onAppear()
modifier is used to set "rendered" status while the .onDisappear()
modifier is used to reset the status.
struct RenderedPreferenceKey: PreferenceKey {
static var defaultValue: Int = 0
static func reduce(value: inout Int, nextValue: () -> Int) {
value = value + nextValue() // sum all those that remain to-be-rendered
}
}
struct RenderModifier: ViewModifier {
@State private var toBeRendered = 1
func body(content: Content) -> some View {
content
.preference(key: RenderedPreferenceKey.self, value: toBeRendered)
.onAppear { toBeRendered = 0 }
.onDisappear { /*reset*/ toBeRendered = 1 }
}
}
Convenience methods on View:
extension View {
func trackRendering() -> some View {
self.modifier(RenderModifier())
}
func onRendered(_ perform: @escaping () -> Void) -> some View {
self.onPreferenceChange(RenderedPreferenceKey.self) { toBeRendered in
// Invoke the callback only when all tracked statuses have been set to 0,
// which happens when all of their .onAppear() modifiers are called
if toBeRendered == 0 { perform() }
}
}
}
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.
final class ViewModel: ObservableObject {
@Published private(set) var data: [String] = []
var dataLoadedFlag: Bool = false
var rows: Int = 46 // arbitrary number
func loadData(_ cols: Int) async {
// Clear data to make the grid remove its items.
// This is necessary for the .onDisappear() modifier to get called on grid items.
if !data.isEmpty {
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.data = []
}
}
// A short pause is necessary for a grid to have time to remove its items.
// This is crucial for scrolling grid for a specific item.
await Task.sleep(UInt64(0.1 * Double(NSEC_PER_SEC)))
}
let newData = loadDataHelper(cols)
DispatchQueue.main.async { [weak self] in
if let _self = self {
_self.dataLoadedFlag = true
_self.data = newData
}
}
}
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]
}
}
An example of usage of the trackRendering()
and onRendered()
functions:
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)
// set RenderModifier
.trackRendering()
}
}
.onAppear {
load(availableWidth: geometry.size.width)
}
.onChange(of: geometry.size.width) { newWidth in
// Available screen width changed.
// We need to re-build the grid to show more or less columns respectively.
// To achive this, we re-load data.
load(availableWidth: geometry.size.width)
}
.onRendered {
// do scrolling only if data was loaded,
// that is the grid was re-built
if viewModel.dataLoadedFlag {
/*reset*/ viewModel.dataLoadedFlag = false
let targetItem = 32 // arbitrary number
scrollViewProxy.scrollTo(targetItem, anchor: .top)
}
}
}
}
}
}
private func load(availableWidth: CGFloat){
let columnsNumber = ScreenWidth.getNumberOfColumns(width: Int(availableWidth))
Task {
await viewModel.loadData(columnsNumber)
}
}
}