自定义 UICollectionViewFlowLayout,用于创建一个 轮播效果的集合视图布局,即类似卡片轮播(Carousel)的效果
主要功能包括:
中间卡片突出显示:
- 集合视图的卡片布局中,中间的卡片被放大、完全显示,其透明度和缩放比例也会被设置为较高值。
侧边卡片缩小或部分显示:
- 中心卡片的两侧卡片会按一定的比例缩小,同时可以设置透明度降低和偏移位置。
流畅的滑动与自动对齐:
- 用户在滚动时,松手后会自动对齐最近的卡片,使其成为中心可见的卡片。
支持水平或垂直方向滚动:
- 滚动方向可以是水平或垂直,并且可以根据需要调整卡片的排列方式。
自定义间距与显示模式:
- 通过 CarouselFlowLayoutSpacingMode 提供两种间距模式:
- fixed: 固定的卡片间距。
- overlap: 重叠模式,可调整可见的偏移量。
// 定义一个枚举,用于设置滚动布局的间距模式
public enum CarouselFlowLayoutSpacingMode {
/// 每个卡片之间设置一个固定的间距
case fixed(spacing: CGFloat)
/// 卡片之间的重叠效果,通过 visibleOffset 控制侧边卡片的可见部分
case overlap(visibleOffset: CGFloat)
}
// 定义一个轮播滚动布局类,继承自 UICollectionViewFlowLayout
open class CarouselFlowLayout: UICollectionViewFlowLayout {
// 内部结构体,用于记录布局状态
fileprivate struct LayoutState {
var size: CGSize // 集合视图的尺寸
var direction: UICollectionView.ScrollDirection // 滚动方向
// 判断两个布局状态是否相等
func isEqual(_ otherState: LayoutState) -> Bool {
return self.size.equalTo(otherState.size) && self.direction == otherState.direction
}
}
// 属性:侧边项的缩放比例
@IBInspectable open var sideItemScale: CGFloat = 0.9
// 属性:侧边项的透明度
@IBInspectable open var sideItemAlpha: CGFloat = 1.0
// 属性:侧边项的偏移量
@IBInspectable open var sideItemShift: CGFloat = 0.0
// 间距模式,默认为固定间距
open var spacingMode = CarouselFlowLayoutSpacingMode.fixed(spacing: 5)
// 当前布局状态
fileprivate var state = LayoutState(size: CGSize.zero, direction: .horizontal)
// 准备布局的方法,每次布局改变都会调用
override open func prepare() {
super.prepare()
// 获取当前集合视图的布局状态
let currentState = LayoutState(size: self.collectionView!.bounds.size, direction: self.scrollDirection)
// 如果布局状态发生变化,则重新设置集合视图并更新布局
if !self.state.isEqual(currentState) {
self.setupCollectionView()
self.updateLayout()
self.state = currentState
}
}
// 设置集合视图的一些基本属性
fileprivate func setupCollectionView() {
guard let collectionView = self.collectionView else { return }
// 设置集合视图的减速率为快速
if collectionView.decelerationRate != UIScrollView.DecelerationRate.fast {
collectionView.decelerationRate = UIScrollView.DecelerationRate.fast
}
}
// 更新布局
fileprivate func updateLayout() {
guard let collectionView = self.collectionView else { return }
let collectionSize = collectionView.bounds.size // 集合视图的尺寸
let isHorizontal = (self.scrollDirection == .horizontal) // 是否水平滚动
// 计算上下和左右的边距,使单元格居中
let yInset = (collectionSize.height - self.itemSize.height) / 2
let xInset = (collectionSize.width - self.itemSize.width) / 2
self.sectionInset = UIEdgeInsets.init(top: yInset, left: xInset, bottom: yInset, right: xInset)
// 计算缩放后的单元格偏移量
let side = isHorizontal ? self.itemSize.width : self.itemSize.height
let scaledItemOffset = (side - side * self.sideItemScale) / 2
// 根据间距模式设置最小行间距
switch self.spacingMode {
case .fixed(let spacing):
self.minimumLineSpacing = spacing - scaledItemOffset
case .overlap(let visibleOffset):
let fullSizeSideItemOverlap = visibleOffset + scaledItemOffset
let inset = isHorizontal ? xInset : yInset
self.minimumLineSpacing = inset - fullSizeSideItemOverlap
}
}
// 在集合视图的边界发生变化时,是否需要重新计算布局
override open func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
// 返回布局中所有可见单元格的布局属性
override open func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let superAttributes = super.layoutAttributesForElements(in: rect),
let attributes = NSArray(array: superAttributes, copyItems: true) as? [UICollectionViewLayoutAttributes]
else { return nil }
// 修改每个布局属性并返回
return attributes.map({ self.transformLayoutAttributes($0) })
}
// 对单元格的布局属性进行变换(缩放、透明度和位置调整)
fileprivate func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
guard let collectionView = self.collectionView else { return attributes }
let isHorizontal = (self.scrollDirection == .horizontal) // 是否水平滚动
// 集合视图中心点的坐标
let collectionCenter = isHorizontal ? collectionView.frame.size.width / 2 : collectionView.frame.size.height / 2
// 滚动偏移量
let offset = isHorizontal ? collectionView.contentOffset.x : collectionView.contentOffset.y
// 单元格中心点相对于集合视图中心的偏移量
let normalizedCenter = (isHorizontal ? attributes.center.x : attributes.center.y) - offset
// 计算最大距离和当前距离
let maxDistance = (isHorizontal ? self.itemSize.width : self.itemSize.height) + self.minimumLineSpacing
let distance = min(abs(collectionCenter - normalizedCenter), maxDistance)
let ratio = (maxDistance - distance) / maxDistance
// 设置透明度、缩放比例和偏移量
let alpha = ratio * (1 - self.sideItemAlpha) + self.sideItemAlpha
let scale = ratio * (1 - self.sideItemScale) + self.sideItemScale
let shift = (1 - ratio) * self.sideItemShift
attributes.alpha = alpha
attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
attributes.zIndex = Int(alpha * 10)
// 根据滚动方向调整单元格的位置
if isHorizontal {
attributes.center.y = attributes.center.y + shift
} else {
attributes.center.x = attributes.center.x + shift
}
return attributes
}
// 确定目标内容偏移量,用于滚动停止时对齐单元格
override open func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView, !collectionView.isPagingEnabled,
let layoutAttributes = self.layoutAttributesForElements(in: collectionView.bounds)
else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) }
let isHorizontal = (self.scrollDirection == .horizontal) // 是否水平滚动
// 集合视图中心点的坐标
let midSide = (isHorizontal ? collectionView.bounds.size.width : collectionView.bounds.size.height) / 2
// 目标内容偏移量的中心点
let proposedContentOffsetCenterOrigin = (isHorizontal ? proposedContentOffset.x : proposedContentOffset.y) + midSide
var targetContentOffset: CGPoint
if isHorizontal {
// 找到距离目标偏移中心最近的单元格
let closest = layoutAttributes.sorted { abs($0.center.x - proposedContentOffsetCenterOrigin) < abs($1.center.x - proposedContentOffsetCenterOrigin) }.first ?? UICollectionViewLayoutAttributes()
targetContentOffset = CGPoint(x: floor(closest.center.x - midSide), y: proposedContentOffset.y)
} else {
let closest = layoutAttributes.sorted { abs($0.center.y - proposedContentOffsetCenterOrigin) < abs($1.center.y - proposedContentOffsetCenterOrigin) }.first ?? UICollectionViewLayoutAttributes()
targetContentOffset = CGPoint(x: proposedContentOffset.x, y: floor(closest.center.y - midSide))
}
return targetContentOffset
}
}
计算当前页的索引
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// 将当前集合视图的布局转换为自定义的 CarouselFlowLayout
let layout = self.collectionView.collectionViewLayout as! CarouselFlowLayout
// 计算每页的宽度(包括每个项目的宽度和行间距)
let pageSide = layout.itemSize.width + layout.minimumLineSpacing
// 获取当前滚动视图的水平偏移量
let offset = scrollView.contentOffset.x
/**
根据偏移量计算当前页的索引
计算逻辑:
1. 将当前偏移量减去页面宽度的一半以确保正确的页对齐
2. 将结果除以每页宽度以得到精确的位置索引
3. 使用 floor 函数向下取整以确保整数索引
4. 加 1 是为了将偏移量对齐到从 0 开始的索引
*/
let index = Int(floor((offset - pageSide / 2) / pageSide) + 1)
// 打印当前的页索引,用于调试或记录
PrintLog(message: index)
}