轮播效果的集合视图布局

自定义 UICollectionViewFlowLayout,用于创建一个 轮播效果的集合视图布局,即类似卡片轮播(Carousel)的效果

主要功能包括:

中间卡片突出显示:

  • 集合视图的卡片布局中,中间的卡片被放大、完全显示,其透明度和缩放比例也会被设置为较高值。
侧边卡片缩小或部分显示:
  • 中心卡片的两侧卡片会按一定的比例缩小,同时可以设置透明度降低和偏移位置。
流畅的滑动与自动对齐:
  • 用户在滚动时,松手后会自动对齐最近的卡片,使其成为中心可见的卡片。
支持水平或垂直方向滚动:
  • 滚动方向可以是水平或垂直,并且可以根据需要调整卡片的排列方式。
自定义间距与显示模式:
  • 通过 CarouselFlowLayoutSpacingMode 提供两种间距模式:
    • fixed: 固定的卡片间距。
    • overlap: 重叠模式,可调整可见的偏移量。

\color{Red} {\underline{\mathbf{collectionView 在使用时,不能设置 isPagingEnabled 为 True}}}

// 定义一个枚举,用于设置滚动布局的间距模式
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)
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容