Invert simple UIView mask (cut hole instead of clip to circle) [duplicate] - ios

This question already has answers here:
How can I 'cut' a transparent hole in a UIImage?
(4 answers)
Closed 1 year ago.
I'm trying to avoid CAShapeLayer because I would need to mess with CABasicAnimation, not UIView.animate. So instead, I'm just using UIView's mask property to mask views. Here's my code currently:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let imageView = UIImageView(frame: CGRect(x: 50, y: 50, width: 200, height: 300))
imageView.image = UIImage(named: "TestImage")
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
view.addSubview(imageView)
let maskView = UIView(frame: CGRect(x: 100, y: 100, width: 80, height: 80))
maskView.backgroundColor = UIColor.blue /// ensure opaque
maskView.layer.cornerRadius = 10
imageView.mask = maskView /// set the mask
}
}
Without imageView.mask = maskView
With imageView.mask = maskView
It makes a portion of the image view visible. However, this is what I want:
Instead of making part of the image view visible, how can I cut a hole in it?

You can create an image view and set that as your mask. Note that this does not lend itself to animation. If you want to animate the mask to different shapes, you should add a mask to your view's CALayer and use CALayerAnimation, as you mention. It's not that bad.
Below I outline how to generate an image with a transparent part (a hole) that you can use as a mask in an image view. If your goal is to animate the size, shape, or position of the hole, however, this won't work. You'd have to regenerate the mask image for every frame, which would be really slow.
Here's how you would get the effect your are after for static views using an image view as a mask:
Use UIGraphicsBeginImageContextWithOptions() UIGraphicsImageRenderer to create an image that is opaque for most of your image, and has a transparent "hole" where you want a hole.
Then install that image in your image view, and make that image view your mask.
The code to create a mostly opaque image with a transparent rounded rect "hole" might look like this:
/**
Function to create a UIImage that is mostly opaque, with a transparent rounded rect "knockout" in it. Such an image might be used ask a mask
for another view, where the transparent "knockout" appears as a hole in the view that is being masked.
- Parameter size: The size of the image to create
- Parameter transparentRect: The (rounded )rectangle to make transparent in the middle of the image.
- Parameter cornerRadius: The corner radius ot use in the transparent rectangle. Pass 0 to make the rectangle square-cornered.
*/
func imageWithTransparentRoundedRect(size: CGSize, transparentRect: CGRect, cornerRadius: CGFloat) -> UIImage? {
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { (context) in
let frame = CGRect(origin: .zero, size: size)
UIColor.white.setFill()
context.fill(frame)
let roundedRect = UIBezierPath(roundedRect: transparentRect, cornerRadius: cornerRadius)
context.cgContext.setFillColor(UIColor.clear.cgColor)
context.cgContext.setBlendMode(.clear)
roundedRect.fill()
}
return image
}
And a viewDidLoad method that installs a UIImageView with a mask image view with a hole in it might look like this:
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .cyan
let size = CGSize(width: 200, height: 300)
let origin = CGPoint(x: 50, y: 50)
let frame = CGRect(origin: origin, size: size)
let imageView = UIImageView(frame: frame)
imageView.image = UIImage(named: "TestImage")
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
view.addSubview(imageView)
imageView.layer.borderWidth = 2
//Create a mask image view the same size as the (image) view we will be masking
let maskView = UIImageView(frame: imageView.bounds)
//Build an opaque UIImage with a transparent "knockout" rounded rect inside it.
let transparentRect = CGRect(x: 100, y: 100, width: 80, height: 80)
let maskImage = imageWithTransparentRoundedRect(size: size, transparentRect: transparentRect, cornerRadius: 20)
//Install the image with the "hole" into the mask image view
maskView.image = maskImage
//Make the maskView the ImageView's mask
imageView.mask = maskView /// set the mask
}
}
I created a sample project using the code above. You can download it from Github here:
https://github.com/DuncanMC/UIImageMask.git
I just updated the project to also show how to do the same thing using a CAShapeLayer as a mask on the image view's layer. Doing it that way, it's possible to animate changes to the mask layer's path.
The new version has a segmented control that lets you pick whether to mask the image view using a UIImage in the view's mask property, or via a CAShapeLayer used as a mask on the image view's layer.
For the CAShapeLayer version, the mask layer's path is a rectangle the size of the whole image view, with a second, smaller rounded rectangle drawn inside it. The winding rule on the shape layer is then set to the "even/odd" rule, meaning that if you have to cross an even number of shape boundaries to get to a point, it is considered outside the shape. That enables you to create hollow shapes like we need here.
When you select the layer mask option, it enables an animation button that animates random changes to the "cutout" transparent rectangle in the mask.
The function that creates the mask path looks like this:
func maskPath(transparentRect: CGRect, cornerRadius: CGFloat) -> UIBezierPath {
let fullRect = UIBezierPath(rect: maskLayer.frame)
let roundedRect = UIBezierPath(roundedRect: transparentRect, cornerRadius: cornerRadius)
fullRect.append(roundedRect)
return fullRect
}
And the function that does the animation looks like this:
#IBAction func handleAnimateButton(_ sender: Any) {
//Create a CABasicAnimation that will change the path of our maskLayer
//Use the keypath "path". That tells the animation object what property we are animating
let animation = CABasicAnimation(keyPath: "path")
animation.autoreverses = true //Make the animation reverse back to the oringinal position once it's done
//Use ease-in, ease-out timing, which looks smooth
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
animation.duration = 0.3 //Make each step in the animation last 0.3 seconds.
let transparentRect: CGRect
//Randomly either animate the transparent rect to a different shape or shift it
if Bool.random() {
//Make the transparent rect taller and skinnier
transparentRect = self.transparentRect.inset(by: UIEdgeInsets(top: -20, left: 20, bottom: -20, right: 20))
} else {
//Shift the transparent rect to by a random amount that still says inside the image view's bounds.
transparentRect = self.transparentRect.offsetBy(dx: CGFloat.random(in: -100...20), dy: CGFloat.random(in: -100...100))
}
let cornerRadius: CGFloat = CGFloat.random(in: 0...30)
//install the new path as the animation's `toValue`. If we dont specify a `fromValue` the animation will start from the current path.
animation.toValue = maskPath(transparentRect: transparentRect, cornerRadius: cornerRadius).cgPath
//add the animation to the maskLayer. Since the animation's `keyPath` is "path",
//it will animate the layer's "path" property to the "toValue"
maskLayer.add(animation, forKey: nil)
//Since we don't actually change the path on the mask layer, the mask will revert to it's original path once the animation completes.
}
The results (using my own sample image) look like this:
A sample of the CALayer based mask animation looks like this:

Related

How do I make everything outside of a CAShapeLayer black with an opacity of 50% with Swift?

I have the following code which draws a shape:
let screenSize: CGRect = UIScreen.main.bounds
let cardLayer = CAShapeLayer()
let cardWidth = 350.0
let cardHeight = 225.0
let cardXlocation = (Double(screenSize.width) - cardWidth) / 2
let cardYlocation = (Double(screenSize.height) / 2) - (cardHeight / 2) - (Double(screenSize.height) * 0.05)
cardLayer.path = UIBezierPath(roundedRect: CGRect(x: 0, y: 0, width: cardWidth, height: 225.0), cornerRadius: 10.0).cgPath
cardLayer.position = CGPoint(x: cardXlocation, y: cardYlocation)
cardLayer.strokeColor = UIColor.white.cgColor
cardLayer.fillColor = UIColor.clear.cgColor
cardLayer.lineWidth = 4.0
self.previewLayer.insertSublayer(cardLayer, above: self.previewLayer)
I want everything outside of the shape to be black with an opacity of 50%. That way you can see the camera view still behind it, but it's dimmed, except where then shape is.
I tried adding a mask to previewLayer.mask but that didn't give me the effect I was looking for.
Your impulse to use a mask is correct, but let's think about what needs to be masked. You are doing three things:
Dimming the whole thing. Let's call that the dimming layer. It needs a dark semi-transparent background.
Drawing the white rounded rect. That's the shape layer.
Making a hole in the entire thing. That's the mask.
Now, the first two layers can be the same layer. That leaves only the mask. This is not trivial to construct: a mask affects its owner in terms entirely of its transparency, so we need a mask that is opaque except for an area shaped like the shape of the shape layer, which needs to be clear. To get that, we start with the shape and clip to that shape as we fill the mask — or we can clip to that shape as we erase the mask, which is the approach I prefer.
In addition, your code has some major flaws, the most important of which is that your shape layer has no size. Without a size, there is nothing to mask.
So here, with corrections and additions, is your code; I made this the entirety of a view controller, for testing purposes, and what I'm covering is the entire view controller's view rather than a particular subview or sublayer:
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .red
}
private var didInitialLayout = false
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if didInitialLayout {
return
}
didInitialLayout = true
let screenSize = UIScreen.main.bounds
let cardLayer = CAShapeLayer()
cardLayer.frame = self.view.bounds
self.view.layer.addSublayer(cardLayer)
let cardWidth = 350.0 as CGFloat
let cardHeight = 225.0 as CGFloat
let cardXlocation = (screenSize.width - cardWidth) / 2
let cardYlocation = (screenSize.height / 2) - (cardHeight / 2) - (screenSize.height * 0.05)
let path = UIBezierPath(roundedRect: CGRect(
x: cardXlocation, y: cardYlocation, width: cardWidth, height: cardHeight),
cornerRadius: 10.0)
cardLayer.path = path.cgPath
cardLayer.strokeColor = UIColor.white.cgColor
cardLayer.lineWidth = 8.0
cardLayer.backgroundColor = UIColor.black.withAlphaComponent(0.5).cgColor
let mask = CALayer()
mask.frame = cardLayer.bounds
cardLayer.mask = mask
let r = UIGraphicsImageRenderer(size: mask.bounds.size)
let im = r.image { ctx in
UIColor.black.setFill()
ctx.fill(mask.bounds)
path.addClip()
ctx.cgContext.clear(mask.bounds)
}
mask.contents = im.cgImage
}
And here's what we get. I didn't have a preview layer but the background is red, and as you see, the red shows through inside the white shape, which is just the effect you are looking for.
The shape layer can only affect what it covers, not the space it doesn't cover. Make a path that covers the entire video and has a hole in it where the card should be.
let areaToDarken = previewLayer.bounds // assumes origin at 0, 0
let areaToLeaveClear = areaToDarken.insetBy(dx: 50, dy: 200)
let shapeLayer = CAShapeLayer()
let path = CGPathCreateMutable()
path.addRect(areaToDarken, ...)
path.addRoundedRect(areaToLeaveClear, ...)
cardLayer.frame = previewLayer.bounds // use frame if shapeLayer is sibling
cardLayer.path = path
cardLayer.fillRule = .evenOdd // allow holes
cardLayer.fillColor = black, 50% opacity

How to draw a rounded rectangle with UIBezierPath?

I'm trying to mask a view with a rounded rectangle UIBezierPath. I want the mask to look exactly the same as if I were just setting layer.cornerRadius:
let frame = CGRect(x: 0, y: 0, width: 80, height: 80)
let cornerRadius = 30
Using cornerRadius:
let view = UIView(frame: frame)
view.layer.cornerRadius = cornerRadius
Using UIBezierPath mask:
let view = UIView(frame: frame)
let maskingShape = CAShapeLayer()
maskingShape.path = UIBezierPath(roundedRect: frame, cornerRadius: cornerRadius).cgPath
view.layer.mask = maskingShape
The resulting rounded rects are completely different. The standard cornerRadius works as expected while the bezier path just snaps to a full circle at a certain radius.
Apparently, this is intended behaviour from iOS 7.
How then do I draw a standard rounded rectangle with a bezier path?
I found this category but this has to be a joke right? Is there no simpler way? :(
Related question.

scrolling between invisible rects

Take a look at this picture :
Imagine a scroll view, where the pink rects are images each on a scroller page.
Imagine that the blue rects are invisible rects different in size on each page.
Imagine that the yellow rect is an image that is static = not on the scroll view.
I would like to scroll between 2 and 3, where the yellow stay at the same position, BUT it disappear when the blue rects moving. (you see it only inside)
In rect 3 you can see what happen to the yellow while scrolling .
How should I put my layers on the scroller to create such an effect ?
So based on comments, it look like you need a scrollview with simple views.
This views can be with overlay that has a hole inside, take a look at code for making layer with hole:
func layerWith(img: UIImage) -> CALayer {
let overlay = UIView(frame: CGRect(x: 0, y: 0,
width: UIScreen.main.bounds.width,
height: UIScreen.main.bounds.height))
// Create the initial layer from the view bounds.
let maskLayer = CAShapeLayer()
maskLayer.frame = overlay.bounds
let myImage = UIImage(named: "star")?.cgImage
maskLayer.frame = myView.bounds
maskLayer.contents = myImage
// Create the frame.
let radius: CGFloat = 150.0
let rect = CGRect(x: overlay.frame.midX - radius,
y: overlay.frame.midY - radius,
width: 2 * radius,
height: 2 * radius)
// Create the path.
let path = UIBezierPath(rect: overlay.bounds)
maskLayer.fillRule = kCAFillRuleEvenOdd
// Append the rect to the path so that it is subtracted.
path.append(UIBezierPath(rect: rect))
maskLayer.path = path.cgPath
// Set the mask of the view.
overlay.layer.mask = maskLayer
// Add the view so it is visible.
return overlay
}
And some code to add subviews into scrollview:
var i: CGFloat = 0
//or width that you want
let width = UIApplication.shared.keyWindow!.frame.width
for item in items {
let frame = CGRect(x: width * i, y: 0, width: width, height: scrollView.frame.height)
let v = UIView(frame: frame)
//here you can add layer from code above to this view before adding it to scrollview
v.layer.addSublayer(layerWith(img:your image goes here))
self.scrollView.addSubview(v)
i += 1
}

CALayer does not respect zero shadowRadius while drawing?

It seems that CALayer's property shadowRadius is non-zero while drawing even if explicitelly set to zero when following conditions are met:
shadowPath property is also set on the same CALayer, and
shadowPath set is something else than plain rectangular path
To reproduce the issue I created 2 rounded CALayers with shadow that are identical in everything but shadow path - first one has shadowPath set to nil, second one has shadowPath set to its own shape. I would expect those 2 to render into exactly the same picture but they do not. Here is the result (magnified):
As you can see second rectangle obviously has some shadow radius higher then 0 even though it was set to 0. Here is code used to produce the picture above:
override func viewDidLoad() {
super.viewDidLoad()
let layerWithoutShadowPath = CALayer() //shadow of this rectangle will be drawn correctly
let layerWithShadowPath = CALayer() //shadow of this one will be drawn with radius higher then 0
layerWithoutShadowPath.frame = CGRect(x: 30, y: 30, width: 30, height: 30)
layerWithShadowPath.frame = CGRect(x: 70, y: 30, width: 30, height: 30)
layerWithShadowPath.shadowPath = UIBezierPath(roundedRect: layerWithShadowPath.bounds, cornerRadius: 4.0).CGPath
setupLayer(layerWithoutShadowPath)
setupLayer(layerWithShadowPath)
}
private func setupLayer(layer: CALayer) {
layer.backgroundColor = UIColor.yellowColor().colorWithAlphaComponent(1).CGColor
layer.cornerRadius = 4.0
layer.shadowColor = UIColor.blackColor().CGColor
layer.shadowOffset = CGSize(width: 0, height: 3)
layer.shadowRadius = 0 //SHADOW RADIUS IS SET TO 0 FOR BOTH RECTANGLES
layer.shadowOpacity = 1
view.layer.addSublayer(layer)
}
This might be a Cocoa bug or I am just missing something... Anyway, the question is: How can add rounded rectangle shadow with exactly zero radius to CALayer while shadowPath is set? (if you are wondering why I must set shadowPath the reason is performance)
Well, you might be able to fix it by setting the layer's edgeAntialiasingMask. However, it seems to me that you are needlessly trapped in a world of layer inefficiency - as if the only thing you knew how to do was make the layer draw itself with rounded corners and a shadow. What I would do is draw the yellow rounded square and its shadow as an actual drawing, and make that drawing the content of a layer:
let layer = CALayer()
layer.contentsScale = UIScreen.mainScreen().scale
layer.frame = CGRect(x: 30, y: 30, width: 30, height: 35)
UIGraphicsBeginImageContextWithOptions(layer.bounds.size, false, 0)
let con = UIGraphicsGetCurrentContext()
CGContextSetShadowWithColor(con, CGSizeMake(0,3), 1, UIColor.blackColor().CGColor)
let path = UIBezierPath(roundedRect: CGRectMake(0,0,30,30), cornerRadius: 4)
UIColor.yellowColor().setFill()
path.fill()
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
layer.contents = im.CGImage
view.layer.addSublayer(layer)
This has the advantage that the shadow is part of the drawing, and so there is no inefficiency - the very inefficiency you say you are trying to avoid - as there would be if you give the layer itself a shadow and thus compelled the render tree to do the work. Indeed, the same thing is true for the rounded rectangle: it is much more efficient to draw a rounded rectangle, as I am doing here, than to force the render tree to round the layer's corners for you, as you are doing.

Simply mask a UIView with a rectangle

I want to know how to simply mask the visible area of a UIView of any kind. All the answers/tutorials I've read so far describe masking with an image, gradient or creating round corners which is way more advanced than what I am after.
Example: I have a UIView with the bounds (0, 0, 100, 100) and I want to cut away the right half of the view using a mask. Therefore my mask frame would be (0, 0, 50, 100).
Any idea how to do this simply? I don't want to override the drawrect method since this should be applicable to any UIView.
I've tried this but it just makes the whole view invisible.
CGRect mask = CGRectMake(0, 0, 50, 100);
UIView *maskView = [[UIView alloc] initWithFrame:mask];
viewToMask.layer.mask = maskView.layer;
Thanks to the link from MSK, this is the way I went with which works well:
// Create a mask layer and the frame to determine what will be visible in the view.
CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init];
CGRect maskRect = CGRectMake(0, 0, 50, 100);
// Create a path with the rectangle in it.
CGPathRef path = CGPathCreateWithRect(maskRect, NULL);
// Set the path to the mask layer.
maskLayer.path = path;
// Release the path since it's not covered by ARC.
CGPathRelease(path);
// Set the mask of the view.
viewToMask.layer.mask = maskLayer;
Thanks for answers guys.
In case someone can't find suitable answer on SO for this question for hours, like i just did, i've assembled a working gist in Swift 2.2 for masking/clipping UIView with CGRect/UIBezierPath:
https://gist.github.com/Flar49/7e977e81f1d2827f5fcd5c6c6a3c3d94
extension UIView {
func mask(withRect rect: CGRect, inverse: Bool = false) {
let path = UIBezierPath(rect: rect)
let maskLayer = CAShapeLayer()
if inverse {
path.append(UIBezierPath(rect: self.bounds))
maskLayer.fillRule = kCAFillRuleEvenOdd
}
maskLayer.path = path.cgPath
self.layer.mask = maskLayer
}
func mask(withPath path: UIBezierPath, inverse: Bool = false) {
let path = path
let maskLayer = CAShapeLayer()
if inverse {
path.append(UIBezierPath(rect: self.bounds))
maskLayer.fillRule = kCAFillRuleEvenOdd
}
maskLayer.path = path.cgPath
self.layer.mask = maskLayer
}
}
Usage:
let viewSize = targetView.bounds.size
let rect = CGRect(x: 20, y: 20, width: viewSize.width - 20*2, height: viewSize.height - 20*2)
// Cuts rectangle inside view, leaving 20pt borders around
targetView.mask(withRect: rect, inverse: true)
// Cuts 20pt borders around the view, keeping part inside rect intact
targetView.mask(withRect: rect)
Hope it will save someone some time in the future :)
No need any mask at all.
Just put it into a wrapper view with the smaller frame, and set clipsToBounds.
wrapper.clipsToBounds = true
Very simple example in a Swift ViewController, based on the accepted answer:
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let redView = UIView(frame: view.bounds)
view.addSubview(redView)
view.backgroundColor = UIColor.green
redView.backgroundColor = UIColor.red
mask(redView, maskRect: CGRect(x: 50, y: 50, width: 50, height: 50))
}
func mask(_ viewToMask: UIView, maskRect: CGRect) {
let maskLayer = CAShapeLayer()
let path = CGPath(rect: maskRect, transform: nil)
maskLayer.path = path
// Set the mask of the view.
viewToMask.layer.mask = maskLayer
}
}
Output
An optional layer whose alpha channel is used as a mask to select
between the layer's background and the result of compositing the
layer's contents with its filtered background.
#property(retain) CALayer *mask
The correct way to do what you want is to create the maskView of the same frame (0, 0, 100, 100) as the viewToMask which layer you want to mask. Then you need to set the clearColor for the path you want to make invisible (this will block the view interaction over the path so be careful with the view hierarchy).
Swift 5 , thanks #Dan Rosenstark
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))
}
}
extension UIView{
func mask(_ rect: CGRect){
let mask = CAShapeLayer()
let path = CGPath(rect: rect, transform: nil)
mask.path = path
// Set the mask of the view.
layer.mask = mask
}
}
Setting MaskToBounds is not enough, for example, in scenario where you have UIImageView that is positioned inside RelativeLayout. In case that you put your UIImageView to be near top edge of the layout and then you rotate your UIImageView, it will go over layout and it won't be cropped. If you set that flag to true, it will be cropped yes, but it will also be cropped if UIImageView is on the center of layout, and that is not what you want.
So, determinating maskRect is the right approach.

Resources