CALayer circle mask animation incorrect behaviour + Swift - ios

I have been trying to do this animation for a couple of days now. I am trying to animate the size of my mask, basically taking a large circle (mask) and shrinking it.
#IBOutlet weak var myView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
animateMask()
}
func presentMaskScreenWithAnimation () {
//Setup Mask Layer
let bounds = myView.bounds
let maskLayer = CAShapeLayer()
maskLayer.frame = bounds
maskLayer.fillColor = UIColor.brown.cgColor
//CropCircle Out of mask Layer
let initialCircle = CGRect(x: 10, y: 10, width: 300, height: 300)
let path = UIBezierPath(roundedRect: initialCircle, cornerRadius: initialCircle.size.width/2)
path.append(UIBezierPath(rect: bounds))
maskLayer.path = path.cgPath
maskLayer.fillRule = kCAFillRuleEvenOdd
myView.layer.mask = maskLayer
//Define new circle path
let circlePath2 = CGRect(x: 50, y: 50, width: 100, height: 100)
let path2 = UIBezierPath(roundedRect: circlePath2, cornerRadius: circlePath2.size.width/2)
//Create animation
let anim = CABasicAnimation(keyPath: "path")
anim.fromValue = path.cgPath
anim.toValue = path2.cgPath
anim.duration = 3.0
anim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
maskLayer.add(anim, forKey: nil)
}
This code sets up the mask and begins to animate it however it inverts the entire maskLayer, I am trying to just animate the path of the circle, Large -> Small
Start
Finished

Add path2.append(UIBezierPath(rect: bounds)) after let path2 = UIBezierPath(roundedRect: circlePath2, cornerRadius: circlePath2.size.width/2). Also do not forget to do maskLayer.path = path2.cgPath at the end, if you want the shrinking mask stays after animation.

Related

How to animate object using bezier path?

Here is my code to draw dashed line
func drawLine() {
let shapeLayer = CAShapeLayer()
shapeLayer.bounds = viewDraw.bounds
shapeLayer.position = CGPoint(x: viewDraw.bounds.width/2, y: viewDraw.bounds.height/2)
shapeLayer.fillColor = nil
shapeLayer.strokeColor = ColorConstants.ThemeColor.cgColor
shapeLayer.lineWidth = 3
shapeLayer.lineJoin = CAShapeLayerLineJoin.round // Updated in swift 4.2
shapeLayer.lineDashPattern = [10]
let path = UIBezierPath(roundedRect: viewDraw.bounds, cornerRadius: viewDraw.bounds.size.height/2)
shapeLayer.path = path.cgPath
viewDraw.layer.addSublayer(shapeLayer)
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.duration = 1
shapeLayer.add(animation, forKey: "MyAnimation")
}
Now as per my screenshot I want to animate blue dot to red dot within that dashed line infinite time. When blue dot reaches red dot then it will start again with blue dot points.
Note: This whole screen is dynamic so, Line draw is also dynamic according to screen height
Any help would be appreciated
You are almost there
Now you have to add animation using CAKeyframeAnimation which has property path where you can pass the path where you want to animate
here is working sample code
func drawLine() {
let shapeLayer = CAShapeLayer()
shapeLayer.bounds = self.view.bounds
shapeLayer.position = CGPoint(x: self.view.bounds.width/2, y: self.view.bounds.height/2)
shapeLayer.fillColor = nil
shapeLayer.strokeColor = UIColor.red.cgColor
shapeLayer.lineWidth = 3
shapeLayer.lineJoin = CAShapeLayerLineJoin.round // Updated in swift 4.2
shapeLayer.lineDashPattern = [10]
let path = UIBezierPath(roundedRect: self.view.bounds, cornerRadius: self.view.bounds.size.height/2)
shapeLayer.path = path.cgPath
self.view.layer.addSublayer(shapeLayer)
let animation1 = CABasicAnimation(keyPath: "strokeEnd")
animation1.fromValue = 0
animation1.duration = 1
shapeLayer.add(animation1, forKey: "MyAnimation")
// Create squareView and add animation
let animation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.position))
animation.duration = 1
animation.repeatCount = MAXFLOAT
animation.path = path.cgPath
let squareView = UIView()
// Don't worry about x,y and width and height it will not affect the animation
squareView.frame = CGRect(x: 0, y: 0, width: 50, height: 50)
squareView.backgroundColor = .lightGray
view.addSubview(squareView)
// You can also pass any unique string value for key
squareView.layer.add(animation, forKey: nil)
}
You can see this tutorial for more help http://mathewsanders.com/animations-in-swift-part-two/

Animate transparency of part of mask with CAShapeLayer

I have a view with .brown backgroundColor.
In that view I have a backgroundView, which has backgroundColor: UIColor.black.withAlphaComponent(0.5).
I then want to have a transparent box in this backgroundView which shows the underlaying view. And I have achieved that, but I can't do it by animating its transparency, my code only grows the "cut out area" until desired size instead of animating it's transparency.
Brown view with the backgroundView and it's 0.5 alpha
Added layer to backgroundView shows part of the brown view
The code I've written to get what I have now is this:
func addIndicatorTo(global frame: CGRect) {
let shape = CAShapeLayer()
let targetRect = convert(frame, from: nil).insetBy(dx: -4, dy: -4)
let animation = CABasicAnimation(keyPath: "path")
let toPath = CGMutablePath()
toPath.addRect(bounds)
toPath.addPath(UIBezierPath(roundedRect: targetRect,
byRoundingCorners: .allCorners,
cornerRadii: CGSize(width: 4, height: 4)).cgPath)
toPath.closeSubpath()
let fromPath = CGMutablePath()
fromPath.addRect(bounds)
fromPath.addPath(UIBezierPath(roundedRect: targetRect.insetBy(dx: targetRect.width / 2,
dy: targetRect.height / 2),
byRoundingCorners: .allCorners,
cornerRadii: CGSize(width: 4, height: 4)).cgPath)
fromPath.closeSubpath()
animation.fromValue = fromPath
animation.toValue = toPath
animation.duration = 0.5
shape.fillRule = .evenOdd
shape.path = animation.fromValue as! CGPath
backgroundView.layer.mask = shape
shape.add(animation, forKey: nil)
CATransaction.begin()
CATransaction.setDisableActions(true)
shape.path = toPath
CATransaction.commit()
}
Not sure if this is a "good" solution. But I will go with this until someone tells shows me how to solve it more elegantly!
Basically I just create a mask with the box cut out, then I create a new boxLayer with draws in the area of the "cutout". Then I just animate the opacity of the boxLayer.
func addIndicatorTo(global frame: CGRect) {
let maskLayer = CAShapeLayer()
let boxLayer = CAShapeLayer()
let targetRect = convert(frame, from: nil).insetBy(dx: -4, dy: -4)
let animation = CABasicAnimation(keyPath: "opacity")
let toPath = CGMutablePath()
let boxPath = UIBezierPath(roundedRect: targetRect,
byRoundingCorners: .allCorners,
cornerRadii: CGSize(width: 4, height: 4)).cgPath
toPath.addRect(bounds)
toPath.addPath(boxPath)
toPath.closeSubpath()
boxLayer.fillColor = UIColor.black.withAlphaComponent(0.5).cgColor
boxLayer.path = boxPath
animation.fromValue = 1.0
animation.toValue = 0.0
animation.duration = 0.5
maskLayer.fillRule = .evenOdd
maskLayer.path = toPath
backgroundView.layer.mask = maskLayer
layer.addSublayer(boxLayer)
boxLayer.add(animation, forKey: nil)
CATransaction.begin()
CATransaction.setDisableActions(true)
boxLayer.opacity = 0.0
CATransaction.commit()
}

Thin border when using CAShapeLayer as mask for CAShapeLayer

In Swift, I have two semi-transparent circles, both of which are CAShapeLayer. Since they are semi-transparent, any overlap between them becomes visible like so:
Instead, I want them to visually "merge" together. The solution I have tried is to use circle 2 as a mask for circle 1, therefore cutting away the overlap.
This solution is generally working, but I get a thin line on the outside of circle 2:
My question: How can I get rid of the thin, outside line on the right circle? Why is it even there?
The code is as follows (Xcode playground can be found here):
private let yPosition: CGFloat = 200
private let circle1Position: CGFloat = 30
private let circle2Position: CGFloat = 150
private let circleDiameter: CGFloat = 200
private var circleRadius: CGFloat { return self.circleDiameter/2.0 }
override func loadView() {
let view = UIView()
view.backgroundColor = .black
self.view = view
let circle1Path = UIBezierPath(
roundedRect: CGRect(
x: circle1Position,
y: yPosition,
width: circleDiameter,
height: circleDiameter),
cornerRadius: self.circleDiameter)
let circle2Path = UIBezierPath(
roundedRect: CGRect(
x: circle2Position,
y: yPosition,
width: circleDiameter,
height: circleDiameter),
cornerRadius: self.circleDiameter)
let circle1Layer = CAShapeLayer()
circle1Layer.path = circle1Path.cgPath
circle1Layer.fillColor = UIColor.white.withAlphaComponent(0.6).cgColor
let circle2Layer = CAShapeLayer()
circle2Layer.path = circle2Path.cgPath
circle2Layer.fillColor = UIColor.white.withAlphaComponent(0.6).cgColor
self.view.layer.addSublayer(circle1Layer)
self.view.layer.addSublayer(circle2Layer)
//Create a mask from the surrounding rectangle of circle1, and
//then cut out where it overlaps circle2
let maskPath = UIBezierPath(rect: CGRect(x: circle1Position, y: yPosition, width: circleDiameter, height: circleDiameter))
maskPath.append(circle2Path)
maskPath.usesEvenOddFillRule = true
maskPath.lineWidth = 0
let maskLayer = CAShapeLayer()
maskLayer.path = maskPath.cgPath
maskLayer.fillColor = UIColor.black.cgColor
maskLayer.fillRule = kCAFillRuleEvenOdd
circle1Layer.mask = maskLayer
}
If both CAShapeLayers have the same alpha value, you could place them inside a new parent CALayer then set the alpha of the parent instead.

Shadow on UIView layer

I've make a path in order to mask my view:
let path = // create magic path (uiview bounds + 2 arcs)
let mask = CAShapeLayer()
mask.path = path.cgPath
view.layer.masksToBounds = false
view.layer.mask = mask
Up to here all ok.
Now I would like to add a shadow that follows path, is it possibile?
I try in several way, the last one is:
mask.shadowPath = path.cgPath
mask.shadowColor = UIColor.red.cgColor
mask.shadowOffset = CGSize(width: 10, height: 2.0)
mask.shadowOpacity = 0.5
But this produce a partial shadow and with color of the original view..
With debug view hierarchy:
Any advice?
Final result should be similar to this, but with shadow that "follows" arcs on path.
When you add a mask to a layer, it clips anything outside that mask - including the shadow. To achieve this you'll need to add a "shadow" view below your masked view, that has the same path as the mask.
Or add a shadow layer to the masked view's superview.
let view = UIView(frame: CGRect(x: 50, y: 70, width: 100, height: 60))
view.backgroundColor = .cyan
let mask = CAShapeLayer()
mask.path = UIBezierPath(roundedRect: view.bounds, cornerRadius: 10).cgPath
view.layer.mask = mask
let shadowLayer = CAShapeLayer()
shadowLayer.frame = view.frame
shadowLayer.path = UIBezierPath(roundedRect: view.bounds, cornerRadius: 10).cgPath
shadowLayer.shadowOpacity = 0.5
shadowLayer.shadowRadius = 5
shadowLayer.masksToBounds = false
shadowLayer.shadowOffset = .zero
let container = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
container.backgroundColor = .white
container.layer.addSublayer(shadowLayer)
container.addSubview(view)
If you're going to be using this elsewhere, you could create a ShadowMaskedView that contains the shadow layer, and the masked view - maybe with a path property.
You can try this extension:
extension UIView {
func dropShadow() {
self.layer.masksToBounds = false
self.layer.shadowColor = UIColor.black.cgColor
self.layer.shadowOpacity = 0.5
self.layer.shadowOffset = CGSize(width: -1, height: 1)
self.layer.shadowRadius = 1
self.layer.shadowPath = UIBezierPath(rect: self.bounds).cgPath
self.layer.shouldRasterize = true
}
}

iOS invert mask in drawRect

With the code below, I am successfully masking part of my drawing, but it's the inverse of what I want masked. This masks the inner portion of the drawing, where I would like to mask the outer portion. Is there a simple way to invert this mask?
myPath below is a UIBezierPath.
CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init];
CGMutablePathRef maskPath = CGPathCreateMutable();
CGPathAddPath(maskPath, nil, myPath.CGPath);
[maskLayer setPath:maskPath];
CGPathRelease(maskPath);
self.layer.mask = maskLayer;
With even odd filling on the shape layer (maskLayer.fillRule = kCAFillRuleEvenOdd;) you can add a big rectangle that covers the entire frame and then add the shape you are masking out. This will in effect invert the mask.
CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init];
CGMutablePathRef maskPath = CGPathCreateMutable();
CGPathAddRect(maskPath, NULL, someBigRectangle); // this line is new
CGPathAddPath(maskPath, nil, myPath.CGPath);
[maskLayer setPath:maskPath];
maskLayer.fillRule = kCAFillRuleEvenOdd; // this line is new
CGPathRelease(maskPath);
self.layer.mask = maskLayer;
For Swift 3.0
func mask(viewToMask: UIView, maskRect: CGRect, invert: Bool = false) {
let maskLayer = CAShapeLayer()
let path = CGMutablePath()
if (invert) {
path.addRect(viewToMask.bounds)
}
path.addRect(maskRect)
maskLayer.path = path
if (invert) {
maskLayer.fillRule = kCAFillRuleEvenOdd
}
// Set the mask of the view.
viewToMask.layer.mask = maskLayer;
}
Based on the accepted answer, here's another mashup in Swift. I've made it into a function and made the invert optional
class func mask(viewToMask: UIView, maskRect: CGRect, invert: Bool = false) {
let maskLayer = CAShapeLayer()
let path = CGPathCreateMutable()
if (invert) {
CGPathAddRect(path, nil, viewToMask.bounds)
}
CGPathAddRect(path, nil, maskRect)
maskLayer.path = path
if (invert) {
maskLayer.fillRule = kCAFillRuleEvenOdd
}
// Set the mask of the view.
viewToMask.layer.mask = maskLayer;
}
Here's my Swift 4.2 solution that allows a corner radius
extension UIView {
func mask(withRect maskRect: CGRect, cornerRadius: CGFloat, inverse: Bool = false) {
let maskLayer = CAShapeLayer()
let path = CGMutablePath()
if (inverse) {
path.addPath(UIBezierPath(roundedRect: self.bounds, cornerRadius: cornerRadius).cgPath)
}
path.addPath(UIBezierPath(roundedRect: maskRect, cornerRadius: cornerRadius).cgPath)
maskLayer.path = path
if (inverse) {
maskLayer.fillRule = CAShapeLayerFillRule.evenOdd
}
self.layer.mask = maskLayer;
}
}
Swift 5
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let red = UIView(frame: view.bounds)
view.addSubview(red)
view.backgroundColor = UIColor.cyan
red.backgroundColor = UIColor.red
red.mask(CGRect(x: 50, y: 50, width: 50, height: 50), invert: true)
}
}
extension UIView{
func mask(_ rect: CGRect, invert: Bool = false) {
let maskLayer = CAShapeLayer()
let path = CGMutablePath()
if (invert) {
path.addRect(bounds)
}
path.addRect(rect)
maskLayer.path = path
if (invert) {
maskLayer.fillRule = CAShapeLayerFillRule.evenOdd
}
// Set the mask of the view.
layer.mask = maskLayer
}
}
Thanks #arvidurs
For Swift 4.2
func mask(viewToMask: UIView, maskRect: CGRect, invert: Bool = false) {
let maskLayer = CAShapeLayer()
let path = CGMutablePath()
if (invert) {
path.addRect(viewToMask.bounds)
}
path.addRect(maskRect)
maskLayer.path = path
if (invert) {
maskLayer.fillRule = .evenOdd
}
// Set the mask of the view.
viewToMask.layer.mask = maskLayer;
}
In order to invert mask you can to something like this
Here I have mask of crossed rectancles like
let crossPath = UIBezierPath(rect: cutout.insetBy(dx: 30, dy: -5))
crossPath.append(UIBezierPath(rect: cutout.insetBy(dx: -5, dy: 30)))
let crossMask = CAShapeLayer()
crossMask.path = crossPath.cgPath
crossMask.backgroundColor = UIColor.clear.cgColor
crossMask.fillRule = .evenOdd
And here I add 3rd rectancle around my crossed rectancles
so using .evenOdd it takes area that is equal to (New Rectancle - Old Crossed Rectangle) so in other words outside area of crossed rectancles
let crossPath = UIBezierPath(rect: cutout.insetBy(dx: -5, dy: -5) )
crossPath.append(UIBezierPath(rect: cutout.insetBy(dx: 30, dy: -5)))
crossPath.append(UIBezierPath(rect: cutout.insetBy(dx: -5, dy: 30)))
let crossMask = CAShapeLayer()
crossMask.path = crossPath.cgPath
crossMask.backgroundColor = UIColor.clear.cgColor
crossMask.fillRule = .evenOdd
There are many great answers, all of them using even-odd rule for resolving interior and exterior surfaces. Here is Swift 5 approach with non-zero rule:
extension UIView {
func mask(_ rect: CGRect, cornerRadius: CGFloat, invert: Bool = false) {
let maskLayer = CAShapeLayer()
let path = CGMutablePath()
if (invert) {
path.move(to: .zero)
path.addLine(to: CGPoint(x: 0, y: bounds.height))
path.addLine(to: CGPoint(x: bounds.width, y: bounds.height))
path.addLine(to: CGPoint(x: bounds.width, y: 0))
path.addLine(to: .zero)
}
path.addPath(UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).cgPath)
maskLayer.path = path
layer.mask = maskLayer
}
}
For masking operation it is pretty straight forward. We draw rect and use it as masking path.
For inverted masking we use bounds of the view that is going to be masked. Then we draw it counter clockwise. After that we add UIBezierPath rect, which is by default drawn clockwise. That way any point p inside the rect will have one clockwise intersection and one counter clockwise intersection, leading to total winding number of zero, making that point an exterior point.
For 2023. All answers currently here seem wrong as they don't adjust the mask when the layout changes (or - just one example - when the view is animating in size or shape).
It's very simple ..
1. have a layer (perhaps just a square of color, an image, whatever)
lazy var examp: CALayer = {
let l = CALayer()
l.background = UIColor.blue.cgColor
l.mask = shapeAsMask
layer.addSublayer(l)
return l
}()
Note that examp has its mask set already.
2. Whenever you want to mask, you of course need a masking layer on hand
lazy var shapeAsMask: CAShapeLayer = {
let s = CAShapeLayer()
s.fillRule = .evenOdd
layer.addSublayer(s)
layer.mask = s
return s
}()
Note that the fillrule is set as needed.
3. Now make a shape with a bezier curve. Let's just make a circle:
override func layoutSubviews() {
super.layoutSubviews()
examp.frame = bounds
let i = bounds.width * 0.30
let thing = UIBezierPath(ovalIn: bounds.insetBy(dx: i, dy: i))
...
}
So that's a small circle in the middle of the view.
If you want to "have the shape" it's just
override func layoutSubviews() {
super.layoutSubviews()
fill.frame = bounds
let i = bounds.width * 0.30
let thing = UIBezierPath(ovalIn: bounds.insetBy(dx: i, dy: i))
shapeAsMask.path = thing.cgPath
}
If you want to "have the INVERSE OF the shape" it's just
override func layoutSubviews() {
super.layoutSubviews()
fill.frame = bounds
let i = bounds.width * 0.30
let thing = UIBezierPath(ovalIn: bounds.insetBy(dx: i, dy: i))
let p = CGMutablePath()
p.addRect(bounds)
p.addPath(thing.cgPath)
shapeAsMask.path = p
}
In short, the "negative" of this ..
let thing = UIBezierPath( ... some path
is just this:
let neg = CGMutablePath()
neg.addRect(bounds)
neg.addPath(thing.cgPath)
So that's it.
Don't forget that ...
Anytime you have a mask (or a layer!) you have to set it in layoutSubviews. (That's why layoutSubviews is named layoutSubviews !)

Resources