UICollectionView启用交互式键盘关闭时单元格动画异常问题
我之前做聊天界面时也碰到过几乎一模一样的问题,这种异常动画本质是视图翻转变换和系统自动布局/键盘交互的布局更新逻辑冲突导致的。结合你给出的代码和尝试过的方案,我给你几个针对性的解决思路:
核心问题分析
你通过CGAffineTransform(scaleX: 1, y: -1)翻转整个CollectionView和单元格,虽然实现了聊天消息从底部往上排列的效果,但会让系统对CollectionView的contentOffset、contentSize的计算逻辑产生坐标反转。当开启交互式键盘关闭时,拖动键盘会动态改变CollectionView的可视区域,此时系统的自动布局动画和你手动缓存的单元格尺寸、翻转后的坐标系统不兼容,就会出现单元格“跳闪”或滑动动画。
解决方案:替换翻转方案,改用自定义FlowLayout实现底部布局
最彻底的解决方式是放弃视图翻转,直接让CollectionView的布局从底部开始排列,这样完全避免变换带来的坐标冲突。
1. 自定义聊天专用FlowLayout
创建一个继承自UICollectionViewFlowLayout的类,重写布局逻辑让元素从底部开始排列:
class ChatBottomUpFlowLayout: UICollectionViewFlowLayout { override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { guard let originalAttributes = super.layoutAttributesForElements(in: rect), let collectionView = collectionView else { return originalAttributes } // 计算内容总高度和CollectionView可视高度 let totalContentHeight = collectionView.contentSize.height let collectionViewVisibleHeight = collectionView.bounds.height - sectionInset.top - sectionInset.bottom // 翻转每个元素的Y坐标,实现从底部布局 return originalAttributes.map { attributes in let newY = totalContentHeight - attributes.frame.maxY + collectionViewVisibleHeight + sectionInset.top let adjustedAttributes = attributes.copy() as! UICollectionViewLayoutAttributes adjustedAttributes.frame = CGRect(x: attributes.frame.origin.x, y: newY, width: attributes.frame.width, height: attributes.frame.height) return adjustedAttributes } } // 当CollectionView bounds变化时(比如键盘弹出/收起),重新计算布局 override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true } }
2. 修改CollectionView初始化代码
移除翻转相关的代码,改用自定义FlowLayout:
lazy var chatCollectionView: UICollectionView = { let collectionViewFlowLayout = ChatBottomUpFlowLayout() collectionViewFlowLayout.scrollDirection = .vertical let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewFlowLayout) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.backgroundColor = .white collectionView.showsHorizontalScrollIndicator = false collectionView.showsVerticalScrollIndicator = false collectionView.register(OutgoingTextCell.self, forCellWithReuseIdentifier: OutgoingTextCell.className) collectionView.register(IncomingTextCell.self, forCellWithReuseIdentifier: IncomingTextCell.className) collectionView.register(OutgoingImageCell.self, forCellWithReuseIdentifier: OutgoingImageCell.className) collectionView.register(IncomingImageCell.self, forCellWithReuseIdentifier: IncomingImageCell.className) // 移除翻转相关代码 // collectionView.transform = CGAffineTransform(scaleX: 1, y: -1) // collectionView.semanticContentAttribute = .forceLeftToRight collectionView.keyboardDismissMode = .interactive return collectionView }()
3. 移除单元格内的翻转代码
在OutgoingTextCell和其他单元格中,删除containerView.transform的设置:
func configure(model: MessageViewModel) { // 移除这行翻转代码 // containerView.transform = CGAffineTransform(scaleX: 1, y: -1) contentLabel.text = model.content timeLabel.text = model.time checkMarkImageView.isHidden = !model.isSent }
4. 修正单元格尺寸计算逻辑
之前你用view.frame.width计算文本宽度,这可能在键盘弹出时因为视图布局变化导致宽度不准确,改用CollectionView的实际宽度:
private func calculateTextHeight(at indexPath: IndexPath) -> CGFloat { // 改用collectionView的宽度,避免视图布局变化带来的误差 let availableWidth = chatCollectionView.bounds.width - 88 let approximateSize = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) let estimatedHeight = NSString(string: presenter.contentForMessage(at: indexPath)).boundingRect(with: approximateSize, options: .usesLineFragmentOrigin, attributes: [.font: DinNextFont.regular.getFont(ofSize: 14)], context: nil).height return estimatedHeight + 40 }
5. 键盘变化时强制刷新布局(可选)
为了确保键盘弹出/收起时布局完全正确,可以在键盘通知中清除尺寸缓存并无动画刷新布局:
// 假设你用Typist监听键盘变化 func setupKeyboardListener() { let typist = Typist() typist .willChangeFrame { [weak self] info in guard let self = self else { return } UIView.performWithoutAnimation { self.cachedSizes.removeAll() self.chatCollectionView.collectionViewLayout.invalidateLayout() self.chatCollectionView.reloadData() } } .start() }
为什么之前的方案无效?
- 禁用动画只能临时屏蔽,但系统布局更新的根源(坐标冲突)没解决;
- 换成UITableView后动画减轻是因为TableView的布局逻辑更简单,但翻转带来的坐标问题依然存在;
- 移除Typist没用是因为问题核心不在键盘通知的封装,而是视图变换和布局的冲突。
这个自定义FlowLayout的方案我在多个聊天项目中用过,完全解决了键盘交互时的动画异常问题,而且布局逻辑更清晰,也避免了翻转带来的其他潜在问题(比如手势识别反向、子视图布局混乱等)。
内容的提问来源于stack exchange,提问作者Vortex

