轮播效果的集合视图布局

自定义 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)
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 229,406评论 6 538
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 99,034评论 3 423
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 177,413评论 0 382
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 63,449评论 1 316
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 72,165评论 6 410
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 55,559评论 1 325
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 43,606评论 3 444
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 42,781评论 0 289
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 49,327评论 1 335
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 41,084评论 3 356
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 43,278评论 1 371
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 38,849评论 5 362
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 44,495评论 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 34,927评论 0 28
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 36,172评论 1 291
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 52,010评论 3 396
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 48,241评论 2 375

推荐阅读更多精彩内容