Search code examples
swiftuilazyvgridlazyvstackonappear

How to detect that LazyVGrid's items get re-built?


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

Solution

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