# CollectionViewCoolAnimation **Repository Path**: kmyhy/CollectionViewCoolAnimation ## Basic Information - **Project Name**: CollectionViewCoolAnimation - **Description**: No description available - **Primary Language**: Swift - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2022-02-24 - **Last Updated**: 2022-02-24 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 创建酷炫的 CollectionViewCell 转换动画 新建 iOS App 项目,打开 Main.storyboad,拖入一个 CollectionView,为其创建布局约束如下: 为 CollectionView 创建一个 IBOutlet 连接: ```swift @IBOutlet weak var collectionView: UICollectionView! ``` 新建 swift 文件,充当我们的 model ,这就是我们要渲染在 cell 上的数据: ```swift public struct SalonEntity { // MARK: - Variables /// Name public internal(set) var name: String? /// Address public internal(set) var address: String? // MARK: - Init /// Convenience init public init(name: String, address: String) { self.name = name self.address = address } } ``` 新建 UICollectionViewCell 子类 SalonSelectorCollectionViewCell。打开 SalonSelectorCollectionViewCell.xib,创建如下 UI : SalonSelectorCollectionViewCell 目前还是十分简单: ```swift class SalonSelectorCollectionViewCell: UICollectionViewCell { @IBOutlet weak var containerView: UIView! @IBOutlet weak var salonNameLabel: UILabel! @IBOutlet weak var salonAddressLabel: UILabel! @IBOutlet weak var separatorLine: UIView! func configure(with salon: SalonEntity) { salonNameLabel.text = salon.name salonAddressLabel.text = salon.address } override func prepareForReuse() { super.prepareForReuse() salonNameLabel.text = nil salonAddressLabel.text = nil } } extension UICollectionViewCell { class var reuseIdentifier: String { return NSStringFromClass(self).components(separatedBy: ".").last! } } ``` 打开 ViewController.swift,在 viewDidLoad 中: ```swift collectionView.register(UINib(nibName: "SalonSelectorCollectionViewCell", bundle: nil), forCellWithReuseIdentifier: SalonSelectorCollectionViewCell.reuseIdentifier) collectionView.dataSource = self collectionView.delegate = self ``` 然后实现 UICollectionViewDataSource: ```swift extension ViewController: UICollectionViewDataSource { public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { salons.count } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let selectorCell = collectionView.dequeueReusableCell(withReuseIdentifier: SalonSelectorCollectionViewCell.reuseIdentifier, for: indexPath) as? SalonSelectorCollectionViewCell else { return UICollectionViewCell() } let salon = salons[indexPath.item] selectorCell.configure(with: salon) return selectorCell } } extension ViewController: UICollectionViewDelegate { public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { } func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { } } ``` 运行 App,collect view 中显示出 5 个 cell: 接下来,我们要利用 UICollectionViewDelegate 协议让 collection view 在选中状态下显示一点不同的样式: ```swift public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) guard let cell = collectionView.cellForItem(at: indexPath) as? SalonSelectorCollectionViewCell else { return } cell.containerView.backgroundColor = .lightGray } func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { guard let cell = collectionView.cellForItem(at: indexPath) as? SalonSelectorCollectionViewCell else { return } cell.containerView.backgroundColor = .white } ``` 这样当选中一个 cell 时,cell 背景色变成灰色。但这样显然不够酷。我们需要为它添加一点动画。 首先,我们为 SalonSelectorCollectionViewCell 增加一个状态: ```swift enum State { case collapsed case expanded var backgroundColor: UIColor { switch self { case .collapsed: return UIColor.lightGray case .expanded: return .white } } } ``` State 有两种状态:collapsed 和 expanded,二者的不同在于 backgroundColor - collapse 状态下这个值时灰色,而 expanded 状态下为白色,就类似于我们刚才所做的,当 cell 选中时是一个颜色,反选时是另一个颜色。 当然除了背景色外,我们还需要让 cell 在两种不同的状态下做一些 UI 上的改变,比如在 expanded 状态下让 cell 变得更大一点。这需要我们为一些布局约束创建一些 IBOutlet: ```swift @IBOutlet weak var interLabelsPaddingConstraint: NSLayoutConstraint! // 两个 label 间的 padding @IBOutlet weak var separatorLineWidthConstraint: NSLayoutConstraint! // 中间细线的宽 @IBOutlet weak var separatorLineHeightConstraint: NSLayoutConstraint! // 中间细线的高 @IBOutlet weak var containerViewHeightConstraint: NSLayoutConstraint! // 整个 cell 的高 @IBOutlet weak var containerViewWidthConstraint: NSLayoutConstraint! // 整个 cell 的宽 @IBOutlet weak var salonNameLeadingConstraint: NSLayoutConstraint! // 沙龙名(上面的 label)的 leading @IBOutlet weak var salonAddressLeadingConstraint: NSLayoutConstraint! // 沙龙地址(下面的 label)的 leading ``` 同时在 enm State 的定义中,规定在不同状态( collapase 状态和 expanded 状态)下对应约束的 constant 值,总的来说除了背景色的不同外,会让 cell 在 expanded 状态下显得稍大一些,同时 collapsed 状态下中间的分割线是不可见的: ```swift enum State { ... var interLabelPadding: CGFloat { switch self { case .collapsed: return 6 case .expanded: return 56 } } var separatorWidth: CGFloat { switch self { case .collapsed: return 0 case .expanded: return 240 } } var separatorHeight: CGFloat { switch self { case .collapsed: return 0 case .expanded: return 2 } } var salonNameLeadingConstant: CGFloat { switch self { case .collapsed: return 20 case .expanded: return 40 } } var salonAddressLeadingConstant: CGFloat { switch self { case .collapsed: return 60 case .expanded: return 80 } } var containerWidth: CGFloat { switch self { case .collapsed: return 250 case .expanded: return 320 } } var containerHeight: CGFloat { switch self { case .collapsed: return 150 case .expanded: return 200 } } } ``` 然后为 SalonSelecotrCollectionViewCell 增加一个属性: ```swift var state: State = .collapsed { didSet { guard oldValue != state else { return } updateViewConstraints() } } ``` 然后在 updateViewConstraints 方法中,根据不同状态去修改约束常量: ```swift private func updateViewConstraints() { containerView.backgroundColor = state.backgroundColor containerViewWidthConstraint.constant = state.containerWidth containerViewHeightConstraint.constant = state.containerHeight salonNameLeadingConstraint.constant = state.salonNameLeadingConstant salonAddressLeadingConstraint.constant = state.salonAddressLeadingConstant interLabelsPaddingConstraint.constant = state.interLabelPadding separatorLineWidthConstraint.constant = state.separatorWidth separatorLineHeightConstraint.constant = state.separatorHeight layoutIfNeeded() } ``` 当然,默认情况下 cell 是 collapsed 状态(反选): ```swift override func prepareForReuse() { ... state = .collapsed } ``` 回到 view controller 修改 didSelectItemAt 方法: ```swift public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) guard let cell = collectionView.cellForItem(at: indexPath) as? SalonSelectorCollectionViewCell else { return } cell.containerView.backgroundColor = .lightGray UIView.animate(withDuration: 0.3) { cell.state = .expanded } } ``` 实际上,didDeselectItemAt 方法是不必要的,我们可以删除它了。 运行 App,现在我们选中 cell 时,cell 背景色从浅灰变成白色,同时 cell 放大: 通常情况下选择一个 cell 需要你点击它,但我们经常会在某些 app 中看到,有时候 cell 并不需要点击,只需要将它滚动到视图中心就回自动选中,这是怎么做到的? 这实际上利用了 UIScrollView 的相关代理而非 UICollectionView。回到 ViewController.swift,实现如下方法: ```swift func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { var offsetAdjustment = CGFloat.greatestFiniteMagnitude let horizontalCenter = targetContentOffset.pointee.x + collectionView.bounds.width / 2 let targetRect = CGRect(origin: targetContentOffset.pointee, size: collectionView.bounds.size) guard let layoutAttributes = collectionView.collectionViewLayout.layoutAttributesForElements(in: targetRect) else { return } for layoutAttribute in layoutAttributes { let itemHorizontalCenter = layoutAttribute.center.x if abs(itemHorizontalCenter - horizontalCenter) < abs(offsetAdjustment) { offsetAdjustment = itemHorizontalCenter - horizontalCenter } } targetContentOffset.pointee.x += offsetAdjustment } ``` 这样,在滚动 scroll view 时,当你释放手指时,这个方法回自动将 scroll view 滚动的位置调整到 cell 中心对齐,当然,前提是 contentView 有足够的空间(例外情况:第一个 cell 和最后一个 cell)。你可以运行 App 看看效果。 然后定义一个新枚举,用于记录 ScrollView 的滚动状态: ```swift enum SelectionCollectionViewScrollingState { case idle case scrolling(animateSelectionFrame: Bool) } ``` idle 表示 scroll view 已经停止滚动,scrolling 表示还在滚动。在 ViewController 中定义一个 SelectorCollectionViewScrollingState 属性: ```swift private var scrollingState: SelectionCollectionViewScrollingState = .idle { didSet { if scrollingState != oldValue { updateSelection() } } } ``` 这里对 SelectionCollectionViewScrollingState 进行了 != 比较,需要让 SelectionCollectionViewScrollingState 实现 Equatable 协议: ```swift extension SelectionCollectionViewScrollingState: Equatable { public static func ==(lhs: SelectionCollectionViewScrollingState, rhs: SelectionCollectionViewScrollingState) -> Bool { switch (lhs, rhs) { case (.idle, .idle): return true case (.scrolling(_), .scrolling(_)): return true default: return false } } } ``` 当 scrollingState 发生改变时,调用 updateSelection 去修改 cell 的状态: ```swift func updateSelection() { func updateSelection() { UIView.animate(withDuration: 0.15) { () -> Void in guard let indexPath = self.getSelectedIndexPath(), let cell = self.collectionView.cellForItem(at: indexPath) as? SalonSelectorCollectionViewCell else { return } switch self.scrollingState { case .idle: cell.state = .expanded case .scrolling(_): cell.state = .collapsed } } } } func getSelectedIndexPath() -> IndexPath? { let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size) let visiblePoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY) if let visibleIndexPath = collectionView.indexPathForItem(at: visiblePoint) { return visibleIndexPath } return nil } ``` getSelectedIndex() 首先获取 collection view 当前的可视区域的 frame,然后得到它的中心点,调用 collectionView.indexPathForItem() 方法并传入这个中心点,即可知道位于该点的 cell 的 indexPath。 然后实现 scrollView 的两个代理方法: ```swift public func scrollViewDidScroll(_ scrollView: UIScrollView) { scrollingState = .scrolling(animateSelectionFrame: true) } public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollingState = .idle } ``` 这样,当你滚动 collection view 时,滚动到屏幕中央的 cell 会自动选中并呈现 expanded 状态: > 如果视频不能播放,可在此处下载:https://gitee.com/kmyhy/CollectionViewCoolAnimation/raw/master/5.mov 这样还不够酷,我们准备在选中的 cell 外面再添加一个类型取景框的效果: 首先,在 Main.storyboard,拖入一个 view,并为它创建一个 IBOutlet: ```swift @IBOutlet weak var selectionFrameView: UIView! ``` 在 selectionFrameView 上面放入两个 image view,增加相应的约束,宽高 340*220 并让它和 collection view 中央对齐,类似成这样: 注意左下角的那张图片可以让它旋转 180 度:layer.transform.rotation.z = 3.14 类似在 State 枚举所做的,我们将 SelectionCollectionViewScrollingState 的两个状态绑定到另外两个属性: ```swift enum SelectionCollectionViewScrollingState { ... var alpha: CGFloat { switch self { case .idle: return 1 case .scrolling(let animateSelectionFrame): return animateSelectionFrame ? 0 : 1 } } var transform: CGAffineTransform { switch self { case .idle: return .identity case .scrolling(let animateSelectionFrame): return animateSelectionFrame ? CGAffineTransform(scaleX: 1.5, y: 1.5) : CGAffineTransform(scaleX: 1.15, y: 1.15) } } } ``` 当 idle 状态时,selectionFrameView 的 alpha 将被设置为 1,切换到 .scrolling 状态后,alpha 根据 animatedSelectionFrame 而定,为 true 时 = 0,为 false 时 = 1,同时 transform 也会做相应的改变。这样,只需切换 idle/scrolling 状态,就可改变 “取景框”显示/隐藏状态和 frame 大小。 每当选中 cell 都会调用 updateSelection 方法,我们只需在 updateSelection 方法增加这 2 句: ```swift UIView.animate(withDuration: 0.15) { () -> Void in self.selectionFrameView.transform = self.scrollingState.transform self.selectionFrameView.alpha = self.scrollingState.alpha ... } ``` 即可让取景框自动显示,并执行一个微微放大的动画。 然后在 UICollectionViewDelegate 协议的 didSelectItem 方法中,增加 ```swift scrollingState = .scrolling(animateSelectionFrame: false) ``` 这样当用户通过点击而非拖动选择一个 cell 时,“取景框动画”仍然播放。 然后在我们在视图一加载时默认选中第一个 cell。在 viewDidLoad() 中: ```swift DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.updateSelection() } ``` 因为 collection view 在 viewDidLoad 的时候很可能并没有渲染,此时 collection view 可能并没有来得及实例化任何 cell ,导致 update cell 状态失败,因此我们延迟 0.5 秒才调用 updateSelection 方法,以解决此问题。这是一个不完美的解决方案。 > 如果视频不能播放,请在此处下载:https://gitee.com/kmyhy/CollectionViewCoolAnimation/raw/master/7.mov 可以发现,正如前面所说,第一个 cell 和最后一个 cell 没有滚动到屏幕中央。这可以通过让 ViewController 实现 UICollectionViewDelegateFlowLayout 协议来解决: ```swift extension ViewController: UICollectionViewDelegateFlowLayout { public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { let padding = (collectionView.bounds.width - SalonSelectorCollectionViewCell.State.expanded.containerWidth) / 2 return UIEdgeInsets(top: 10, left: padding, bottom: 10, right: padding) } } ``` 通过调整 cell 的左右 padding ,让 cell 自动居中显示。最终效果如下: > 如果视频不能播放,请到此处下载:https://gitee.com/kmyhy/CollectionViewCoolAnimation/raw/master/8.mov