如何检测重建了 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
的新实例,因此我们可以利用 LazyVGrid
的 onAppear
方法进行一些初始化工作,在这种情况下,将特定项目滚动到查看。
实现方法如下:
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)
}
}
}
在我的应用中,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
的新实例,因此我们可以利用 LazyVGrid
的 onAppear
方法进行一些初始化工作,在这种情况下,将特定项目滚动到查看。
实现方法如下:
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)
}
}
}