Refresh draw function without removing sub layers

627 Views Asked by At

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

1

There are 1 best solutions below

4
Spiritofthecore On BEST ANSWER

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