阅读 463

CollectionView 的装饰视图自定义布局,糊一张带阴影效果的,Swift 5

1,糊一张装饰视图

装饰视图 Decoration View ,苹果的例子是一个 cell 贴一张背景图。

实际上,一个 section ,贴一张背景图,可以的。

苹果设计的非常灵活,背景图 layout 可以自由设置。

比如:一个 section 里面有很多单元格 item , 可以一个 section 的后面 ,放一张装饰背景图

感觉 layout 都自定义了,没什么具体的范式,根据需求做就好。苹果也没什么好讲


one.png

图中,一个 collectionView 的 section, 有 5 个 item,这个 section 后面贴一张背景图

装饰视图是 UICollectionViewLayout 的功能,不是 UICollectionView 的。

UICollectionView 的方法、代理方法 (delegate, datasource)都不涉及装饰视图。

UICollectionView 对装饰视图一无所知,UICollectionView 按照 UICollectionViewLayout 设置的渲染。

要用装饰视图,就要自定制 UICollectionViewLayout,也就是 UICollectionViewLayout 的子类。这个 UICollectionViewLayout 子类,可以添加属性、代理属性,通过设置代理协议方法,来自定制装饰视图。

先实现这个效果

one.png

图中,一个 collectionView 的 section, 有 5 个 item,这个 section 有一个阴影

简要说来,自定制的 layout 子类,实现一个装饰视图,五步:

步骤 1,

要有一个 UICollectionResuableView 的子类, 这个就是具体的装饰视图
class ShadowBg: UICollectionReusableView {
    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = UIColor.white
        //  实现阴影,设置三个属性,就可以
        layer.shadowOpacity = 1
        layer.shadowColor = UIColor.shadowScore.cgColor
        layer.shadowOffset = CGSize(width: ShadowFrame.rhs, height: ShadowFrame.bottom)
    }
    
    required init?(coder: NSCoder) {
        fatalError("没实现")
    }
}
复制代码

步骤 2,

layout 中注册装饰视图。

有了装饰视图,组装在一起 (wire it up)

自定制的 layout 子类中,注册 UICollectionResuableView 的子类,也就是装饰视图。

调用 open func register(_ viewClass: AnyClass?, forDecorationViewOfKind elementKind: String) 方法。

一般在 override func prepare() { 方法中注册。

// 这里用了一个范型
class DecorationFlow<T: UICollectionReusableView>: UICollectionViewFlowLayout {
    
    override func prepare() {
        super.prepare()
        // 还可以用一些设置
        register(T.self, forDecorationViewOfKind: T.id)
    }

复制代码

步骤 3,

设置装饰视图的位置。

override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? 方法,

设置装饰视图 UICollectionResuableView 的位置,该方法返回了装饰视图的布局属性。


override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard indexPath.section == 0, let collection = collectionView, elementKind == T.id else{
            return nil
        }
        let attributes = DecorationLayoutAttributes(forDecorationViewOfKind: T.id, with: indexPath)
        let totalWid = UI.width - VerticalTabBarInfo.tabBarWidth
        let width = totalWid - FrontPageFrame.lhs - FrontPageFrame.rhs + ShadowFrame.rhs
        // 单元格的数目可以是奇数,也可以是偶数,
        // 通过 ceil 向上取整,保证单元格的数目是奇数的情况
        let floor = ceil(Double(collection.numberOfItems(inSection: 0))/2.0)
        let height = CGFloat(floor) * FrontPageFrame.itemHeight + ShadowFrame.bottom
        attributes.frame = CGRect(x: FrontPageFrame.lhs, y: FrontPageFrame.headerH, width: width, height: height)
        attributes.zIndex -= 1
        return attributes
    }
复制代码

步骤 4,

重写 override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { 方法,

该方法会返回给定区域内,所有视图 (单元格、补充视图(header \ footer)、装饰视图 ) 的布局属性。

当 collectionView 调用 layoutAttributesForElements,他会提供 3 种视图的所有布局属性( 单元格、补充视图(header \ footer)、装饰视图)。自定制的装饰视图布局属性,也在这个时机插入。

这里要糊上装饰视图,layoutAttributesForElements返回的布局属性数组,需含有调用 override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? 方法中设置的布局属性。

这一步比较关键,collectionView 得到了足够的信息,显示装饰视图。

collectionView 对装饰视图是隔离的,一无所知。看到的 collectionView 的装饰视图,是自定制 layout 属性提供的。

注册了装饰视图,即创建了自定制的装饰视图实例。collectionView 会根据布局属性,放置好。

把上一步设置的装饰视图布局属性,交给 collectionView 使用


override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var array = super.layoutAttributesForElements(in: rect)
        guard let collection = collectionView, collection.numberOfSections > 0 else{
            return array
        }
        let decorations = layoutAttributesForDecorationView(ofKind: T.id, at: IndexPath(item: 0, section: 0))
        // 找到那一个 layoutAttributes, 添加到原来的 super.layoutAttributes 中
        // 不会影响到我们正常使用 UICollectionViewDelegateFlowLayout
        if let decorate = decorations, rect.intersects(decorate.frame){
            array?.append(decorate)
        }
        return array
        
    }
复制代码
1.1 做一个加强,怎么传值给 Decoration View

Decoration View 能收到消息,才能适应多种情况,状态控制才能有效,根据状态改变 UI

三步走:

含 CollcetionView 的 controller -> layout -> layoutAttributes -> decorationView 装饰视图

实现图片背景效果

one.png

自定制 layout 与装饰视图也是隔离的。创建自定制布局属性对象 UICollectionViewLayoutAttributes 来传值,相当于找了一个信使。 给自定制的 layout 一个图片地址属性,


class DecorationLayoutAttributes: UICollectionViewLayoutAttributes {
    var imgName: String?
}

复制代码

传过去,就好了

控制器中,为指定的 collectionView 设置 layout 的图片 name ( 网络图,则为 url ) ,间接控制装饰视图的图片


class DecorationImgController: TabController {
    
    var layout = DecorationImgFlow()


   override func viewDidLoad() {
        super.viewDidLoad()
        layout.imgName = "bg"

// ...
复制代码

layoutAttributesForDecorationViewOfKind: 中配置,

 override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard indexPath.section == 0, let collection = collectionView, elementKind == ImageBg.id else{
            return nil
        }
        let attributes = DecorationLayoutAttributes(forDecorationViewOfKind: ImageBg.id, with: indexPath)
       // ...
       // 通过属性,传递外部设置的装饰视图实际图片
        attributes.imgName = imgName
      // ...
复制代码

最后,

把自定制 LayoutAttributes 的图片 url 传递给装饰视图, 有 override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) 方法。

当 collectionView 配置装饰视图的时候,会调用该方法。layoutAttributes 作为参数,取出 imgName 属性使用,就可以了

override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
        if let attribute = layoutAttributes as? DecorationLayoutAttributes{
            if let name = attribute.imgName{
                bg.image = UIImage(named: name)
            }
        }
    }
复制代码

2. 通过 Core Graphics, 画一个阴影

除了直接设置图层阴影,也可以画一个阴影

three.png

色块出阴影

当前的绘图上下文,阴影设置后,填充的颜色,就会产生对应的阴影

 override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return  }
        
        let frame = bounds.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: ShadowFrame.bottom, right: ShadowFrame.rhs))
        let rect = UIBezierPath(roundedRect: frame, cornerRadius: ShadowFrame.corn)
        let shadow = UIColor.shadowCompare
        context.setShadow(offset: CGSize(width: ShadowFrame.rhs, height: ShadowFrame.bottom), blur: 12, color: shadow.cgColor)
        UIColor.white.setFill()
        rect.fill()
    }

复制代码

换一个阴影效果

two.png

isOdddidSet 方法中,重新绘制


class ShadowBgSecond: UICollectionReusableView {
    
    var isOdd: Bool = false{
        didSet{
            if isOdd{
                setNeedsDisplay()
            }
        }
    }
    
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = UIColor.clear
    }
    

    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return  }
        
        let frame = bounds.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: ShadowFrame.bottom, right: ShadowFrame.rhs))
        let rect: UIBezierPath
       
        if isOdd{
            // 是奇数,三个色块,拼在一起
            // 下面的一个单元格,上面剩余的单元格,两块间过渡的圆弧
            let upFrame = frame.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: MusicLayout.itemHeight, right: 0))
            rect = UIBezierPath(roundedRect: upFrame, byRoundingCorners: [.topLeft, .topRight, .bottomRight], cornerRadii: CGSize(width: ShadowFrame.corn, height: ShadowFrame.corn))
            let downFrame = CGRect(origin: CGPoint(x: upFrame.minX, y: upFrame.maxY), size: CGSize(width: MusicLayout.doubleItemWidth * 0.5, height: MusicLayout.itemHeight))
            rect.append(UIBezierPath(roundedRect: downFrame, byRoundingCorners: [.bottomLeft, .bottomRight], cornerRadii: CGSize(width: ShadowFrame.corn, height: ShadowFrame.corn)))
            let midArc = UIBezierPath()
            let midP = CGPoint(x: upFrame.midX, y: upFrame.maxY)
            midArc.move(to: midP)
            midArc.addLine(to: midP.v(ShadowFrame.corn))
            midArc.addArc(withCenter: midP.offset(ShadowFrame.corn, offsetV: ShadowFrame.corn), radius: ShadowFrame.corn, startAngle: CGFloat.pi , endAngle: CGFloat.pi * 1.5, clockwise: true)
            midArc.close()
            rect.append(midArc)
        }
        else{
            // 是偶数,单独的一个色块
            rect = UIBezierPath(roundedRect: frame, cornerRadius: ShadowFrame.corn)
        }
        let shadow = UIColor.shadowScore
        context.setShadow(offset: CGSize(width: ShadowFrame.rhs, height: ShadowFrame.bottom), blur: 12, color: shadow.cgColor)
        UIColor.white.setFill()
        rect.fill()
    }
    
    override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
        if let attribute = layoutAttributes as? DecorationLayoutAttributes{
            isOdd = attribute.isOdd
        }
    }
}

复制代码

要设置对应的 UICollectionViewLayoutAttributes, 具体与图片设置类似

3, 补充

网络图,要处理下

要判断 collection.numberOfSections > 0, 使用网络数据的 collectionView , 一开始没有数据,数据请求回来前,不存在实际的 section, indexPath 也没有。

直接取 indexPath ,因为不存在,会崩溃

 override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var array = super.layoutAttributesForElements(in: rect)
        // 添加这一段
        guard let collection = collectionView, collection.numberOfSections > 0 else{
            return array
        }
     // ... 
复制代码
更加深入自定制 CollectionView 的 layout, 还可以通过设置代理协议方法,自由布局相关视图。

由控制器的内容,算出单元格具体的尺寸信息,传递给 Custom CollectionView Layout


通过 Core Graphics, 绘制阴影,常用的技巧有

切换绘图上下文

当前绘图上下文的保存与恢复

context.saveGState()

context.restoreGState()

复制代码
通过透明图层,组合多个色块的阴影

context.beginTransparencyLayer(auxiliaryInfo: nil)

context.endTransparencyLayer()
复制代码

代码 repo