Swift Compositional Layout展开单元格引发意外偏移滚动问题
问题描述
尝试用Compositional Layout实现可展开单元格,但每次展开完成后,单元格会意外向右偏移滚动。明明屏幕左侧有足够空间,不清楚问题成因,想知道如何禁用该行为。

ViewController 代码
class ViewController: UIViewController, UICollectionViewDelegate { lazy var collectionView: UICollectionView = { let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.delegate = self collectionView.contentInsetAdjustmentBehavior = .never collectionView.alwaysBounceVertical = true collectionView.alwaysBounceHorizontal = true return collectionView }() var dataSource: UICollectionViewDiffableDataSource<Section, Item>! var items = [Item]() override func viewDidLoad() { super.viewDidLoad() view.addSubview(collectionView) setupCollectionViewDataSource() ... } func setupCollectionViewDataSource() { dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ProminentCell.cellId, for: indexPath) as! ProminentCell cell.backgroundColor = self.colors.randomElement() cell.numberLabel.text = String(indexPath.item) cell.delegate = self return cell }) updateDataSource() } func createLayout() -> UICollectionViewLayout { let layout = UICollectionViewCompositionalLayout { sectionNumber, env in let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(325), heightDimension: .absolute(708)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.orthogonalScrollingBehavior = .continuous section.interGroupSpacing = 10 return section } return layout } private func updateDataSource() { var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() snapshot.appendSections([.carousel]) snapshot.appendItems(items) dataSource.apply(snapshot, animatingDifferences: true, completion: nil) } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if let cell = collectionView.cellForItem(at: indexPath) as? ProminentCell { cell.currentState = .expanded } } func collectionView(_ collectionView: UICollectionView, didUpdateFocusIn context: UICollectionViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { guard let nextIndexPath = context.nextFocusedIndexPath, let prevIndexPath = context.previouslyFocusedIndexPath else { return } collectionView.isScrollEnabled = false collectionView.scrollToItem(at: nextIndexPath, at: .left, animated: true) } } extension ViewController: ProminentCellDelegate { func expandCell(cell: ProminentCell) { let snapshot = dataSource.snapshot() dataSource.apply(snapshot, animatingDifferences: true, completion: nil) } }
Cell 类代码
protocol ProminentCellDelegate: AnyObject { func expandCell(cell: ProminentCell) func collapseCell(cell: ProminentCell) } enum ProminentCellState { case collapsed, expanded } class ProminentCell: UICollectionViewCell { static let cellId = "cellId" weak var delegate: ProminentCellDelegate? var currentState: ProminentCellState { didSet { if currentState == .collapsed { collapseCell {} } else { expandCell(completion: {}) } } } let imageStringName = "" lazy var backgroundImageView: UIImageView = { let imageView = UIImageView(image: UIImage(named: imageStringName)) imageView.backgroundColor = .systemOrange imageView.translatesAutoresizingMaskIntoConstraints = false imageView.adjustsImageWhenAncestorFocused = true return imageView }() let numberLabel: UILabel = { let label = UILabel() ... return label }() private let expandedWidth = 1035.0 private let collapsedWidth = 325.0 private let animationDuration = 0.33 var closedConstraint: NSLayoutConstraint? = nil var openConstraint: NSLayoutConstraint? = nil override init(frame: CGRect) { currentState = .collapsed super.init(frame: frame) contentView.addSubview(backgroundImageView) contentView.addSubview(numberLabel) contentView.translatesAutoresizingMaskIntoConstraints = false let padding = 10.0 NSLayoutConstraint.activate([ contentView.topAnchor.constraint(equalTo: topAnchor), contentView.leadingAnchor.constraint(equalTo: leadingAnchor), contentView.trailingAnchor.constraint(equalTo: trailingAnchor), contentView.bottomAnchor.constraint(equalTo: bottomAnchor), backgroundImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding), backgroundImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), backgroundImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), backgroundImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding), numberLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), numberLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), ]) closedConstraint = contentView.widthAnchor.constraint(equalToConstant: collapsedWidth) closedConstraint?.isActive = true openConstraint = contentView.widthAnchor.constraint(equalToConstant: expandedWidth) } private func expandCell(completion: () -> Void) { closedConstraint?.isActive = false openConstraint?.isActive = true let curFrame = frame.origin let expandedSize = CGRect(x: curFrame.x, y: curFrame.y, width: expandedWidth, height: frame.size.height) UIViewPropertyAnimator.runningPropertyAnimator(withDuration: animationDuration, delay: 0, options: [.curveLinear]) { self.frame = expandedSize self.layoutIfNeeded() self.contentView.layoutIfNeeded() } delegate?.expandCell(cell: self) } private func collapseCell(completion: () -> Void) { openConstraint?.isActive = false closedConstraint?.isActive = true let curFrame = frame.origin let collapsedSize = CGRect(x: curFrame.x, y: curFrame.y, width: collapsedWidth, height: frame.size.height) UIViewPropertyAnimator.runningPropertyAnimator(withDuration: animationDuration, delay: 0, options: [.beginFromCurrentState]) { self.frame = collapsedSize self.contentView.layoutIfNeeded() self.layoutIfNeeded() } delegate?.collapseCell(cell: self) } ... }
问题原因与修复方案
核心问题
- 手动修改cell frame与Compositional Layout冲突:直接修改
self.frame会干扰Compositional Layout的自动布局管理,导致系统重新计算时产生偏移。 - 不必要的snapshot刷新:展开时调用
dataSource.apply会触发collectionView重新布局,加剧滚动偏移。 - 正交滚动的自动对齐行为:
.continuous滚动行为会在布局变化时自动调整滚动位置,是偏移的诱因之一。
修复步骤
移除手动修改frame的代码:
只保留约束切换和布局刷新逻辑,删除修改self.frame的代码:private func expandCell(completion: () -> Void) { closedConstraint?.isActive = false openConstraint?.isActive = true UIViewPropertyAnimator.runningPropertyAnimator(withDuration: animationDuration, delay: 0, options: [.curveLinear]) { self.contentView.layoutIfNeeded() self.layoutIfNeeded() } // 移除delegate调用,避免触发snapshot刷新 } private func collapseCell(completion: () -> Void) { openConstraint?.isActive = false closedConstraint?.isActive = true UIViewPropertyAnimator.runningPropertyAnimator(withDuration: animationDuration, delay: 0, options: [.beginFromCurrentState]) { self.contentView.layoutIfNeeded() self.layoutIfNeeded() } // 移除delegate调用 }删除不必要的snapshot刷新逻辑:
移除ViewController中ProminentCellDelegate的实现,不需要通过刷新数据源来改变cell尺寸。调整正交滚动行为:
将滚动行为改为.continuousGroupLeadingBoundary,减少自动滚动的触发:func createLayout() -> UICollectionViewLayout { let layout = UICollectionViewCompositionalLayout { sectionNumber, env in // ... 其他代码 let section = NSCollectionLayoutSection(group: group) section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary // ... 其他代码 } return layout }优化cell约束目标:
将宽度约束直接作用于cell本身,而非contentView,让collectionView正确感知尺寸变化:override init(frame: CGRect) { currentState = .collapsed super.init(frame: frame) // ... 其他代码 // 修改约束目标为cell自身 closedConstraint = widthAnchor.constraint(equalToConstant: collapsedWidth) closedConstraint?.isActive = true openConstraint = widthAnchor.constraint(equalToConstant: expandedWidth) }
内容的提问来源于stack exchange,提问作者Kenny Ho
相关产品推荐
相关产品推荐

