You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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)
}
...
}

问题原因与修复方案

核心问题

  1. 手动修改cell frame与Compositional Layout冲突:直接修改self.frame会干扰Compositional Layout的自动布局管理,导致系统重新计算时产生偏移。
  2. 不必要的snapshot刷新:展开时调用dataSource.apply会触发collectionView重新布局,加剧滚动偏移。
  3. 正交滚动的自动对齐行为:.continuous滚动行为会在布局变化时自动调整滚动位置,是偏移的诱因之一。

修复步骤

  1. 移除手动修改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调用
    }
    
  2. 删除不必要的snapshot刷新逻辑:
    移除ViewController中ProminentCellDelegate的实现,不需要通过刷新数据源来改变cell尺寸。

  3. 调整正交滚动行为:
    将滚动行为改为.continuousGroupLeadingBoundary,减少自动滚动的触发:

    func createLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout { sectionNumber, env in
            // ... 其他代码
            let section = NSCollectionLayoutSection(group: group)
            section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
            // ... 其他代码
        }
        return layout
    }
    
  4. 优化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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.13 16:55:55