每次更改@State 时防止reflow/redraw
Prevent reflow/redraw every time @State is changed
每次更改 lastSelectedIndex
的值时,didSelectItemAt
都会导致 UI 到 reflow/redraw,从而导致性能问题。我不确定我是否正确使用 @State
将值从 child 传播到 parent。
P.S。我需要使用 UICollectionView 而不是 swiftui List
或 ScrollView
.
import Foundation
import SwiftUI
struct ContentView: View {
@State var lastSelectedIndex : Int = -1
var body: some View {
ZStack {
CustomCollectionView(lastSelectedIndex: $lastSelectedIndex)
Text("Current Selected Index \(lastSelectedIndex)")
}
}
}
struct CustomCollectionView: UIViewRepresentable {
@Binding var lastSelectedIndex : Int
func makeUIView(context: Context) -> UICollectionView {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.itemSize = CGSize(width: 400, height: 300)
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: CustomCollectionViewCell.reuseId)
collectionView.delegate = context.coordinator
collectionView.dataSource = context.coordinator
collectionView.backgroundColor = .systemBackground
collectionView.isDirectionalLockEnabled = true
collectionView.backgroundColor = UIColor.black
collectionView.showsVerticalScrollIndicator = false
collectionView.showsHorizontalScrollIndicator = false
collectionView.alwaysBounceVertical = false
return collectionView
}
func updateUIView(_ uiView: UICollectionView, context: Context) {
uiView.reloadData()
}
func makeCoordinator() -> CustomCoordinator {
CustomCoordinator(self)
}
}
class CustomCoordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate {
let parent:CustomCollectionView
init(_ parent:CustomCollectionView) {
self.parent = parent
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
100
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CustomCollectionViewCell.reuseId, for: indexPath) as! CustomCollectionViewCell
cell.backgroundColor = UIColor.red
cell.label.text = "Current Index is \(indexPath.row)"
NSLog("Called for Index \(indexPath.row)")
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
parent.lastSelectedIndex = indexPath.row
}
}
class CustomCollectionViewCell: UICollectionViewCell {
static let reuseId = "customCell"
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
label.numberOfLines = 0
contentView.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
引起依赖视图刷新是@State
的一个特性。在可表示的变化依赖状态调用 updateUIView
的情况下,因此,当您将 reloadData
放入其中时 - 它已重新加载:
func updateUIView(_ uiView: UICollectionView, context: Context) {
// Either remove reload from here (ig. make it once in makeUIView to load
// content, or make reload here only conditionally depending on some parameter
// which really needs collection to be reloaded
// uiView.reloadData()
}
快速修复
您可以为此目的使用 ObservableObject
class Model: ObservableObject {
@Published var index: Int
init(index: Int) { self.index = index }
}
为您的文本字段创建一个容器视图,该视图将在此模型更改时更新:
struct IndexPreviewer: View {
@ObservedObject var model: Model
var body: Text { Text("Current Selected Index \(model.index)") }
}
然后在您的 ContentView 中包含此模型和观察者:
struct ContentView: View {
private let model = Model(index: -1)
var body: some View {
VStack {
IndexPreviewer(model: model)
CustomCollectionView(lastSelectedIndex: index)
}
}
var index: Binding<Int> {
Binding {model.index} set: {model.index = [=12=]}
}
}
说明
问题是,一旦您更新 @State
属性,包含视图的 body
将被重新计算。因此,您不能在包含您的集合视图的视图上创建 @State 属性,因为每次您 select 一个不同的单元格时,都会向您的容器发送一条消息,该容器将重新评估它的主体包含集合视图。因此,集合视图将刷新并重新加载您的数据,就像 Asperi 在他的回答中写的那样。
那么你能做些什么来解决这个问题呢?从容器视图中删除状态 属性 包装器。因为当您更新 lastSelectedIndex
时,您的容器视图 (ContentView
) 不应再次呈现。但是您的文本视图应该更新。因此,您应该将 Text 视图包装在一个单独的视图中,该视图 observes selection 索引。
这就是 ObservableObject 发挥作用的地方。它是一个 class 可以在自身上存储状态数据,而不是直接存储在视图的 属性 中。
那么为什么 IndexPreviewer
在模型更改时更新而 ContentView
不更新,您可能会问?那是因为 @ObservedObject
属性 包装器。将此添加到视图将在关联的 ObservableObject 更改时刷新视图。这就是为什么我们 不 在 ContentView 中包含 @ObservedObject
但我们在 IndexPreviewer
.
中包含它
How/Where 要存储您的模型?
为了简单起见,我将 model
作为常量 属性 添加到 ContentView
。然而,当 ContentView 不是您的 SwiftUI 层次结构的根视图时,这不是一个好主意。
举例来说,您的内容视图还从其父级收到 Bool
值:
struct Wrapper: View {
@State var toggle = false
var body: some View {
VStack {
Toggle("toggle", isOn: $toggle)
ContentView(toggle: toggle)
}
}
}
struct ContentView: View {
let toggle: Bool
private let model = Model(index: -1)
...
}
当您 运行 在 iOS 13 或 14 上尝试单击集合视图单元格然后更改开关时,您会看到 selected 索引将重置更改切换时为 -1。为什么会这样?
当您单击切换按钮时,@State var toggle
将更改,并且由于它使用 @State 属性 包装器,因此将重新计算视图的 body
。所以另一个 ContentView
将被构造,并且还有一个新的 Model
对象。
有两种方法可以防止这种情况发生。一种方法是将您的模型在层次结构中向上移动。但这会在视图层次结构的顶部创建一个混乱的根视图。在某些情况下,最好将瞬态 UI 状态保留在包含 UI 组件的本地。这可以通过一个未记录的技巧来实现,即为您的模型对象使用 @State
。就像我说的,它目前(2020 年 7 月)没有记录,但使用 @State 包装的属性将在 UI 更新中保留它们的值。
长话短说:您可能应该使用以下方式存储您的模型:
struct ContentView: View {
@State private var model = Model(index: -1)
lastSelectedIndex
的值时,didSelectItemAt
都会导致 UI 到 reflow/redraw,从而导致性能问题。我不确定我是否正确使用 @State
将值从 child 传播到 parent。
P.S。我需要使用 UICollectionView 而不是 swiftui List
或 ScrollView
.
import Foundation
import SwiftUI
struct ContentView: View {
@State var lastSelectedIndex : Int = -1
var body: some View {
ZStack {
CustomCollectionView(lastSelectedIndex: $lastSelectedIndex)
Text("Current Selected Index \(lastSelectedIndex)")
}
}
}
struct CustomCollectionView: UIViewRepresentable {
@Binding var lastSelectedIndex : Int
func makeUIView(context: Context) -> UICollectionView {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.itemSize = CGSize(width: 400, height: 300)
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: CustomCollectionViewCell.reuseId)
collectionView.delegate = context.coordinator
collectionView.dataSource = context.coordinator
collectionView.backgroundColor = .systemBackground
collectionView.isDirectionalLockEnabled = true
collectionView.backgroundColor = UIColor.black
collectionView.showsVerticalScrollIndicator = false
collectionView.showsHorizontalScrollIndicator = false
collectionView.alwaysBounceVertical = false
return collectionView
}
func updateUIView(_ uiView: UICollectionView, context: Context) {
uiView.reloadData()
}
func makeCoordinator() -> CustomCoordinator {
CustomCoordinator(self)
}
}
class CustomCoordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate {
let parent:CustomCollectionView
init(_ parent:CustomCollectionView) {
self.parent = parent
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
100
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CustomCollectionViewCell.reuseId, for: indexPath) as! CustomCollectionViewCell
cell.backgroundColor = UIColor.red
cell.label.text = "Current Index is \(indexPath.row)"
NSLog("Called for Index \(indexPath.row)")
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
parent.lastSelectedIndex = indexPath.row
}
}
class CustomCollectionViewCell: UICollectionViewCell {
static let reuseId = "customCell"
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
label.numberOfLines = 0
contentView.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
引起依赖视图刷新是@State
的一个特性。在可表示的变化依赖状态调用 updateUIView
的情况下,因此,当您将 reloadData
放入其中时 - 它已重新加载:
func updateUIView(_ uiView: UICollectionView, context: Context) {
// Either remove reload from here (ig. make it once in makeUIView to load
// content, or make reload here only conditionally depending on some parameter
// which really needs collection to be reloaded
// uiView.reloadData()
}
快速修复
您可以为此目的使用 ObservableObject
class Model: ObservableObject {
@Published var index: Int
init(index: Int) { self.index = index }
}
为您的文本字段创建一个容器视图,该视图将在此模型更改时更新:
struct IndexPreviewer: View {
@ObservedObject var model: Model
var body: Text { Text("Current Selected Index \(model.index)") }
}
然后在您的 ContentView 中包含此模型和观察者:
struct ContentView: View {
private let model = Model(index: -1)
var body: some View {
VStack {
IndexPreviewer(model: model)
CustomCollectionView(lastSelectedIndex: index)
}
}
var index: Binding<Int> {
Binding {model.index} set: {model.index = [=12=]}
}
}
说明
问题是,一旦您更新 @State
属性,包含视图的 body
将被重新计算。因此,您不能在包含您的集合视图的视图上创建 @State 属性,因为每次您 select 一个不同的单元格时,都会向您的容器发送一条消息,该容器将重新评估它的主体包含集合视图。因此,集合视图将刷新并重新加载您的数据,就像 Asperi 在他的回答中写的那样。
那么你能做些什么来解决这个问题呢?从容器视图中删除状态 属性 包装器。因为当您更新 lastSelectedIndex
时,您的容器视图 (ContentView
) 不应再次呈现。但是您的文本视图应该更新。因此,您应该将 Text 视图包装在一个单独的视图中,该视图 observes selection 索引。
这就是 ObservableObject 发挥作用的地方。它是一个 class 可以在自身上存储状态数据,而不是直接存储在视图的 属性 中。
那么为什么 IndexPreviewer
在模型更改时更新而 ContentView
不更新,您可能会问?那是因为 @ObservedObject
属性 包装器。将此添加到视图将在关联的 ObservableObject 更改时刷新视图。这就是为什么我们 不 在 ContentView 中包含 @ObservedObject
但我们在 IndexPreviewer
.
How/Where 要存储您的模型?
为了简单起见,我将 model
作为常量 属性 添加到 ContentView
。然而,当 ContentView 不是您的 SwiftUI 层次结构的根视图时,这不是一个好主意。
举例来说,您的内容视图还从其父级收到 Bool
值:
struct Wrapper: View {
@State var toggle = false
var body: some View {
VStack {
Toggle("toggle", isOn: $toggle)
ContentView(toggle: toggle)
}
}
}
struct ContentView: View {
let toggle: Bool
private let model = Model(index: -1)
...
}
当您 运行 在 iOS 13 或 14 上尝试单击集合视图单元格然后更改开关时,您会看到 selected 索引将重置更改切换时为 -1。为什么会这样?
当您单击切换按钮时,@State var toggle
将更改,并且由于它使用 @State 属性 包装器,因此将重新计算视图的 body
。所以另一个 ContentView
将被构造,并且还有一个新的 Model
对象。
有两种方法可以防止这种情况发生。一种方法是将您的模型在层次结构中向上移动。但这会在视图层次结构的顶部创建一个混乱的根视图。在某些情况下,最好将瞬态 UI 状态保留在包含 UI 组件的本地。这可以通过一个未记录的技巧来实现,即为您的模型对象使用 @State
。就像我说的,它目前(2020 年 7 月)没有记录,但使用 @State 包装的属性将在 UI 更新中保留它们的值。
长话短说:您可能应该使用以下方式存储您的模型:
struct ContentView: View {
@State private var model = Model(index: -1)