Swift UICollectionView单元格上滑展开实现方案咨询
实现UICollectionView单元格上滑展开效果(Swift)
我来帮你实现这个类似Todo Concept的单元格上滑展开效果,核心思路是通过动态调整单元格高度+手势识别来完成,下面是完整的实现步骤和代码:
1. 核心思路拆解
- 给每个待办项添加展开状态标记,用于控制单元格显示样式
- 自定义UICollectionViewCell,包含折叠/展开两种状态的UI元素
- 给单元格添加拖动手势,识别上滑/下滑动作来触发状态切换
- 基于展开状态动态计算单元格高度,通过批量更新实现平滑过渡
2. 完整代码实现
2.1 数据模型定义
首先定义一个包含展开状态的待办项模型:
struct TodoItem { let title: String let description: String var isExpanded: Bool = false // 标记是否展开 }
2.2 自定义CollectionViewCell
创建带手势识别和动态UI的自定义单元格:
import UIKit protocol TodoCellDelegate: AnyObject { func toggleExpansion(for cell: TodoCollectionViewCell) } class TodoCollectionViewCell: UICollectionViewCell { static let reuseIdentifier = "TodoCollectionViewCell" // MARK: - UI元素 private let titleLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 18, weight: .medium) label.translatesAutoresizingMaskIntoConstraints = false return label }() private let descriptionLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14) label.numberOfLines = 0 label.translatesAutoresizingMaskIntoConstraints = false label.isHidden = true // 默认隐藏展开内容 return label }() // 用于动态控制展开内容高度的约束(可选) private var descriptionHeightConstraint: NSLayoutConstraint! weak var delegate: TodoCellDelegate? // MARK: - 初始化 override init(frame: CGRect) { super.init(frame: frame) setupUI() addPanGesture() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - UI布局 private func setupUI() { contentView.backgroundColor = .white contentView.layer.cornerRadius = 12 contentView.layer.shadowColor = UIColor.black.cgColor contentView.layer.shadowOffset = CGSize(width: 0, height: 2) contentView.layer.shadowOpacity = 0.1 contentView.layer.shadowRadius = 4 contentView.addSubview(titleLabel) contentView.addSubview(descriptionLabel) NSLayoutConstraint.activate([ titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12), descriptionLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), descriptionLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), descriptionLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16) ]) // 可选:保存高度约束用于更精细的动画控制 descriptionHeightConstraint = descriptionLabel.heightAnchor.constraint(equalToConstant: 0) descriptionHeightConstraint.isActive = false } // MARK: - 手势识别 private func addPanGesture() { let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) addGestureRecognizer(panGesture) } @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: self) // 上滑超过30pt触发展开,下滑超过30pt触发折叠(阈值可调整) switch gesture.state { case .ended: if translation.y < -30 { delegate?.toggleExpansion(for: self) } else if translation.y > 30 { delegate?.toggleExpansion(for: self) } default: break } } // MARK: - 配置单元格数据 func configure(with item: TodoItem) { titleLabel.text = item.title descriptionLabel.text = item.description // 根据展开状态更新UI if item.isExpanded { descriptionLabel.isHidden = false descriptionHeightConstraint.isActive = false } else { descriptionLabel.isHidden = true descriptionHeightConstraint.isActive = true } } }
2.3 主ViewController实现
配置CollectionView并处理展开/折叠逻辑:
import UIKit class TodoViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, TodoCellDelegate { private var collectionView: UICollectionView! private var todoItems: [TodoItem] = [ TodoItem(title: "Finish Swift Project", description: "Need to implement collection view cell expansion and fix layout bugs. Make sure to test on different screen sizes."), TodoItem(title: "Grocery Shopping", description: "Buy milk, eggs, bread, vegetables, and a carton of orange juice. Don't forget to check the discount section."), TodoItem(title: "Call Mom", description: "Catch up with mom about her weekend trip to the lake. Ask how the dog is doing and if she needs any help with gardening.") ] override func viewDidLoad() { super.viewDidLoad() setupCollectionView() view.backgroundColor = .systemGray6 } // MARK: - 配置CollectionView private func setupCollectionView() { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .vertical layout.sectionInset = UIEdgeInsets(top: 20, left: 16, bottom: 20, right: 16) layout.minimumLineSpacing = 12 collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.backgroundColor = .clear collectionView.register(TodoCollectionViewCell.self, forCellWithReuseIdentifier: TodoCollectionViewCell.reuseIdentifier) collectionView.dataSource = self collectionView.delegate = self view.addSubview(collectionView) NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) ]) } // MARK: - UICollectionView DataSource func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return todoItems.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TodoCollectionViewCell.reuseIdentifier, for: indexPath) as! TodoCollectionViewCell cell.delegate = self cell.configure(with: todoItems[indexPath.item]) return cell } // MARK: - UICollectionViewDelegateFlowLayout func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let width = view.bounds.width - 32 // 减去左右内边距 let item = todoItems[indexPath.item] if item.isExpanded { // 动态计算展开后的高度 let titleHeight = item.title.height(withFont: .systemFont(ofSize: 18, weight: .medium), width: width - 32) let descriptionHeight = item.description.height(withFont: .systemFont(ofSize: 14), width: width - 32) return CGSize(width: width, height: titleHeight + descriptionHeight + 44) // 44是内边距和间距总和 } else { // 折叠状态固定高度 return CGSize(width: width, height: 60) } } // MARK: - TodoCellDelegate func toggleExpansion(for cell: TodoCollectionViewCell) { guard let indexPath = collectionView.indexPath(for: cell) else { return } // 切换展开状态 todoItems[indexPath.item].isExpanded.toggle() // 平滑更新单元格布局(比reloadData更流畅) collectionView.performBatchUpdates({ self.collectionView.reloadItems(at: [indexPath]) }, completion: nil) } } // MARK: - 扩展:计算文本高度 extension String { func height(withFont font: UIFont, width: CGFloat) -> CGFloat { let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude) let boundingBox = self.boundingRect( with: constraintRect, options: .usesLineFragmentOrigin, attributes: [.font: font], context: nil ) return ceil(boundingBox.height) } }
3. 关键细节说明
- 手势阈值:你可以修改
handlePan方法中的30这个值,调整触发展开/折叠的滑动距离 - 平滑动画:使用
performBatchUpdates来更新单元格,能实现高度变化的平滑过渡 - 动态高度计算:通过字符串扩展计算文本高度,确保展开后的单元格能完整显示所有内容
- 解耦设计:使用代理模式让单元格通知控制器状态变化,避免直接依赖
内容的提问来源于stack exchange,提问作者user9334348
相关产品推荐
相关产品推荐

