swiftdrawdrawrectcashapelayer

Refresh draw function without removing sub layers


I tried to create an activity graphs, like the activity graph on Apple watch. (3 rings with animation)

here is my full code:

private(set) var animationTime: [CFTimeInterval] = [1, 1, 1]
private(set) var values: [Double] = [0, 0, 0]
private(set) var colors: [UIColor] = [UIColor.red, CUIColor.blue, CUIColor.green]
private(set) var radiusRatios: [Double] = [1, 1, 1]



// to add new value
func setCircle(number index: Int, data: Double) {
    values[index] = data
}

   var drawing = false

 var animationOption = UIView.AnimationOptions()
   override public func draw(_ rect: CGRect) {
      if !drawing {
         switch style {
         case .ring:
            drawing = true
            let borderThickness = (rect.width / 20).rounded().nextUp
            let circleDistance = (rect.width / 20).rounded().nextUp
            self.layer.sublayers?.removeAll()


            var maximumAnimationDuration: TimeInterval = 0
            for index in 0 ..< dataSize {

               let belowCircle = CAShapeLayer()
               let mainCircle = CAShapeLayer()
               layer.addSublayer(belowCircle)
               layer.addSublayer(mainCircle)
               belowCircle.position = CGPoint(x: layer.bounds.midX, y: layer.bounds.midY)
               mainCircle.position = CGPoint(x: layer.bounds.midX, y: layer.bounds.midY)

               let outerCircularPath = UIBezierPath(arcCenter: .zero,
                                                    radius: rect.width / 2
                                                       - borderThickness * CGFloat(2 * index + 1) / 2
                                                       - circleDistance * CGFloat(index),
                                                    startAngle: 0,
                                                    endAngle: 2 * CGFloat.pi,
                                                    clockwise: true)
               belowCircle.path = outerCircularPath.cgPath
               mainCircle.path = outerCircularPath.cgPath

               belowCircle.opacity = 0.2
               belowCircle.strokeColor = colors[index].cgColor
               belowCircle.lineWidth = borderThickness
               belowCircle.fillColor = UIColor.clear.cgColor
               belowCircle.lineCap = CAShapeLayerLineCap.round

               mainCircle.strokeColor = colors[index].cgColor
               mainCircle.lineWidth = borderThickness
               mainCircle.fillColor = UIColor.clear.cgColor
               mainCircle.lineCap = CAShapeLayerLineCap.round
               mainCircle.strokeEnd = 0
               mainCircle.transform = CATransform3DMakeRotation(-CGFloat.pi / 2, 0, 0, 1)

               // outer circle
               let animation = CABasicAnimation(keyPath: "strokeEnd")
               animation.toValue = values[index] < 0.001 ? 0.001 : values[index]
               animation.duration = animationTime[index]
               animation.fillMode = CAMediaTimingFillMode.forwards
               animation.isRemovedOnCompletion = false
               mainCircle.add(animation, forKey: "RingChartOuterCircle\(index)")

               maximumAnimationDuration = max(maximumAnimationDuration, animationTime[index])
            }
            UIView.transition(with: self,
                              duration: maximumAnimationDuration,
                              options: animationOption,
                              animations: nil,
                              completion: { [weak self] _ in
                                 self?.drawing = false
               })

And when I want to use it, I use this function from the parent view controller:

 public func setChartsValus(outerValue: Double, middleValue: Double, innerValue: Double) {

      ringChart.setNeedsDisplay()
      ringChart.setCircle(number: 0, data: outerValue)
      ringChart.setCircle(number: 1, data: middleValue)
      ringChart.setCircle(number: 2, data: innerValue)
   }

like that:

setChartsValus(outerValue: 0.5, middleValue: 0.8, innerValue: 0.6)

My problem is, when I want to add a new value (it will happen every few minutes), I have to remove all sub layers and doing the process again, that is why, every time it's called, the all rings back to zero and move from 0 again to fill the value, which is not good at all. this problem happen because of :

  self.layer.sublayers?.removeAll()

If I don't remove all, all layers come on each other and it become so mess.

I want, when I add a new value from example 0.7, and the old value is 0.3, the ring start to grow from 0.3 to reach 0.7 (like apple watch activity app), not back to zero and fill it again.

I would really be appreciated if everyone can help me on this? I spent two days to fix it but I couldn't find any way.

Thank you so much


Solution

  • My solution is to remove the previous layers, add new layers with the same strokeEnd and set it animate to new values

    override public func draw(_ rect: CGRect) {
        transform(rect)
    }
    
    func transform(_ rect: CGRect, to newValues: [CGFloat]? = nil) {
        if !drawing {
           switch style {
           case .ring:
            drawing = true
            let borderThickness = (rect.width / 20).rounded().nextUp
            let circleDistance = (rect.width / 20).rounded().nextUp
            self.layer.sublayers?.removeAll()
    
            for index in 0 ..< 3 {
    
                 let belowCircle = CAShapeLayer()
                 let mainCircle = CAShapeLayer()
                 layer.addSublayer(belowCircle)
                 layer.addSublayer(mainCircle)
                 belowCircle.position = CGPoint(x: layer.bounds.midX, y: layer.bounds.midY)
                 mainCircle.position = CGPoint(x: layer.bounds.midX, y: layer.bounds.midY)
    
                 let outerCircularPath = UIBezierPath(arcCenter: .zero,
                                                      radius: rect.width / 2
                                                         - borderThickness * CGFloat(2 * index + 1) / 2
                                                         - circleDistance * CGFloat(index),
                                                      startAngle: 0,
                                                      endAngle: 2 * CGFloat.pi,
                                                      clockwise: true)
                 belowCircle.path = outerCircularPath.cgPath
                 mainCircle.path = outerCircularPath.cgPath
    
                 belowCircle.opacity = 0.2
                 belowCircle.strokeColor = colors[index].cgColor
                 belowCircle.lineWidth = borderThickness
                 belowCircle.fillColor = UIColor.clear.cgColor
                 belowCircle.lineCap = CAShapeLayerLineCap.round
    
                 mainCircle.strokeColor = colors[index].cgColor
                 mainCircle.lineWidth = borderThickness
                 mainCircle.fillColor = UIColor.clear.cgColor
                 mainCircle.lineCap = CAShapeLayerLineCap.round
                if let newValues = newValues {
                    mainCircle.strokeEnd = values[index]
    
                } else {
                    mainCircle.strokeEnd = 0
                }
                 mainCircle.transform = CATransform3DMakeRotation(-CGFloat.pi / 2, 0, 0, 1)
                
                let animation = CABasicAnimation(keyPath: "strokeEnd")
                if let newValues = newValues {
                    animation.toValue = newValues[index] < 0.001 ? 0.001 : newValues[index]
    
                } else {
                    animation.toValue = values[index] < 0.001 ? 0.001 : values[index]
                }
                   animation.duration = animationTime[index]
                   animation.fillMode = CAMediaTimingFillMode.forwards
                   animation.isRemovedOnCompletion = false
                mainCircle.add(animation, forKey: "RingChartOuterCircle\(index)")
            }
            drawing = false
           case .chart:
              break
           }
        }
    

    In container view controller, I call function transform again with my new values:

    override func viewDidLoad() {
        super.viewDidLoad()
        
        setChartsValus(outerValue: outerValue, middleValue: middleValue, innerValue: innerValue)
        
        // suppose we need to change 10% every 2 second
        Timer.scheduledTimer(timeInterval: 2, target: self, selector: #selector(changeValue), userInfo: nil, repeats: true)
    }
    
    @objc func changeValue() {
        outerValue += 0.1
        innerValue += 0.1
        middleValue += 0.1
        
        ringChart.layer.sublayers?.removeAll()
        ringChart.transform(ringChart.bounds, to: [outerValue, middleValue, innerValue])
    }