【问题标题】:UICollectionViewCell created from XIB will cause flickering during drag and drop从XIB创建的UICollectionViewCell会导致拖放时闪烁
【发布时间】:2021-08-27 06:38:15
【问题描述】:

我实现了一个简单的拖放示例。

import UIKit

class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {

    private var collectionView: UICollectionView?
    
    var colors: [UIColor] = [
        .link,
        .systemGreen,
        .systemBlue,
        .red,
        .systemOrange,
        .black,
        .systemPurple,
        .systemYellow,
        .systemPink,
        .link,
        .systemGreen,
        .systemBlue,
        .red,
        .systemOrange,
        .black,
        .systemPurple,
        .systemYellow,
        .systemPink
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .vertical
        layout.itemSize = CGSize(width: view.frame.size.width/3.2, height: view.frame.size.width/3.2)
        layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        
        //collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
        
        let customCollectionViewCellNib = CustomCollectionViewCell.getUINib()
        collectionView?.register(customCollectionViewCellNib, forCellWithReuseIdentifier: "cell")
        
        collectionView?.delegate = self
        collectionView?.dataSource = self
        collectionView?.backgroundColor = .white
        view.addSubview(collectionView!)
        
        let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressGesture))
        collectionView?.addGestureRecognizer(gesture)
    }

    @objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
        guard let collectionView = collectionView else {
            return
        }
        
        switch gesture.state {
        case .began:
            guard let targetIndexPath = collectionView.indexPathForItem(at: gesture.location(in: self.collectionView)) else {
                return
            }
            collectionView.beginInteractiveMovementForItem(at: targetIndexPath)
        case .changed:
            collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: collectionView))
        case .ended:
            collectionView.endInteractiveMovement()
        default:
            collectionView.cancelInteractiveMovement()
        }
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        collectionView?.frame = view.bounds
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return colors.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
        
        cell.backgroundColor = colors[indexPath.row]
        
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: view.frame.size.width/3.2, height: view.frame.size.width/3.2)
    }
    
    func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
        return true
    }
    
    func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        let item = colors.remove(at: sourceIndexPath.row)
        colors.insert(item, at: destinationIndexPath.row)
    }
}

但是,我注意到,如果我的 UICollectionViewCell 是使用 XIB 创建的,它会在拖放过程中随机出现闪烁行为。


CustomCollectionViewCell 是一个非常简单的代码。

CustomCollectionViewCell.swift

import UIKit

extension UIView {
    static func instanceFromNib() -> Self {
        return getUINib().instantiate(withOwner: self, options: nil)[0] as! Self
    }
    
    static func getUINib() -> UINib {
        return UINib(nibName: String(describing: self), bundle: nil)
    }
}


class CustomCollectionViewCell: UICollectionViewCell {

    override func awakeFromNib() {
        super.awakeFromNib()
        // Initialization code
    }

}

闪烁

通过使用以下代码

let customCollectionViewCellNib = CustomCollectionViewCell.getUINib()
collectionView?.register(customCollectionViewCellNib, forCellWithReuseIdentifier: "cell")

它将具有以下随机闪烁行为 - https://youtu.be/CbcUAHlRJKI


无闪烁

但是,如果改用下面的代码

collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")

一切正常。没有闪烁的行为 - https://youtu.be/QkV2HlIrXK8


我可以知道为什么会这样吗?当我的自定义 UICollectionView 是从 XIB 创建时,如何避免闪烁行为?

请注意,闪烁行为并非一直发生。它随机发生。使用真实的 iPhone 设备比模拟器更容易重现问题。

这是完整的示例代码 - https://github.com/yccheok/xib-view-cell-cause-flickering

【问题讨论】:

    标签: ios swift uicollectionview uikit


    【解决方案1】:

    虽然我们正在重新排列 UICollectionView 中的单元格(手势处于活动状态),但它会为我们处理所有单元格移动(无需我们担心在重新排列过程中更改 dataSource)。

    在此重新排列手势结束时,UICollectionView 理所当然地期望我们将在我们的 dataSource 中反映您在此处所做的正确更改。

    func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        let item = colors.remove(at: sourceIndexPath.row)
        colors.insert(item, at: destinationIndexPath.row)
    }
    

    由于UICollectionView 期望我们这边的dataSource 更新,它执行以下步骤 -

    1. 致电我们的collectionView(_:, moveItemAt:, to:) 实现,让我们有机会反映dataSource 中的变化。
    2. 从调用#1 调用destinationIndexPath 值的collectionView(_:, cellForItemAt:) 实现,以从头开始在该indexPath 处重新创建一个新单元格。
    好的,但是即使这是该 indexPath 处的正确单元格,它为什么还要执行第 2 步?

    这是因为UICollectionView 不确定您是否真的对dataSource 进行了这些更改。如果您不进行这些更改会怎样? - 现在你的 dataSourceUI 不同步了。

    为了确保您的dataSource 更改正确反映在UI 中,它必须执行此步骤。

    现在,当重新创建单元格时,您有时会看到闪烁。让 UI 第一次重新加载,在第一行的 cellForItemAt: 实现中放置一个断点并重新排列一个单元格。重新排列完成后,您的程序将在该断点处暂停,您可以在屏幕上看到以下内容。


    为什么 UICollectionViewCell 类(不是 XIB)不会发生这种情况?

    确实如此(正如其他人所指出的那样) - 它的频率较低。使用上面的步骤通过下一个断点,你就可以在那个状态下捕获它。


    如何解决?
    1. 获取对当前正在拖动的单元格的引用。
    2. cellForItemAt: 实现返回此实例。
    var currentlyBeingDraggedCell: UICollectionViewCell?
    var willRecreateCellAtDraggedIndexPath: Bool = false
    @objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
        guard let cv = collectionView else { return }
        
        let location = gesture.location(in: cv)
        switch gesture.state {
        case .began:
            guard let targetIndexPath = cv.indexPathForItem(at: location) else { return }
            currentlyBeingDraggedCell = cv.cellForItem(at: targetIndexPath)
            cv.beginInteractiveMovementForItem(at: targetIndexPath)
            
        case .changed:
            cv.updateInteractiveMovementTargetPosition(location)
            
        case .ended:
            willRecreateCellAtDraggedIndexPath = true
            cv.endInteractiveMovement()
        
        default:
            cv.cancelInteractiveMovement()
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        
        if willRecreateCellAtDraggedIndexPath,
           let currentlyBeingDraggedCell = currentlyBeingDraggedCell {
            self.willRecreateCellAtDraggedIndexPath = false
            self.currentlyBeingDraggedCell = nil
            return currentlyBeingDraggedCell
        }
        
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
        
        cell.contentView.backgroundColor = colors[indexPath.item]
            
        return cell
    }
    

    这会 100% 解决问题吗?

    没有。 UICollectionView 仍然会从它的视图层次结构中删除单元格并要求我们提供一个新单元格 - 我们只是为它提供一个现有的单元格实例(根据我们自己的实现,我们知道这将是正确的)。

    您仍然可以在它再次出现之前从 UI 中消失的状态下捕捉它。然而这次几乎没有工作要做,所以它会明显更快,你会看到闪烁更少。


    奖金

    iOS 15 似乎正在通过 UICollectionView.reconfigureItems API 解决类似问题。请参阅以下 Twitter 线程中的解释。 这些改进是否会重新安排,我们将拭目以待。


    其他观察

    您的 UICollectionViewCell 子类的 XIB 如下所示

    但它应该如下所示(第一个缺少 contentView 包装器,默认情况下,当您将 Collection View Cell 从视图库拖到 XIB 或创建 UICollectionViewCell 子类时,您会得到这个与 XIB)。

    你的实现使用 -

    cell.backgroundColor = colors[indexPath.row]
    

    您应该使用 contentView 进行所有 UI 自定义,还要注意 indexPath.item(vs row) 更适合 cellForItemAt: 术语(尽管这些值没有差异)。 cellForRowAt: & indexPath.row 更适合UITableView 实例。

    cell.contentView.backgroundColor = colors[indexPath.item]
    

    更新

    我应该在生产中的应用中使用此解决方法吗?

    没有。

    正如 OP 在下面的 cmets 中指出的那样 -

    建议的解决方法有 2 个缺点。

    (1) 缺少单元格

    (2) 内容单元格错误。

    这在https://www.youtube.com/watch?v=uDRgo0Jczuw 中清晰可见即使您在if 块内执行显式currentlyBeingDraggedCell.backgroundColor = colors[indexPath.item],错误的内容单元格问题仍然存在。


    【讨论】:

    • 感谢您的详细解释。但是,如果我从 XIB 创建 UICollectionViewCell 子类,我不会得到 imgur.com/a/s3olpZ7 中所示的“contentView”。你知道为什么吗?
    • @CheokYanCheng 您可以从 XIB 中删除此单元格,从视图库中手动拖动新的 Collection View Cell 并将其类设置为正确的子类。看到这个 - imgur.com/4Yt4GjA
    • 在阅读 developer.apple.com/documentation/uikit/uicollectionviewcell/… 之前,我永远不会知道这一点。到目前为止,我仍然没有遇到任何不使用 contentView 的问题。你知道为什么 Xcode 默认的 XIB 创建行为没有将 contentView 放在 UICollectionViewCell 中吗?
    • 经过我的一些测试,我觉得使用默认的只有safeArea的XIB是可以的。我猜即使 contentView 在 XIB 中不可见,在 safeArea 下添加的组件也会隐式放置在 contentView 中。我确认我的发现,打印出cell.contentView.subviews.count
    • 感谢您的建议。但是,请注意,建议的解决方法确实有两个缺点。 (1) 缺少单元格 (2) 错误的内容单元格。这可以在youtube.com/watch?v=uDRgo0Jczuw 中显示即使我在if 块内执行显式currentlyBeingDraggedCell.backgroundColor = colors[indexPath.row],仍然存在错误的内容单元问题。
    【解决方案2】:

    闪烁是由在新位置重新创建的单元格引起的。您可以尝试按住单元格。

    (仅显示相关代码)

    // keeps a reference to the cell being dragged
    private weak var draggedCell: UICollectionViewCell?
    // the flag is set when the dragging completes
    private var didInteractiveMovementEnd = false
    
    @objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
        switch gesture.state {
        case .began:
            // keep cell reference
            draggedCell = collectionView.cellForItem(at: targetIndexPath)
            collectionView.beginInteractiveMovementForItem(at: targetIndexPath)
    
        case .ended:
            // reuse the cell in `cellForItem`
            didInteractiveMovementEnd = true
            collectionView.performBatchUpdates {
                collectionView.endInteractiveMovement()
            } completion: { completed in
                self.draggedCell = nil
                self.didInteractiveMovementEnd = false
            }
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        // reuse the dragged cell
        if didInteractiveMovementEnd, let draggedCell = draggedCell {
            return draggedCell
        }
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
        ...
    }
    

    【讨论】:

    • 感谢您的建议。但是,请注意,建议的解决方法确实有两个缺点。 (1) 缺少单元格 (2) 内容单元格错误。这可以在youtube.com/watch?v=uDRgo0Jczuw 中显示,即使我在if 块内执行显式draggedCell.backgroundColor = colors[indexPath.row],仍然存在错误的内容单元问题。
    • @CheokYanCheng 我没有看到我发布的代码存在这些问题。您可以使用您的更改更新您的代码存储库以供审核。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-25
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多