每次更改@State 时防止reflow/redraw

Prevent reflow/redraw every time @State is changed

每次更改 lastSelectedIndex 的值时,

didSelectItemAt 都会导致 UI 到 reflow/redraw,从而导致性能问题。我不确定我是否正确使用 @State 将值从 child 传播到 parent。

P.S。我需要使用 UICollectionView 而不是 swiftui ListScrollView.

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)