Image for reference:
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:
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.
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):
Then we create an "endPath" moving the centers ... and we decreasing the radius to Zero where needed (arcs 2, 3, 6, 7):
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()
}
}