I have built a collectionView which shows a circular graph (to demonstrate a percentage) in each cell. In order to do so I am drawing CAShapelayer within a subclass (CircularGraph) which I am casting in my CustomCell.
The issue that I have is that whenever I reload my collectionView, the CAShapelayers all redraw to their strokeEnd position and are adding an unwanted animation while doing so. It appears that they are drawing from the value of the previous layer's strokeEnd value. This makes me believe that this has something to do with my subclass not being able to cast discrete versions of the graph.
To demonstrate this issue I have built a small demo project, available here. Just tap the reload button to see what I am referring to. This also occurs whenever the view is loaded, e.g. when switching tabs (which I guess makes sense since the collectionView would load its data again).
This is the collectionView:
class CellsController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
let graphValues:[CGFloat] = [0.12, 0.35, 0.14, 1, 0.89]
override func viewDidLoad() {
super.viewDidLoad()
print("Showing cellsController")
collectionView?.backgroundColor = .lightGray
navigationItem.title = "Cells"
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Reload", style: .plain, target: self, action: #selector(didPressReload))
collectionView?.register(CustomCell.self, forCellWithReuseIdentifier: "CustomCell")
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath) as! CustomCell
cell.graph.progressLayerStrokeEnd = graphValues[indexPath.row]
return cell
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return graphValues.count
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: view.frame.width, height: 156)
}
@objc func didPressReload() {
collectionView?.reloadData()
}
}
This is the CustomCell which is casting the graph:
class CustomCell: UICollectionViewCell {
let graph: CircularGraph = {
let graph = CircularGraph()
graph.translatesAutoresizingMaskIntoConstraints = false
return graph
}()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
addSubview(graph)
graph.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
graph.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
graph.heightAnchor.constraint(equalToConstant: 100).isActive = true
graph.widthAnchor.constraint(equalToConstant: 100).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
This is the subclass for the graph:
class CircularGraph: UIView {
//All layers
let trackLayer = CAShapeLayer()
let progressLayer = CAShapeLayer()
//Animation values
var percentageValue = CGFloat()
//Line width
var lineWidth: CGFloat = 15 { didSet { updatePath() } }
//Fill colors
var trackLayerFillColor: UIColor = .clear { didSet { trackLayer.fillColor = trackLayerFillColor.cgColor } }
var progressLayerFillColor: UIColor = .clear { didSet { progressLayer.fillColor = progressLayerFillColor.cgColor } }
//Stroke colors
var trackStrokeColor: UIColor = UIColor.lightGray { didSet { trackLayer.strokeColor = trackStrokeColor.cgColor } }
var progressLayerStrokeColor: UIColor = UIColor.green { didSet { progressLayer.strokeColor = progressLayerStrokeColor.cgColor } }
//Stroke start and end
var trackLayerStrokeStart: CGFloat = 0 { didSet { trackLayer.strokeStart = trackLayerStrokeStart } }
var progressLayerStrokeStart: CGFloat = 0 { didSet { progressLayer.strokeStart = progressLayerStrokeStart } }
var trackLayerStrokeEnd: CGFloat = 1 { didSet { trackLayer.strokeEnd = trackLayerStrokeEnd } }
var progressLayerStrokeEnd: CGFloat = 1 { didSet { progressLayer.strokeEnd = progressLayerStrokeEnd } }
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configure()
}
override func layoutSubviews() {
super.layoutSubviews()
updatePath()
}
func configure() {
trackLayer.strokeColor = trackStrokeColor.cgColor
trackLayer.fillColor = trackLayerFillColor.cgColor
trackLayer.strokeStart = trackLayerStrokeStart
trackLayer.strokeEnd = trackLayerStrokeEnd
progressLayer.strokeColor = progressLayerStrokeColor.cgColor
progressLayer.fillColor = progressLayerFillColor.cgColor
progressLayer.strokeStart = progressLayerStrokeStart
progressLayer.strokeEnd = progressLayerStrokeEnd
layer.addSublayer(trackLayer)
layer.addSublayer(progressLayer)
}
func updatePath() {
//The actual calculation for the circular graph
let arcCenter = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = (min(bounds.width, bounds.height) - lineWidth) / 2
let circularPath = UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
trackLayer.path = circularPath.cgPath
trackLayer.lineWidth = lineWidth
progressLayer.path = circularPath.cgPath
progressLayer.lineWidth = lineWidth
progressLayer.lineCap = kCALineCapRound
//Set the frame in order to rotate the outer circular paths to start at 12 o'clock
trackLayer.transform = CATransform3DIdentity
trackLayer.frame = bounds
trackLayer.transform = CATransform3DMakeRotation(-CGFloat.pi/2, 0, 0, 1)
progressLayer.transform = CATransform3DIdentity
progressLayer.frame = bounds
progressLayer.transform = CATransform3DMakeRotation(-CGFloat.pi/2, 0, 0, 1)
}
}
I found a fix based on CATransaction
, you may follow this idea:
var progressLayerStrokeEnd: CGFloat = 1 {
didSet {
CATransaction.begin()
CATransaction.setDisableActions(true)
progressLayer.strokeEnd = progressLayerStrokeEnd
CATransaction.commit()
}
}
Basically, every time you modify CAShapeLayer
, you should disable the implicit CAShapeLayer animation. So you might tune as needed the flag for setDisableActions
, eg: set it true
just when you are calling reloadData