Search code examples
iosswiftuicollectionviewcelluicollectionviewlayout

Incomprehensible work of the sublayer for cells in the CollectionView. Swift


So, the problem is that the gradient stroke is applied in a way that is not clear to me. At the same time, the problem is only when I do the logic of clicking on the cells as a radio button. If you leave the default (multiple choice), then there is no problem. Maybe somewhere I'm missing changing the height of the view on reloading the collection. Also, if I remove the gradient and include just a stroke, then everything works well. Who has any ideas?

enter image description here enter image description here

I add the gradient directly in the cell.

class InvestorTypeCollectionViewCell: UICollectionViewCell {

@IBOutlet private weak var cellTitleLabel: UILabel!
@IBOutlet private weak var cellDescriptionLabel: UILabel!
@IBOutlet private weak var checkMarkImageView: UIImageView!
@IBOutlet private weak var itemContainerView: UIView!

weak var delegate: InvestorTypeCollectionViewCellDelegate?

override func prepareForReuse() {
    super.prepareForReuse()
    checkMarkImageView.image = UIImage(named: "uncheckedBox")
    
    // remove sublayer
    itemContainerView.layer.sublayers?.filter{ $0 is CAGradientLayer }.forEach{ $0.removeFromSuperlayer() }
}

override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
    setNeedsLayout()
    layoutIfNeeded()
    let size = contentView.systemLayoutSizeFitting(layoutAttributes.size)
    var frame = layoutAttributes.frame
    frame.size.height = ceil(size.height)
    layoutAttributes.frame = frame
    return layoutAttributes
}

func configCellWith(item: InvestorTypeModel, indexPath row: Int, selectedIndex: Int? = nil) {
    itemContainerView.backgroundColor = UIColor.LIGHT_BLUE_BACKGROUND
    itemContainerView.layer.cornerRadius = 8
    cellTitleLabel.text = item.itemTitle.uppercased()
    cellDescriptionLabel.text = Transformer.share.strippingOutHtmlFrom(text: item.itemDescription)
    if item.checkBoxSelected {
        diselectUISetUp()
        // delegate to change item checkbox to false
        delegate?.cellDidSelectedAt(indexPath: row, withCheckbox: false)
    } else {
        if let selectedIndex = selectedIndex {
            if row == selectedIndex {
                // select this cell
                selectUISetUp()
                // delegate to change item checkbox propertie to true
                delegate?.cellDidSelectedAt(indexPath: row, withCheckbox: true)
            }
        }
    }
}

private func diselectUISetUp() {
    checkMarkImageView.image = UIImage(named: "uncheckedBox")
    itemContainerView.layer.borderWidth = 0
    itemContainerView.layer.borderColor = UIColor.clear.cgColor
    itemContainerView.layer.cornerRadius = 8
}

private func selectUISetUp() {
    checkMarkImageView.image = UIImage(named: "checkedBox")
    
    // add gradient
    let colors = [UIColor(red: 0.30, green: 0.84, blue: 0.74, alpha: 1.00),
                  UIColor(red: 0.44, green: 0.35, blue: 0.92, alpha: 1.00),
                  UIColor(red: 0.26, green: 0.20, blue: 0.87, alpha: 0.93)]
    itemContainerView.gradientBorder(width: 1, colors: colors, andRoundCornersWithRadius: 8)
}

}

this is where the gradient is configured

func gradientBorder(width: CGFloat,
                    colors: [UIColor],
                    startPoint: CGPoint = CGPoint(x: 0.5, y: 0.0),
                    endPoint: CGPoint = CGPoint(x: 0.5, y: 1.0),
                    andRoundCornersWithRadius cornerRadius: CGFloat = 0) {
    let existingBorder = gradientBorderLayer()
    let border = existingBorder ?? CAGradientLayer()
    border.frame = CGRect(x: bounds.origin.x, y: bounds.origin.y,
                          width: bounds.size.width + width, height: bounds.size.height + width)
    border.colors = colors.map { return $0.cgColor }
    border.startPoint = startPoint
    border.endPoint = endPoint
    let mask = CAShapeLayer()
    let maskRect = CGRect(x: bounds.origin.x + width/2, y: bounds.origin.y + width/2,
                          width: bounds.size.width - width, height: bounds.size.height - width)
    mask.path = UIBezierPath(roundedRect: maskRect, cornerRadius: cornerRadius).cgPath
    mask.fillColor = UIColor.clear.cgColor
    mask.strokeColor = UIColor.white.cgColor
    mask.lineWidth = width
    border.mask = mask
    let exists = (existingBorder != nil)
    if !exists {
        layer.addSublayer(border)
    }
}
private func gradientBorderLayer() -> CAGradientLayer? {
    let borderLayers = layer.sublayers?.filter { return $0.name == UIView.kLayerNameGradientBorder }
    if borderLayers?.count ?? 0 > 1 {
        fatalError()
    }
    return borderLayers?.first as? CAGradientLayer
}

When the data arrives, I change the size of the collection

func refreshData() {
    DispatchQueue.main.async {
        self.collectionView.reloadData()
        guard let dataCount = self.viewModelPresenter?.data?.count else { return }
        self.heightConstraintCV.constant = 1000 + 50
    }
}

Solution

  • You will find it much easier to use a subclassed UIView that handles its own gradient border.

    For example:

    @IBDesignable
    class GradientBorderView: UIView {
    
        // turns on/off the gradient border
        @IBInspectable public var selected: Bool = false { didSet { setNeedsLayout() } }
        
        // default colors
        //  - can be set at run-time if desired
        public var colors: [UIColor] = [UIColor(red: 0.30, green: 0.84, blue: 0.74, alpha: 1.00),
                                        UIColor(red: 0.44, green: 0.35, blue: 0.92, alpha: 1.00),
                                        UIColor(red: 0.26, green: 0.20, blue: 0.87, alpha: 0.93)]
        {
            didSet {
                setNeedsLayout()
            }
        }
    
        // default boder line width, corner radius, and inset-from-edges
        //  - can be set at run-time if desired
        @IBInspectable public var bWidth: CGFloat = 1 { didSet { setNeedsLayout() } }
    
        @IBInspectable public var cRadius: CGFloat = 8 { didSet { setNeedsLayout() } }
    
        @IBInspectable public var inset: CGFloat = 0.5 { didSet { setNeedsLayout() } }
        
        override class var layerClass: AnyClass {
            return CAGradientLayer.self
        }
        private var gLayer: CAGradientLayer {
            return self.layer as! CAGradientLayer
        }
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
        func commonInit() {
            gLayer.startPoint = CGPoint(x: 0.5, y: 0.0)
            gLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
        }
        override func layoutSubviews() {
            super.layoutSubviews()
        
            let shapeLayer = CAShapeLayer()
    
            if selected {
                gLayer.colors = colors.compactMap( {$0.cgColor })
                
                // make shapeLayer path the size of view inset by "inset"
                //  with rounded corners
                //  strokes are centered, so inset must be at least 1/2 of the border width
                let mInset = max(inset, bWidth * 0.5)
                shapeLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: mInset, dy: mInset), cornerRadius: cRadius).cgPath
                // clear fill color
                shapeLayer.fillColor = UIColor.clear.cgColor
                // stroke color can be any non-clear color
                shapeLayer.strokeColor = UIColor.black.cgColor
                shapeLayer.lineWidth = bWidth
            } else {
                // we'll mask with an empty path
                shapeLayer.path = UIBezierPath().cgPath
            }
    
            gLayer.mask = shapeLayer
        }
        
    }
    

    So, use a GradientBorderView instead of a UIView:

    @IBOutlet private weak var itemContainerView: GradientBorderView!
    

    and all you have to do is set its selected property to show/hide the border:

    itemContainerView.selected = true
    

    or you could set .isHidden to show/hide it.

    The "gradient border" will automatically update any time the view size changes.

    The view is also marked @IBDesignable with @IBInspectable properties, so you can see how it looks while designing in Storyboard (note: selected is false by default, so you won't see anything unless you change that to true).