Search code examples
swiftuikituiviewanimationuibezierpathcgpath

How to animate this shape animation with rounded corners?


Image for reference:

enter image description here

I need to animate from the leftmost shape to the rightmost shape. I have included intermediate frames as an example of what is so tricky about this (and necessary for the design): the corner radius effect where the two rectangles meet.

class TestRoundedCornerViews: UIView {
    
    let leftView = UIView()
    let middleView = UIView()
    let rightView = UIView()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        let cornerRadius: CGFloat = 16
        
        leftView.layer.cornerRadius = cornerRadius
        leftView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
        self.addSubview(self.leftView)
        
        middleView.layer.cornerRadius = cornerRadius
        middleView.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
        self.addSubview(self.middleView)
        
        rightView.layer.cornerRadius = cornerRadius
        rightView.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
        self.addSubview(self.rightView)
        
    }
    
    // MARK: Layout
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        let leftSideWidth: CGFloat = 100
        
        let leftViewWidth: CGFloat = leftSideWidth / 2
        let middleViewWidth: CGFloat = leftViewWidth
        
        let rightViewWidth: CGFloat = 40
        
        leftView.frame = CGRect(
            x: 0,
            y: 0,
            width: leftViewWidth,
            height: self.bounds.height
        )
        
        middleView.frame = CGRect(
            x: leftView.frame.maxX,
            y: 0,
            width: middleViewWidth,
            height: self.bounds.height
        )
        
        rightView.frame = CGRect(
            x: middleView.frame.maxX,
            y: 0,
            width: rightViewWidth,
            height: self.bounds.height
        )
        
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }

}

Here's a visual of what that ends up like, using 3 views:

enter image description here

However it does not have that corner radius where the views meet, and I do not know how to animate that to the end position.


Solution

  • I did this animation by animating a CAShapeLayer path.

    We start by adding 8 arcs to a UIBezierPath "startPath" (the connecting lines are automatically added):

    enter image description here

    Then we create an "endPath" moving the centers ... and we decreasing the radius to Zero where needed (arcs 2, 3, 6, 7):

    enter image description here

    We can then use CABasicAnimation(keyPath: "path") with .fromValue = startPath.cgPath and .toValue = endPath.cgPath to "morph" from one shape to another.

    You'll notice near the end of the animation that the corners being "straightened out" get a little "stepping" effect as their radius nears Zero... with some more math, and possibly chaining a few animations together, we could probably make that a little cleaner.

    However, how noticeable it is depends on the speed of the animation and the overall size of the view, and may not be worth the effort.

    Here's an example view subclass to do this:

    class TestRoundedCornerViews: UIView {
        
        let shapeLayer = CAShapeLayer()
        var startPath: UIBezierPath!
        var endPath: UIBezierPath!
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() {
    
            shapeLayer.fillColor = UIColor.lightGray.cgColor
            
            // if you want to watch the path border instead
            //shapeLayer.fillColor = nil
            //shapeLayer.strokeColor = UIColor.red.cgColor
            //shapeLayer.lineWidth = 1
            
            layer.addSublayer(shapeLayer)
    
        }
        
        func doAnim() {
            
            let animation1 = CABasicAnimation(keyPath: "path")
            animation1.fromValue = startPath.cgPath
            animation1.toValue = endPath.cgPath
            animation1.duration = 0.5
            animation1.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
            animation1.fillMode = .both
            
            // un-comment next two lines to repeat and auto-revers
            //animation1.repeatCount = .greatestFiniteMagnitude
            //animation1.autoreverses = true
            
            animation1.isRemovedOnCompletion = false
            
            shapeLayer.add(animation1, forKey: animation1.keyPath)
            
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            
            let cr: CGFloat = 16
            
            let oneThirdWidth: CGFloat = bounds.width * 1.0 / 3.0
            let twoThirdWidth: CGFloat = oneThirdWidth * 2.0
    
            let oneThirdHeight: CGFloat = bounds.height * 1.0 / 3.0
            let twoThirdHeight: CGFloat = oneThirdHeight * 2.0
    
            // centers for startPath arcs (rounded corners)
    
            // top-left corner - will not change
            let c1: CGPoint = .init(x: cr, y: bounds.minY + cr)
            
            var c2: CGPoint = .init(x: twoThirdWidth - cr, y: bounds.minY + cr)
            var c3: CGPoint = .init(x: twoThirdWidth + cr, y: oneThirdHeight - cr)
            var c4: CGPoint = .init(x: bounds.maxX - cr, y: oneThirdHeight + cr)
            var c5: CGPoint = .init(x: bounds.maxX - cr, y: twoThirdHeight - cr)
            var c6: CGPoint = .init(x: twoThirdWidth + cr, y: twoThirdHeight + cr)
            var c7: CGPoint = .init(x: twoThirdWidth - cr, y: bounds.maxY - cr)
            
            // bottom-left corner - will not change
            let c8: CGPoint = .init(x: cr, y: bounds.maxY - cr)
            
            startPath = UIBezierPath()
            startPath.move(to: .init(x: 0.0, y: cr))
            startPath.addArc(withCenter: c1, radius: cr, startAngle: .pi * 1.0, endAngle: .pi * 1.5, clockwise: true)
            startPath.addArc(withCenter: c2, radius: cr, startAngle: .pi * 1.5, endAngle: .pi * 2.0, clockwise: true)
            startPath.addArc(withCenter: c3, radius: cr, startAngle: .pi * 1.0, endAngle: .pi * 0.5, clockwise: false)
            startPath.addArc(withCenter: c4, radius: cr, startAngle: .pi * 1.5, endAngle: .pi * 2.0, clockwise: true)
            startPath.addArc(withCenter: c5, radius: cr, startAngle: .pi * 0.0, endAngle: .pi * 0.5, clockwise: true)
            startPath.addArc(withCenter: c6, radius: cr, startAngle: .pi * 1.5, endAngle: .pi * 1.0, clockwise: false)
            startPath.addArc(withCenter: c7, radius: cr, startAngle: .pi * 0.0, endAngle: .pi * 0.5, clockwise: true)
            startPath.addArc(withCenter: c8, radius: cr, startAngle: .pi * 0.5, endAngle: .pi * 1.0, clockwise: true)
            startPath.close()
            
            shapeLayer.path = startPath.cgPath
            
            // centers for endPath arcs (rounded corners)
            c2 = .init(x: bounds.maxX - cr, y: bounds.minY)
            c3 = .init(x: bounds.maxX - cr, y: bounds.minY)
            c4 = .init(x: bounds.maxX - cr, y: cr)
            c5 = .init(x: bounds.maxX - cr, y: bounds.maxY - cr)
            c6 = .init(x: bounds.maxX - cr, y: bounds.maxY)
            c7 = .init(x: bounds.maxX - cr, y: bounds.maxY)
            
            endPath = UIBezierPath()
            endPath.move(to: .init(x: 0.0, y: cr))
            endPath.addArc(withCenter: c1, radius: cr, startAngle: .pi * 1.0, endAngle: .pi * 1.5, clockwise: true)
            endPath.addArc(withCenter: c2, radius: 0.0, startAngle: .pi * 1.5, endAngle: .pi * 2.0, clockwise: true)
            endPath.addArc(withCenter: c3, radius: 0.0, startAngle: .pi * 1.0, endAngle: .pi * 0.5, clockwise: false)
            endPath.addArc(withCenter: c4, radius: cr, startAngle: .pi * 1.5, endAngle: .pi * 2.0, clockwise: true)
            endPath.addArc(withCenter: c5, radius: cr, startAngle: .pi * 0.0, endAngle: .pi * 0.5, clockwise: true)
            endPath.addArc(withCenter: c6, radius: 0.0, startAngle: .pi * 1.5, endAngle: .pi * 1.0, clockwise: false)
            endPath.addArc(withCenter: c7, radius: 0.0, startAngle: .pi * 0.0, endAngle: .pi * 0.5, clockwise: true)
            endPath.addArc(withCenter: c8, radius: cr, startAngle: .pi * 0.5, endAngle: .pi * 1.0, clockwise: true)
            endPath.close()
    
        }
    
    }
    

    and here's a simple view controller that shows the view (at 150 x 300 size), then animates on tap:

    class ViewController: UIViewController {
        let testView = TestRoundedCornerViews()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            testView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                testView.topAnchor.constraint(equalTo: g.topAnchor, constant: 100.0),
                testView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 100.0),
                testView.widthAnchor.constraint(equalToConstant: 150.0),
                testView.heightAnchor.constraint(equalTo: testView.widthAnchor, multiplier: 2.0),
                
            ])
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            testView.doAnim()
        }
    }