Search code examples
iosswiftcashapelayercollectionview

CAShapelayer in collectionView adding odd animation on reloadData()


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)
  }
}

Solution

  • 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