如何检测重建了 LazyVGrid 的项目?

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

在我的应用中,LazyVGrid 多次重新构建其内容。网格中的项目数量可能会有所不同或保持不变。每次必须以编程方式将特定项目滚动到视图中。
LazyVGrid 首次出现时,可以使用 onAppear() 修饰符将项目滚动到视图中。
有什么方法可以检测 LazyVGrid 下次完成重建项目的时间,以便可以安全地滚动网格?

这是我的代码:

网格

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

帮助枚举确定要在网格中显示的列数

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

简化视图模型

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

我找到了两个解决方案。

第一个是将 LazyVGrid 放在 ForEach 中,其范围的上限等于 Int 发布的变量,每次更新数据时都会递增。通过这种方式,每次更新都会创建一个 LazyVGrid 的新实例,因此我们可以利用 LazyVGridonAppear 方法进行一些初始化工作,在这种情况下,将特定项目滚动到查看。

实现方法如下:

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

视图模型

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

---------------------------------------- ----------------------

第二种方法基于@NewDev 提出的

想法是跟踪网格项的“已呈现”状态,并在网格重新构建其内容以响应视图模型的数据更改后一旦它们出现就触发回调。

RenderModifier 使用 PreferenceKey 收集数据来跟踪网格项的“呈现”状态。 .onAppear() 修饰符用于设置“已呈现”状态,而 .onDisappear() 修饰符用于重置状态。

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

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

在加载新数据之前,视图模型会清除其当前数据以使网格删除其内容。这是 .onDisappear() 修饰符在网格项目上调用所必需的。

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

trackRendering()onRendered() 函数的用法示例:

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