adincebic
adincebic

Reputation: 91

How to make UIBezierPath to fill from the center

How do I make a UIBezierPath that starts drawing from the center and expands to the left and right?

My current solution is not using UIBezierPath but rather a UIView inside another UIView. The children view is centered inside of it's parent and I adjust child's width when I need it to expand. However, this solution is far from perfect because the view is located inside of UIStackView and the stack view is in UICollectionView cell and it sometimes does not get the width correctly.

   override func layoutSubviews() {
    super.layoutSubviews()
    progressView.cornerRadius = cornerRadius
    clipsToBounds = true
    addSubview(progressView)
    NSLayoutConstraint.activate([
        progressView.topAnchor.constraint(equalTo: topAnchor),
        progressView.bottomAnchor.constraint(equalTo: bottomAnchor),
        progressView.centerXAnchor.constraint(equalTo: centerXAnchor)
    ])
    let width = frame.width
    let finalProgress = CGFloat(progress) * width
    let widthConstraint = progressView.widthAnchor.constraint(equalToConstant: finalProgress)
    widthConstraint.priority = UILayoutPriority(rawValue: 999)
    widthConstraint.isActive = true
}

the progress is a class property of type double and when it gets set I call layoutIfNeeded.

I also tried calling setNeedsLayout and layoutIfNeeded but it does not solve the issue. SetNeedsDisplay and invalidateIntrinzicSize do not work either.

Now, I would like to do it using bezier path, but the issue is that it starts from a given point and draws from left to right. The question is: how do I make a UIBezierPath that is centered and when I increase the width it expands (stays in center)

Here is the image of what I want to achieve. enter image description here

Upvotes: 0

Views: 512

Answers (1)

Rob
Rob

Reputation: 437422

There are quite a few options. But here are a few:

  1. CABasicAnimation of the strokeStart and strokeEnd of CAShapeLayer, namely:

    • Create CAShapeLayer whose path is the final UIBezierPath (full width), but whose strokeStart and strokeEnd are both 0.5 (i.e. start half-way, and finish half-way, i.e. nothing visible yet).

      func configure() {
          shapeLayer.strokeColor = strokeColor.cgColor
          shapeLayer.lineCap = .round
          setProgress(0, animated: false)
      }
      
      func updatePath() {
          let path = UIBezierPath()
          path.move(to: CGPoint(x: bounds.minX + lineWidth, y: bounds.midY))
          path.addLine(to: CGPoint(x: bounds.maxX - lineWidth, y: bounds.midY))
          shapeLayer.path = path.cgPath
          shapeLayer.lineWidth = lineWidth
      }
      
    • Then, in CABasicAnimation animate the change of the strokeStart to 0 and the strokeEnd to 1 to achieve the animation growing both left and right from the middle. For example:

      func setProgress(_ progress: CGFloat, animated: Bool = true) {
          let percent = max(0, min(1, progress))
          let strokeStart = (1 - percent) / 2
          let strokeEnd = 1 - (1 - percent) / 2
      
          if animated {
              let strokeStartAnimation = CABasicAnimation(keyPath: "strokeStart")
              strokeStartAnimation.fromValue = shapeLayer.strokeStart
              strokeStartAnimation.toValue = strokeStart
      
              let strokeEndAnimation = CABasicAnimation(keyPath: "strokeEnd")
              strokeEndAnimation.fromValue = shapeLayer.strokeEnd
              strokeEndAnimation.toValue = strokeEnd
      
              let animation = CAAnimationGroup()
              animation.animations = [strokeStartAnimation, strokeEndAnimation]
      
              shapeLayer.strokeStart = strokeStart
              shapeLayer.strokeEnd = strokeEnd
      
              layer.add(animation, forKey: nil)
          } else {
              shapeLayer.strokeStart = strokeStart
              shapeLayer.strokeEnd = strokeEnd
          }
      }
      
  2. Animation of the path of a CAShapeLayer

    • Have method that creates the UIBezierPath:

      func setProgress(_ progress: CGFloat, animated: Bool = true) {
          self.progress = progress
      
          let cgPath = path(for: progress).cgPath
      
          if animated {
              let animation = CABasicAnimation(keyPath: "path")
              animation.fromValue = shapeLayer.path
              animation.toValue = cgPath
      
              shapeLayer.path = cgPath
      
              layer.add(animation, forKey: nil)
          } else {
              shapeLayer.path = cgPath
          }
      }
      

      Where

      func path(for progress: CGFloat) -> UIBezierPath {
          let path = UIBezierPath()
          let percent = max(0, min(1, progress))
          let width = bounds.width - lineWidth
          path.move(to: CGPoint(x: bounds.midX - width * percent / 2 , y: bounds.midY))
          path.addLine(to: CGPoint(x: bounds.midX + width * percent / 2, y: bounds.midY))
          return path
      }
      
    • When you create the CAShapeLayer call setProgress with value of 0 and no animation; and

    • When you want to animate it out, then call setProgress with value of 1 but with animation.

  3. Abandon UIBezierPath/CAShapeLayer approaches and use UIView and UIView block-based animation:

    • Create short UIView with a backgroundColor and whose layer has a cornerRadius which is half the height of the view.

    • Define constraints such that the view has a zero width. E.g. you might define the centerXanchor and so that it’s placed where you want it, and with a widthAnchor is zero.

    • Animate the constraints by setting the existing widthAnchor’s constant to whatever width you want and place layoutIfNeeded inside a UIView animation block.

Upvotes: 2

Related Questions