0

I'm trying to create an animation where a CALayer rectangle changes from being flush with the borders of a view to having either the left, right, or both corners rounded and the width of the rectangle is also changed.

The problem I'm having is that the animation looks distorted. If I disable the corner radius change in the animation, the distortion doesn't happen. I suspect this is because when the original path and the final path in the animation have a different number of rounded corners, the paths have a different number of control points, so the path animation behavior is undefined as per the docs.

I'm thinking that I should try and find a way to get the number of control points in the rounded rect to be equal to the number in the non-rounded rect but I'm not sure how I would do this since I haven't found a way to count the number of control points in a CGPath/UIBezierPath.

Here's the code I'm using right now, where I'm animating the path, but I'm open to changing the implementation entirely to 'cheat' by having two rectangles or something like that.

func setSelection(to color: UIColor, connectedLeft: Bool, connectedRight: Bool, animated: Bool) {
    self.connectedLeft = connectedLeft
    self.connectedRight = connectedRight
    self.color = color
    if animated {
        let pathAnimation = CABasicAnimation(keyPath: "path")
        let colorAnimation = CABasicAnimation(keyPath: "fillColor")
        self.configure(animation: pathAnimation)
        self.configure(animation: colorAnimation)
        pathAnimation.toValue = self.rectPath(connectedLeft: connectedLeft, connectedRight: connectedRight).cgPath
        colorAnimation.toValue = color.cgColor
        let group = CAAnimationGroup()
        group.animations = [pathAnimation, colorAnimation]
        self.configure(animation: group)
        group.delegate = self
        self.rectLayer.add(group, forKey: Constants.selectionChangeAnimationKey)
    } else {
        self.rectLayer.fillColor = color.cgColor
        self.rectLayer.path = self.rectPath(connectedLeft: connectedLeft, connectedRight: connectedRight).cgPath
    }
}

private func rectPath(connectedLeft: Bool, connectedRight: Bool) -> UIBezierPath {
    let spacing: CGFloat = 5
    var origin = self.bounds.origin
    var size = self.bounds.size
    var corners = UIRectCorner()
    if !connectedLeft {
        origin.x += spacing
        size.width -= spacing
        corners.formUnion([.topLeft, .bottomLeft])
    }
    if !connectedRight {
        size.width -= spacing
        corners.formUnion([.topRight, .bottomRight])
    }
    let path = UIBezierPath(roundedRect: .init(origin: origin, size: size), byRoundingCorners: corners, cornerRadii: .init(width: 8, height: 8))
    print(path.cgPath)
    return path
}
rpecka
  • 1,168
  • 5
  • 17
  • Does this https://stackoverflow.com/a/34842463/10352079 help? – Razvan S. Feb 12 '21 at 16:56
  • It could, and I'll try it out, but I need to be able to have only some of the corners rounded. I could hack it by using overlapping rectangles. – rpecka Feb 12 '21 at 17:25

1 Answers1

0

You are correct as to why your animation doesn't work correctly.

The Core Graphics/Core Animation methods that draw arcs compose the arcs using variable numbers of cubic bezier curves depending on the angle being drawn.

I believe that a 90 degree corner is rendered as a single cubic bezier curve.

Based on a recent test I did, it seems all you need to do is have the same number of endpoints as a Bezier curve in order to get the sharp-corner-to-rounded-corner animation to draw correctly. For a rounded corner, I'm pretty sure it draws a line segment up to the rounded corner, then the arc as one Bezier curve, then the next line segment. Thus you SHOULD be able to create a rectangle by drawing each sharp corner point twice. I haven't tried it, but I think it would work.

(Based on my understanding of how arcs of circles are drawn using bezier curve, an arc of a circle is composed of a cubic bezier curve for each quarter-circle or part of a quarter circle that is drawn. So for a 90 degree bend, you just need 2 control points for a sharp corner to replace a rounded corner. If the angle is >90 degrees but less than 180, you'd want 3 control points at the corner, and if it's >180 but less than 270, you'd want 4 control points.)

Edit:

The above approach of adding each corner-point twice for a rounded rectangle SHOULD work for the rounded rectangle case (where every corner is exactly 90 degrees) but it doesn't work for irregular polygons where the angle that needs to be rounded varies. I couldn't work out how to handle that case. I already created a demo project that generated irregular polygons with any mixture of sharp and curved corners, so I adapted it to handle animation.

The project now includes a function that generates CGPaths with a settable mixture of rounded and sharp corners that will animate the transition between sharp corners and rounded corners. The function you want to look at is called roundedRectPath(rect:byRoundingCorners:cornerRadius:)

Here is the function header and in-line documentation

/**
This function works like the UIBezierPath initializer `init(roundedRect:byRoundingCorners:cornerRadii:)` It returns a CGPath a rounded rectangle with a mixture of rounded and sharp corners, as specified by the options in `corners`.

 Unlike the UIBezierPath `init(roundedRect:byRoundingCorners:cornerRadii:` intitializer, The CGPath that is returned by this function will animate smoothly from rounded to non-rounded corners.

 - Parameter  rect: The starting rectangle who's corners you wish to round
 - Parameter corners: The corners to round
 - Parameter cornerRadius: The corner radius to use
 - Returns: A CGPath containing for the rounded rectangle.
*/
public func roundedRectPath(rect: CGRect, byRoundingCorners corners: UIRectCorner, cornerRadius: CGFloat) -> CGPath

You can download the project from Github and try it out at this link.

Here is a GIF of the demo app in operation:

enter image description here

Duncan C
  • 128,072
  • 22
  • 173
  • 272